← Back to all writeups

Code Review: Server-Side Request Forgery via Unvalidated URL Fetch

A webhook testing feature that fetched user-supplied URLs with no validation allowed an attacker to proxy requests through the server — reaching internal services, cloud metadata endpoints, and sensitive infrastructure.

ssrfnodejswebhookcloudcode-review

SSRF is one of those vulnerabilities that looks harmless from the outside but becomes catastrophic the moment you understand what the server can reach that you can’t.

This one lived inside a webhook testing feature. Completely legitimate functionality. Completely unprotected.


The Code

app.post('/api/webhook/test', async (req, res) => {
    const { url, payload } = req.body;

    try {
        const response = await fetch(url, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(payload)
        });

        const data = await response.text();

        return res.json({
            status: response.status,
            body: data
        });

    } catch (err) {
        return res.status(500).json({ error: err.message });
    }
});

The feature is simple — test a webhook by sending a POST request to a URL and returning the response. Useful for developers. Dangerous without guardrails.


Breaking It Down

The endpoint takes a url from the request body and uses the server to make an HTTP request to it. The full response — status code and body — is returned to the caller.

The flow:

  1. Attacker submits any URL in the request body
  2. The server makes a request to that URL — not the browser
  3. The response is returned directly to the attacker

The attacker never talks to the target directly. The server does — on their behalf. That’s the core of SSRF.


Why This Is Dangerous

From the internet, an attacker can’t reach:

  • Internal services running on localhost or 127.0.0.1
  • Private network ranges — 10.x.x.x, 172.16.x.x, 192.168.x.x
  • Cloud metadata services — 169.254.169.254
  • Internal admin panels, databases, monitoring dashboards

But the server can reach all of these. By controlling the URL the server fetches, the attacker uses the server as a proxy into restricted infrastructure.


Attack 1 — Internal Service Enumeration

POST /api/webhook/test
{
    "url": "http://localhost:8080/admin",
    "payload": {}
}

The server requests its own admin panel — running locally on port 8080, not exposed to the internet. The full response comes back to the attacker. Internal admin interfaces, status pages, debug endpoints — all suddenly accessible.


Attack 2 — Cloud Metadata Endpoint

On AWS, every EC2 instance has a metadata service at a fixed IP:

POST /api/webhook/test
{
    "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
    "payload": {}
}

The server fetches its own cloud metadata. The response contains the IAM role name attached to the instance. Follow-up request:

POST /api/webhook/test
{
    "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/[role-name]",
    "payload": {}
}

Response:

{
    "AccessKeyId": "ASIA...",
    "SecretAccessKey": "...",
    "Token": "...",
    "Expiration": "2026-04-21T..."
}

Temporary AWS credentials — leaked. The attacker now has IAM access to whatever that role permits. In misconfigured environments this means full S3 bucket access, EC2 control, or worse.


Attack 3 — Internal Network Scanning

By iterating through internal IP ranges the attacker can map the internal network:

POST /api/webhook/test
{ "url": "http://10.0.0.1", "payload": {} }
{ "url": "http://10.0.0.2", "payload": {} }
{ "url": "http://10.0.0.3", "payload": {} }

Response times and error messages reveal which hosts are alive. Open ports can be probed by appending port numbers. The internal network topology is mapped without the attacker ever touching it directly.


Root Cause

One line:

const response = await fetch(url, { ... });

url comes directly from req.body with no validation. The server will fetch any URL — internal, external, loopback, cloud metadata, file system on some platforms. The developer never asked: “Should the server be allowed to reach this destination?”


The Fix

Fix 1 — Validate the URL before fetching:

const { URL } = require('url');

function isSafeUrl(rawUrl) {
    let parsed;
    try {
        parsed = new URL(rawUrl);
    } catch {
        return false;
    }

    // Only allow HTTP/HTTPS
    if (!['http:', 'https:'].includes(parsed.protocol)) return false;

    const hostname = parsed.hostname.toLowerCase();

    // Block loopback
    if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false;

    // Block cloud metadata IPs
    if (hostname === '169.254.169.254') return false;

    // Block private IP ranges
    const privateRanges = [/^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./];
    if (privateRanges.some(r => r.test(hostname))) return false;

    return true;
}

app.post('/api/webhook/test', async (req, res) => {
    const { url, payload } = req.body;

    if (!isSafeUrl(url)) {
        return res.status(400).json({ error: 'URL not allowed' });
    }

    const response = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload)
    });

    const data = await response.text();
    return res.json({ status: response.status, body: data });
});

Fix 2 — Resolve and re-validate after DNS resolution:

DNS rebinding attacks can bypass hostname checks by resolving a public hostname to a private IP after validation. Re-validate the resolved IP:

const dns = require('dns').promises;

const { address } = await dns.lookup(parsed.hostname);
if (isPrivateIP(address)) {
    return res.status(400).json({ error: 'Resolved to internal address' });
}

Fix 3 — Use an allowlist instead of a blocklist:

If webhooks only need to reach known external endpoints, define them explicitly:

const ALLOWED_DOMAINS = ['hooks.example.com', 'api.partner.com'];
if (!ALLOWED_DOMAINS.includes(parsed.hostname)) {
    return res.status(400).json({ error: 'Domain not permitted' });
}

Allowlists are always stronger than blocklists.

Fix 4 — Disable redirects:

Servers that follow redirects can be redirected from a public URL to an internal one after the initial validation passes:

const response = await fetch(url, {
    method: 'POST',
    redirect: 'error', // never follow redirects
    body: JSON.stringify(payload)
});

Key Takeaways

  • Any feature that fetches a URL on behalf of the user is a potential SSRF vector — webhook testers, URL previewers, PDF generators, image importers
  • The server’s network perspective is completely different from the attacker’s — what looks like a harmless URL fetch can reach infrastructure the attacker can never touch directly
  • Cloud metadata endpoints are the highest-value SSRF target169.254.169.254 leaks IAM credentials that can compromise entire AWS environments
  • Blocklists alone are not sufficient — DNS rebinding, IPv6 variants, and redirect chains can bypass them. Combine with allowlists and post-resolution checks
  • redirect: 'error' — always disable automatic redirect following when fetching user-supplied URLs

Found this useful? More code reviews coming. Hit me up on X if you want to discuss.