JustAppSec
Back to guides

Next.js SSRF Protection

Server-Side Request Forgery (SSRF) happens when an attacker tricks your server into making requests to unintended destinations. In Next.js, this risk exists anywhere server-side code uses a URL or hostname from user input.

Where SSRF Happens in Next.js

Server Components

// app/preview/page.tsx — VULNERABLE
export default async function PreviewPage({ searchParams }: { searchParams: { url?: string } }) {
  const url = searchParams.url ?? "";
  // Attacker can set url=http://169.254.169.254/latest/meta-data/
  const res = await fetch(url);
  const data = await res.text();
  return <div>{data}</div>;
}

Server Actions

// VULNERABLE
"use server";
export async function fetchPreview(url: string) {
  const res = await fetch(url); // user controls the target
  return res.text();
}

Route Handlers

// app/api/proxy/route.ts — VULNERABLE
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const target = searchParams.get("target") ?? "";
  const res = await fetch(target); // open proxy
  return new Response(await res.text());
}

Image Optimization

Next.js <Image> with external sources can be an SSRF vector if remotePatterns is too permissive.

// next.config.ts — too permissive
const nextConfig = {
  images: {
    remotePatterns: [{ protocol: "https", hostname: "**" }], // allows ANY host
  },
};

Reference: Next.js — Image Optimization

Defences

1. Allowlist Domains

The most effective defence. Only allow requests to known, trusted domains:

const ALLOWED_HOSTS = new Set([
  "api.example.com",
  "cdn.example.com",
]);

function validateUrl(input: string): URL {
  const url = new URL(input);
  if (!ALLOWED_HOSTS.has(url.hostname)) {
    throw new Error("Host not allowed");
  }
  if (url.protocol !== "https:") {
    throw new Error("HTTPS required");
  }
  return url;
}

2. Block Private IP Ranges

If you cannot use a strict allowlist, block internal addresses:

import { isIP } from "net";
import dns from "dns/promises";

const PRIVATE_RANGES = [
  /^127\./,
  /^10\./,
  /^172\.(1[6-9]|2\d|3[01])\./,
  /^192\.168\./,
  /^169\.254\./,
  /^0\./,
  /^::1$/,
  /^fc00:/i,
  /^fe80:/i,
  /^fd/i,
];

function isPrivateIp(ip: string): boolean {
  return PRIVATE_RANGES.some((r) => r.test(ip));
}

async function validateNotInternal(hostname: string): Promise<void> {
  if (isIP(hostname)) {
    if (isPrivateIp(hostname)) {
      throw new Error("Private IP blocked");
    }
    return;
  }

  // Resolve both IPv4 and IPv6 to prevent bypass via AAAA records
  const [v4, v6] = await Promise.all([
    dns.resolve4(hostname).catch(() => []),
    dns.resolve6(hostname).catch(() => []),
  ]);

  for (const addr of [...v4, ...v6]) {
    if (isPrivateIp(addr)) {
      throw new Error("Resolved to private IP");
    }
  }
}

3. Disable Redirects

Attackers use open redirects to bypass hostname checks. The initial URL passes validation, then redirects to an internal address:

const res = await fetch(validatedUrl, {
  redirect: "error", // throw on any redirect
});

4. Restrict Next.js Image Domains

Lock down remotePatterns to specific hosts:

// next.config.ts
const nextConfig = {
  images: {
    remotePatterns: [
      { protocol: "https", hostname: "cdn.example.com" },
      { protocol: "https", hostname: "avatars.githubusercontent.com" },
    ],
  },
};

Reference: Next.js — remotePatterns

5. Network-Level Controls

  • In AWS, enforce IMDSv2 (requires a token, blocks SSRF to metadata): AWS — IMDSv2
  • In GCP, use the metadata concealment endpoint or workload identity.
  • Use VPC security groups and firewall rules to restrict outbound traffic from application servers.
  • Run applications in containers with restricted network policies.

6. Timeout and Size Limits

Even with validation, set conservative limits:

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);

const res = await fetch(validatedUrl, {
  signal: controller.signal,
  redirect: "error",
});

clearTimeout(timeout);

// Limit response size
const MAX_SIZE = 1024 * 1024; // 1 MB
const body = await res.text();
if (body.length > MAX_SIZE) {
  throw new Error("Response too large");
}

Cloud Metadata Endpoints to Block

CloudMetadata URL
AWShttp://169.254.169.254/latest/meta-data/
GCPhttp://metadata.google.internal/computeMetadata/
Azurehttp://169.254.169.254/metadata/instance
DigitalOceanhttp://169.254.169.254/metadata/v1/

Reference: AWS — Instance Metadata

Related Guides


Content is AI-assisted and reviewed by our team, but issues may be missed and best practices evolve rapidly, send corrections to [email protected]. Always consult official documentation and validate key implementation decisions before making design or security choices.

Need help?Get in touch.