Server-side request forgery

Server-side request forgery

Description

Server-side request forgery (SSRF) occurs when user input controls the destination of an outgoing server request. An attacker may make the server contact internal addresses or protected services, such as a cloud metadata endpoint, to discover resources, obtain sensitive information or bypass access boundaries. Redirects, DNS rebinding and open redirects can also undermine destination checks.

Potential impact

  • The server may reach internal hosts behind network access controls on the attacker's behalf.
  • Internal APIs, database administration services or metadata endpoints may expose credentials or tokens.
  • Calls to internal management services may bypass intended access restrictions.
  • Repeated requests may exhaust application resources or overload backend services.
  • The server may be used to probe internal hosts and ports.

Remediation

  • Select hosts from fixed values or a predefined allow-list. Prefer mapping service identifiers to fixed URLs.
  • Restrict paths, reject directory traversal such as .., and construct URLs with a standard parser such as new URL.
  • Block destinations that should not be reachable, including loopback, private and link-local ranges such as 127.0.0.0/8, 10.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12 and 192.168.0.0/16, plus metadata endpoints.
  • Allow only required HTTP or HTTPS destinations; reject protocols such as file://, ftp:// and gopher://.
  • Disable automatic redirects or apply the same destination checks to every redirect.

Examples

Before

javascript
// Request the supplied URL directly, risking SSRF
const express = require("express");
const axios = require("axios");
const app = express();

app.get("/proxy", async (req, res) => {
  try {
    const url = req.query.url; // For example: http://127.0.0.1:8080/admin
    if (!url) return res.status(400).send("url required");

    // Use the user-supplied URL directly
    const resp = await axios.get(url, { timeout: 5000 });
    res.send(resp.data);
  } catch (e) {
    res.status(502).send("bad gateway");
  }
});

app.listen(3000);

After

javascript
// Map identifiers to fixed URLs and restrict paths
const express = require("express");
const axios = require("axios");
const app = express();

// 1) Map allowed service identifiers to fixed base URLs
const SERVICE_BASE = new Map([
  ["status", new URL("https://status.example.com")],
  ["api", new URL("https://api.example.com")],
]);

// 2) Require one initial '/' and allow only alphanumerics, /, - and _
//    Reject protocol-relative URLs such as '//evil.example' and '..' paths.
const SAFE_PATH = /^\/(?!\/)[A-Za-z0-9_\/-]*$/;

app.get("/proxy-safe", async (req, res) => {
  try {
    const { service, path = "/" } = req.query;

    // Validate the service identifier against the allow-list
    if (typeof service !== "string" || typeof path !== "string") {
      return res.status(400).send("invalid parameters");
    }
    const base = SERVICE_BASE.get(service);
    if (!base) return res.status(400).send("invalid service");

    // Validate and normalize the path
    if (!SAFE_PATH.test(path) || path.includes("..")) {
      return res.status(400).send("invalid path");
    }

    // Use the standard URL parser to construct the destination
    const target = new URL(path, base);
    if (target.origin !== base.origin) {
      return res.status(400).send("invalid target");
    }

    // Limit redirects and set a timeout
    const resp = await axios.get(target.toString(), {
      timeout: 4000,
      maxRedirects: 0, // Prevent redirect-based destination changes
      responseType: "arraybuffer",
      maxContentLength: 1024 * 1024,
      validateStatus: (s) => s < 400,
    });

    // Do not execute remote content as HTML under this application's origin
    res.status(resp.status);
    res.set("Content-Type", "application/octet-stream");
    res.set("X-Content-Type-Options", "nosniff");
    res.send(Buffer.from(resp.data));
  } catch (e) {
    // Handle redirect or validation failures without exposing details
    res.status(502).send("bad gateway");
  }
});

app.listen(3000);

Explanation:

  • Before: The supplied URL controls the server's request, potentially reaching internal or metadata addresses. Following redirects may also move a request from an external to an internal destination.
  • After: A server-owned Map supplies the fixed host. Paths must begin with one / and cannot contain protocol-relative destinations such as //evil.example or .. traversal.
  • The parsed result must retain the base origin, and maxRedirects: 0 prevents redirect-based destination changes.
  • The response is limited to 1 MiB and returned as binary data with application/octet-stream and nosniff, rather than executable HTML under the application's origin.
  • Restricting user-selected hosts reduces the SSRF attack surface. Also maintain trusted DNS, actual connection destinations and network egress policy for the allowed hosts.

References