Next.js CVE-2024-34351: The Host Header Is User Input

How a relative redirect in Next.js Server Actions became SSRF, and why internal requests must never build URLs from the Host header.

Next.js CVE-2024-34351: The Host Header Is User Input
In this essay

CVE-2024-34351 was a server-side request forgery (SSRF) bug in Next.js Server Actions. It was patched in 2024, but the mistake behind it is still common: building an internal request URL from the incoming Host header. Upgrading Next.js is part of the lesson. The more durable part is that Host is user input, and any URL derived from it can be influenced by an attacker.

What actually went wrong

The GitHub advisory GHSA-fr5h-rqp8-mj6g covers Next.js from 13.4.0 up to, but not including, 14.1.1. It was published on May 9, 2024. It scored 7.5 (High) under CVSS 3.1 and is classified as CWE-918. Adam Kues and Shubham Shah of Assetnote reported it and published a detailed write-up.

Exploitation required three conditions at once:

  • The app was self-hosted, for example a Node.js server or container behind an ordinary reverse proxy.
  • It used Server Actions ('use server').
  • A Server Action called redirect() with a relative path such as /dashboard.

The third condition is where the design gets interesting. After an action redirects, the client wants the destination page's React Server Components payload. That lets it switch views without a full reload. To produce that payload, the server made its own HTTP request for the target route. To turn /dashboard into an absolute URL, it took the host from the incoming request.

If that header carried an unexpected value, the server's "internal" fetch went to a machine chosen by whoever sent the request. The request still originated from the application server and carried that server's network access. That is the SSRF. The original research explains how the response could be steered further. This article stays on the defensive side and focuses on the design flaw.

According to the published details, the issue required self-hosting. Apps running on Vercel were not exposed to this scenario.

A redirect is not a self-call

Neatly routed network patch cables entering a brushed aluminum switch in a quiet server room with a raw concrete wall

The bug blends two concepts that look alike:

  • The public origin is the domain users see. You need it for absolute links, canonical URLs, and emails.
  • The internal address is how the process reaches itself or a sibling service, such as 127.0.0.1:3000.

The Host header reliably answers neither question. It states what the client claims to want. A reverse proxy may pass it through unchanged. X-Forwarded-Host is also client-controllable unless your proxy overwrites it.

Here is the anti-pattern in plain TypeScript. It shows up in hand-written middleware, SSR helpers, and "fetch my own API route" utilities in Express, Nuxt, and Next.js projects:

// Anti-pattern: the destination depends on client input
const base = `http://${req.headers.host}`;
const res = await fetch(new URL(path, base));

The fix is boring, which is the point:

// Internal calls go to a fixed address you control
const INTERNAL_ORIGIN =
  process.env.INTERNAL_ORIGIN ?? `http://127.0.0.1:${process.env.PORT ?? 3000}`;

const res = await fetch(new URL(path, INTERNAL_ORIGIN));

Even better, skip the network hop and call the underlying function directly. An HTTP request to yourself is often just a slower, riskier function call.

For public links, read a configured canonical origin, such as a PUBLIC_ORIGIN environment variable, instead of echoing the request back. The same habit protects password-reset emails from Host header poisoning, a separate but closely related bug class.

Upgrade, then look past the version number

The fix shipped in Next.js 14.1.1 through pull request #62561. The essential change is that the internal fetch's destination no longer depends on the Host value supplied by the client.

Treat 14.1.1 as a floor, not a target. Next.js has published further security advisories since then. Self-hosted teams should track a currently supported release line instead of stopping at the first version that closed this particular hole.

Upgrading closes the framework path. It does not fix the same pattern in your own code, which is why the next layers matter.

Defense in depth for self-hosted apps

Reject unknown hosts at the proxy

In Nginx, a catch-all default_server can drop requests whose Host matches no configured server_name. The real server block then sets forwarded headers explicitly:

server {
    listen 80 default_server;
    server_name _;
    return 444;  # drop requests for unknown hosts
}

server {
    listen 80;
    server_name app.example.com;

    location / {
        proxy_set_header Host app.example.com;
        proxy_set_header X-Forwarded-Host app.example.com;
        proxy_pass http://127.0.0.1:3000;
    }
}

On its own, proxy_set_header Host $host; simply forwards what the client sent. The protection comes from routing unknown names to the catch-all and from pinning the values you forward. Apply the same treatment to your TLS listener.

Copy Django's default posture

Django already treats Host as untrusted. request.get_host() validates the value against ALLOWED_HOSTS and rejects mismatches with a 400 response. USE_X_FORWARDED_HOST is off by default. JavaScript stacks rarely give you this out of the box, so add the equivalent yourself: a small middleware that compares Host against an allowlist and returns 400 on a mismatch.

Limit what the server can reach

The damage from SSRF scales with what the server can reach. Egress rules on containers should block internal admin ranges and any destinations the app never needs. On AWS EC2, enforcing IMDSv2 means metadata access requires a session token obtained with a PUT request, which a simple forged GET cannot supply. A hop limit of 1 also keeps that token from reaching workloads one network hop away, such as containers on bridge networking.

A quick audit

Search your codebase for places that read the request host:

grep -rnE "headers\.host|x-forwarded-host|get\('host'\)" src/

For each hit, ask two questions. Does this value decide where an outbound request goes? Does it end up in a redirect, link, or email? If either answer is yes, replace it with configuration or an allowlist check.

The framework bug is patched. The pattern that caused it can only be fixed through your own review habits. Treat the Host header with the same suspicion you would give a query string.

Sources

  1. tenable.com
  2. nvd.nist.gov
  3. cybersecurity-help.cz
  4. mindmix.blog
  5. mallory.ai
  6. slcyber.io

AI-assisted · Reviewed by PKN

0

Responses

Loading comments…