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 causeThe 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 dataReproduced 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)