home / findings / wm-2026-002

Next.js: server-side URL import reaches the cloud metadata service (SSRF)

ID
WM-2026-002
Severity
Critical  CVSS 3.1 base 9.3
Vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:N
Class
Server-side request forgery · CWE-918 Server-Side Request Forgery
Component
store.plankdesigns.co, Next.js 14, server action importFromUrl()
Status
Fixed, published after the fix
Published
2026-10-04

Executive summary

A server action that imports an image from a user-supplied URL performs the request from the application server, validates only that the URL starts with http, and returns the response to the caller. It can be pointed at the cloud provider's instance metadata service, which discloses the role name and, with the right follow-up request, the temporary credentials attached to the server.

Impact

  • Disclosure of instance metadata and the temporary cloud credentials of the application's role.
  • The impact extends beyond the application: the credentials act with whatever permissions the role has in the cloud account (scope changed).
  • Reachable without authentication on the public sign-up page.

Technical details

root cause

The sign-up form lets a user import a profile picture from a URL. The server action fetches the URL itself and returns a preview of the body:

// app/actions/import.ts (vulnerable)
"use server";
export async function importFromUrl(url: string) {
  if (!url.startsWith("http")) throw new Error("invalid url");
  const res = await fetch(url);                    // follows redirects, any host
  return { type: res.headers.get("content-type"), preview: (await res.text()).slice(0, 2000) };
}

There is no restriction on destination host, no check of the resolved address, redirects are followed, and the body is returned to the caller. Together these turn the server into a proxy into its own network, including the link-local metadata address that is reachable only from the instance.

A deny-list of the literal metadata address would not be enough. Alternative encodings of the same address, a hostname that resolves to it, and a redirect from an external URL all reach the same place.

Proof of concept

minimal, no real data

Reproduced in a lab environment with a mock metadata service. Credentials are redacted.

# 1. ask the server to fetch the metadata index
POST /signup   (server action importFromUrl)
url=http://169.254.169.254/latest/meta-data/iam/security-credentials/

# 2. response preview (role name only)
{"type":"text/plain","preview":"app-web-role"}

# 3. following request returns the credential document
#    AccessKeyId / SecretAccessKey / Token  ->  <REDACTED>

The credentials were not used. The test stopped at confirming that the document was returned.

Remediation

Treat any server-side fetch of a user-supplied URL as a request to an untrusted destination.

// fixed: allow-list, resolve first, block internal ranges, no redirects, no body echo
import { lookup } from "node:dns/promises";
import ipaddr from "ipaddr.js";

const ALLOWED = new Set(["images.example-cdn.com"]);

export async function importFromUrl(raw: string) {
  const u = new URL(raw);
  if (u.protocol !== "https:" || !ALLOWED.has(u.hostname)) throw new Error("not allowed");
  const { address } = await lookup(u.hostname);
  if (ipaddr.parse(address).range() !== "unicast") throw new Error("not allowed");
  const res = await fetch(u, { redirect: "manual" });
  if (!res.headers.get("content-type")?.startsWith("image/")) throw new Error("not an image");
  return storeImage(await res.arrayBuffer());      // return an id, never the raw body
}
  • Require the metadata service version that needs a session token (IMDSv2) and set its hop limit to 1 so a proxied request cannot reach it.
  • Restrict outbound traffic from the application with an egress filter so it can reach only what it needs.
  • Give the application role the least privilege, and rotate credentials if exposure is suspected.
  • Pin the resolved address for the request to avoid a second DNS lookup returning a different answer (DNS rebinding).

Detection

Alert on outbound requests from the application to link-local or private ranges, and on any request to the metadata service that does not come from the instance agent.

Disclosure timeline

days from report
d0
Report sent to the vendor security contact
d2
Acknowledged
d4
Server action disabled as a stop-gap
d21
Permanent fix and metadata hardening deployed
d90
Public disclosure window (published after the fix)

References

All findings

Tags

#web#nextjs#ssrf#cloud

Related

shares a tag