Skip to content

CWE-918: Server-Side Request Forgery (SSRF) - JavaScript/Node.js

Overview

Server-Side Request Forgery (SSRF) in JavaScript/Node.js occurs when applications fetch remote resources based on user-supplied URLs without proper validation. Node.js applications are particularly vulnerable when using axios, fetch, http, https, or request libraries with user-controlled URLs. SSRF enables attackers to access internal services, cloud metadata endpoints (AWS, Azure, GCP), and bypass firewalls.

Primary Defence: Validate URLs against an allowlist of permitted domains, block private/reserved IP ranges using DNS resolution checks, and use libraries like validator with custom URL validation.

Common Vulnerable Patterns

axios with User-Controlled URL

const express = require('express');
const axios = require('axios');
const app = express();

app.get('/fetch', async (req, res) => {
    const url = req.query.url;

    try {
        // VULNERABLE - No URL validation
        const response = await axios.get(url);
        res.json({ data: response.data });
    } catch (error) {
        res.status(500).json({ error: error.message });
    }
});

// Attack: /fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
// Result: AWS IAM credentials leaked to attacker

Why this is vulnerable: No validation of URL destination. Attacker can access internal metadata endpoints.

fetch API with URL Concatenation

app.get('/proxy', async (req, res) => {
    const domain = req.query.domain;

    // VULNERABLE - URL construction with user input
    const url = `https://${domain}/api/data`;

    try {
        const response = await fetch(url);
        const data = await response.json();
        res.json(data);
    } catch (error) {
        res.status(500).json({ error: error.message });
    }
});

// Attack: /proxy?domain=localhost:6379/
// Result: Access to internal Redis server (if HTTP interface exposed)

Why this is vulnerable: User controls the domain portion, can specify internal hosts like localhost, 192.168.1.1, etc.

http.get with DNS Rebinding Bypass

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

app.get('/download', (req, res) => {
    const targetUrl = req.query.url;

    try {
        const parsed = new URL(targetUrl);

        // VULNERABLE - Single DNS resolution check
        if (parsed.hostname === 'localhost' || parsed.hostname.startsWith('192.168.')) {
            return res.status(400).json({ error: 'Invalid hostname' });
        }

        // DNS rebinding attack: Domain initially resolves to external IP,
        // then changes to 127.0.0.1 between validation and request
        http.get(targetUrl, (response) => {
            let data = '';
            response.on('data', chunk => data += chunk);
            response.on('end', () => res.send(data));
        });
    } catch (error) {
        res.status(400).json({ error: 'Invalid URL' });
    }
});

// Attack: Domain evil.com resolves to 1.2.3.4 during check,
// then attacker changes DNS to point to 127.0.0.1 before request

Why this is vulnerable: Time-of-check-time-of-use (TOCTOU) vulnerability. DNS can change between validation and request.

request-promise with Insufficient Validation

const request = require('request-promise');

app.post('/webhook', async (req, res) => {
    const webhookUrl = req.body.callback_url;

    // VULNERABLE - Weak denylist
    if (webhookUrl.includes('localhost') || webhookUrl.includes('127.0.0.1')) {
        return res.status(400).json({ error: 'Invalid URL' });
    }

    try {
        const result = await request({
            url: webhookUrl,
            method: 'POST',
            json: { event: 'completed', timestamp: Date.now() }
        });
        res.json({ status: 'Webhook sent' });
    } catch (error) {
        res.status(500).json({ error: error.message });
    }
});

// Bypass attacks:
// - http://[::1]/ (IPv6 localhost)
// - http://127.1/ (shorthand for 127.0.0.1)
// - http://0177.0.0.1/ (octal notation)
// - http://0x7f.0.0.1/ (hex notation)
// - http://2130706433/ (decimal notation for 127.0.0.1)

Why this is vulnerable: Denylists are easily bypassed using IP encoding variations, IPv6, or DNS resolution.

got Library Without IP Validation

const got = require('got');

app.get('/image', async (req, res) => {
    const imageUrl = req.query.url;

    try {
        // VULNERABLE - No IP range checking
        const response = await got(imageUrl, {
            responseType: 'buffer',
            timeout: 5000
        });

        res.set('Content-Type', response.headers['content-type']);
        res.send(response.body);
    } catch (error) {
        res.status(500).json({ error: error.message });
    }
});

// Attack: /image?url=http://192.168.1.1/admin
// Result: Access to internal router admin interface

Why this is vulnerable: No check for private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16).

URL Redirect Following

const axios = require('axios');

app.get('/proxy', async (req, res) => {
    const url = req.query.url;

    // VULNERABLE - Follows redirects without re-validation
    try {
        const response = await axios.get(url, {
            maxRedirects: 5  // Follows up to 5 redirects
        });
        res.json({ data: response.data });
    } catch (error) {
        res.status(500).json({ error: error.message });
    }
});

// Attack: /proxy?url=https://evil.com/redirect
// evil.com redirects to http://169.254.169.254/latest/meta-data/
// Result: Cloud metadata accessed via redirect

Why this is vulnerable: Initial URL may pass validation, but redirect target is not checked.

File Protocol Access

const axios = require('axios');

app.get('/fetch', async (req, res) => {
    const url = req.query.url;

    try {
        // VULNERABLE - No protocol restriction
        const response = await axios.get(url);
        res.send(response.data);
    } catch (error) {
        res.status(500).json({ error: error.message });
    }
});

// Attack: /fetch?url=file:///etc/passwd
// Result: Local file disclosure (if axios supports file:// protocol)

Why this is vulnerable: No restriction on URL schemes. Should only allow http:// and https://.

Cloud Metadata Endpoint Access

const fetch = require('node-fetch');

app.get('/cloud-info', async (req, res) => {
    const endpoint = req.query.endpoint;

    // VULNERABLE - No blocking of metadata IPs
    const response = await fetch(`http://${endpoint}/info`);
    const data = await response.text();
    res.send(data);
});

// Attack: /cloud-info?endpoint=169.254.169.254/latest/meta-data
// Result: AWS metadata with IAM role credentials exposed
// Similar attacks work for:
// - Azure: 169.254.169.254/metadata/instance
// - GCP: metadata.google.internal/computeMetadata/v1/

Why this is vulnerable: Link-local address 169.254.169.254 and metadata hostnames not blocked.

Secure Patterns

URL Allowlist with axios

const express = require('express');
const axios = require('axios');
const { URL } = require('url');
const app = express();

// SECURE - Explicit allowlist of permitted domains
const ALLOWED_DOMAINS = [
    'api.github.com',
    'api.example.com',
    'webhook.example.com'
];

function validateUrl(urlString) {
    try {
        const url = new URL(urlString);

        // Require HTTPS
        if (url.protocol !== 'https:') {
            throw new Error('Only HTTPS URLs allowed');
        }

        // Check against allowlist
        if (!ALLOWED_DOMAINS.includes(url.hostname)) {
            throw new Error(`Domain ${url.hostname} not in allowlist`);
        }

        return url.href;
    } catch (error) {
        throw new Error(`Invalid URL: ${error.message}`);
    }
}

app.get('/fetch', async (req, res) => {
    try {
        const safeUrl = validateUrl(req.query.url);
        const response = await axios.get(safeUrl, {
            maxRedirects: 0  // Disable redirects
        });
        res.json({ data: response.data });
    } catch (error) {
        res.status(400).json({ error: error.message });
    }
});

Why this works: Only pre-approved domains allowed. HTTPS required. Redirects disabled.

IP Range Blocking with DNS Resolution

const dns = require('dns').promises;
const { URL } = require('url');
const ipaddr = require('ipaddr.js');

// SECURE - Validate both hostname and resolved IPs
async function validateUrlWithIpCheck(urlString) {
    const url = new URL(urlString);

    // Only allow HTTP/HTTPS
    if (!['http:', 'https:'].includes(url.protocol)) {
        throw new Error('Invalid protocol');
    }

    // Resolve DNS and check all IPs
    const addresses = await dns.resolve(url.hostname);

    for (const address of addresses) {
        const addr = ipaddr.parse(address);

        // Block private IP ranges
        if (addr.range() === 'private') {
            throw new Error(`Host resolves to private IP: ${address}`);
        }

        // Block loopback
        if (addr.range() === 'loopback') {
            throw new Error('Loopback addresses not allowed');
        }

        // Block link-local (metadata endpoints)
        if (addr.range() === 'linkLocal') {
            throw new Error('Link-local addresses not allowed');
        }
    }

    return url.href;
}

app.get('/proxy', async (req, res) => {
    try {
        const safeUrl = await validateUrlWithIpCheck(req.query.url);
        const response = await axios.get(safeUrl);
        res.json({ data: response.data });
    } catch (error) {
        res.status(400).json({ error: error.message });
    }
});

Why this works: Performs DNS resolution and validates all IPs against private ranges, loopback, and link-local.

Domain Allowlist with Path Restriction

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

const ALLOWED_APIS = {
    'api.github.com': ['/repos/', '/users/'],
    'api.example.com': ['/public/']
};

function validateApiUrl(urlString) {
    const url = new URL(urlString);

    // Check domain allowlist
    const allowedPaths = ALLOWED_APIS[url.hostname];
    if (!allowedPaths) {
        throw new Error('Domain not allowed');
    }

    // Check path allowlist
    const pathAllowed = allowedPaths.some(prefix => 
        url.pathname.startsWith(prefix)
    );

    if (!pathAllowed) {
        throw new Error('Path not allowed');
    }

    // Require HTTPS
    if (url.protocol !== 'https:') {
        throw new Error('HTTPS required');
    }

    return url.href;
}

app.get('/api-proxy', async (req, res) => {
    try {
        const safeUrl = validateApiUrl(req.query.url);
        const response = await axios.get(safeUrl, {
            timeout: 5000,
            maxRedirects: 0
        });
        res.json(response.data);
    } catch (error) {
        res.status(400).json({ error: error.message });
    }
});

Why this works: Validates both domain AND path. Only specific API endpoints allowed.

Network Segmentation with got-ssrf

const got = require('got');
const { gotSsrfPlugin } = require('got-ssrf');

// SECURE - Use got-ssrf plugin to block SSRF attempts
const secureGot = got.extend({
    hooks: {
        beforeRequest: [
            gotSsrfPlugin({
                // Block private IPs
                allowPrivateIPAddresses: false,
                // Block metadata endpoints
                allowMetaIPAddresses: false
            })
        ]
    },
    timeout: {
        request: 5000
    },
    maxRedirects: 0
});

app.get('/fetch', async (req, res) => {
    const url = req.query.url;

    try {
        const response = await secureGot(url);
        res.json({ data: response.body });
    } catch (error) {
        res.status(400).json({ error: 'Request blocked or failed' });
    }
});

Why this works: got-ssrf plugin automatically blocks private IPs and metadata endpoints.

Custom HTTP Agent with IP Filtering

const axios = require('axios');
const dns = require('dns').promises;
const ipaddr = require('ipaddr.js');
const http = require('http');
const https = require('https');

// SECURE - Custom agent that validates IPs before connecting
class SsrfProtectedAgent {
    async createAgent(url) {
        const hostname = new URL(url).hostname;
        const addresses = await dns.resolve(hostname);

        // Validate all resolved IPs
        for (const address of addresses) {
            const addr = ipaddr.parse(address);
            if (['private', 'loopback', 'linkLocal'].includes(addr.range())) {
                throw new Error(`Blocked IP range: ${addr.range()}`);
            }
        }

        // Create agent that uses validated DNS result
        const isHttps = url.startsWith('https');
        const Agent = isHttps ? https.Agent : http.Agent;

        return new Agent({
            lookup: (hostname, options, callback) => {
                // Use pre-validated IP
                callback(null, addresses[0], 4);
            }
        });
    }
}

app.get('/secure-fetch', async (req, res) => {
    const url = req.query.url;

    try {
        const agentHelper = new SsrfProtectedAgent();
        const agent = await agentHelper.createAgent(url);

        const response = await axios.get(url, {
            httpAgent: agent,
            httpsAgent: agent,
            maxRedirects: 0,
            timeout: 5000
        });

        res.json({ data: response.data });
    } catch (error) {
        res.status(400).json({ error: error.message });
    }
});

Why this works: Custom DNS lookup prevents DNS rebinding. IPs validated before connection.

Redirect Validation Middleware

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

const ALLOWED_DOMAINS = ['api.trusted.com'];

async function fetchWithRedirectValidation(url) {
    const response = await axios.get(url, {
        maxRedirects: 0,  // Handle redirects manually
        validateStatus: status => status < 400 || status === 302 || status === 301
    });

    // Check if response is a redirect
    if (response.status === 301 || response.status === 302) {
        const redirectUrl = response.headers.location;
        const redirectHost = new URL(redirectUrl, url).hostname;

        // SECURE - Validate redirect target
        if (!ALLOWED_DOMAINS.includes(redirectHost)) {
            throw new Error('Redirect to unauthorized domain blocked');
        }

        // Recursively fetch with validation
        return fetchWithRedirectValidation(redirectUrl);
    }

    return response;
}

app.get('/safe-redirect', async (req, res) => {
    try {
        const url = new URL(req.query.url);
        if (!ALLOWED_DOMAINS.includes(url.hostname)) {
            return res.status(400).json({ error: 'Domain not allowed' });
        }

        const response = await fetchWithRedirectValidation(url.href);
        res.json({ data: response.data });
    } catch (error) {
        res.status(400).json({ error: error.message });
    }
});

Why this works: Redirects handled manually. Each redirect target validated against allowlist.

Cloud Metadata Endpoint Blocking

const { URL } = require('url');
const dns = require('dns').promises;
const ipaddr = require('ipaddr.js');

const METADATA_HOSTNAMES = [
    'metadata.google.internal',
    'metadata',
    '169.254.169.254'
];

async function blockMetadataEndpoints(urlString) {
    const url = new URL(urlString);

    // Block metadata hostnames
    if (METADATA_HOSTNAMES.includes(url.hostname.toLowerCase())) {
        throw new Error('Metadata endpoint blocked');
    }

    // Resolve and check for metadata IP
    try {
        const addresses = await dns.resolve(url.hostname);
        for (const address of addresses) {
            // Block 169.254.0.0/16 (link-local, used by cloud metadata)
            if (address.startsWith('169.254.')) {
                throw new Error('Metadata IP range blocked');
            }
        }
    } catch (error) {
        if (error.message.includes('blocked')) {
            throw error;
        }
        // DNS resolution failed - block for safety
        throw new Error('Cannot resolve hostname');
    }

    return url.href;
}

app.get('/cloud-safe', async (req, res) => {
    try {
        const safeUrl = await blockMetadataEndpoints(req.query.url);
        const response = await axios.get(safeUrl, {
            timeout: 3000,
            maxRedirects: 0
        });
        res.json({ data: response.data });
    } catch (error) {
        res.status(400).json({ error: error.message });
    }
});

Why this works: Explicitly blocks cloud metadata hostnames and link-local IP range.

Protocol Restriction

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

const ALLOWED_PROTOCOLS = ['http:', 'https:'];

function validateProtocol(urlString) {
    const url = new URL(urlString);

    if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
        throw new Error(`Protocol ${url.protocol} not allowed`);
    }

    return url;
}

app.get('/fetch', async (req, res) => {
    try {
        const url = validateProtocol(req.query.url);

        // Additional validation here (domain allowlist, etc.)

        const response = await axios.get(url.href, {
            timeout: 5000
        });
        res.json({ data: response.data });
    } catch (error) {
        res.status(400).json({ error: error.message });
    }
});

Why this works: Blocks file://, ftp://, gopher://, and other dangerous protocols.

Key Security Functions

IP Address Validation (ipaddr.js)

const ipaddr = require('ipaddr.js');

function isPrivateOrSpecialIP(address) {
    try {
        const addr = ipaddr.parse(address);
        const range = addr.range();

        // Block: private, loopback, linkLocal, broadcast, reserved
        return ['private', 'loopback', 'linkLocal', 'broadcast', 
                'reserved', 'unspecified'].includes(range);
    } catch (error) {
        return true; // Block invalid IPs
    }
}

// Usage:
if (isPrivateOrSpecialIP('192.168.1.1')) {
    throw new Error('Private IP blocked');
}

DNS Resolution and Validation

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

async function resolveAndValidate(hostname) {
    const addresses = await dns.resolve(hostname);

    for (const address of addresses) {
        if (isPrivateOrSpecialIP(address)) {
            throw new Error(`Host resolves to blocked IP: ${address}`);
        }
    }

    return addresses;
}

URL Normalization

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

function normalizeUrl(urlString) {
    const url = new URL(urlString);

    // Remove credentials if present
    url.username = '';
    url.password = '';

    // Normalize hostname (lowercase)
    url.hostname = url.hostname.toLowerCase();

    // Remove fragment
    url.hash = '';

    return url.href;
}

Timeout Configuration

const axios = require('axios');

const secureAxios = axios.create({
    timeout: 5000,  // 5 second timeout
    maxRedirects: 0,  // Disable automatic redirects
    maxContentLength: 10 * 1024 * 1024,  // 10MB max response
    validateStatus: status => status < 400
});

Testing and Validation

SSRF vulnerabilities should be identified through:

  • Static Analysis Tools: Use tools like ESLint with security plugins, Semgrep, SonarQube, or Snyk to identify potential SSRF sinks
  • Dynamic Application Security Testing (DAST): Tools like OWASP ZAP, Burp Suite, or Acunetix can test for SSRF by manipulating URL parameters
  • Manual Penetration Testing: Test with internal IP addresses (127.0.0.1, 192.168.x.x), cloud metadata endpoints (169.254.169.254), and file:// protocols
  • Code Review: Ensure all HTTP client usage includes URL validation against an allowlist and blocks private IP ranges
  • Network Monitoring: Monitor outbound requests to detect unexpected internal network access

Framework-Specific SSRF Patterns

Express.js with axios

const express = require('express');
const axios = require('axios');
const { validateSafeUrl } = require('./ssrf-protection');

const app = express();

app.get('/webhook-test', async (req, res) => {
    try {
        const safeUrl = await validateSafeUrl(req.query.url);

        const response = await axios.post(safeUrl, {
            event: 'test',
            timestamp: Date.now()
        }, {
            timeout: 3000,
            maxRedirects: 0
        });

        res.json({ status: 'sent', response: response.status });
    } catch (error) {
        res.status(400).json({ error: error.message });
    }
});

Next.js API Routes

// pages/api/fetch.js
import axios from 'axios';
import { validateUrl } from '../../lib/ssrf-protection';

export default async function handler(req, res) {
    if (req.method !== 'GET') {
        return res.status(405).json({ error: 'Method not allowed' });
    }

    try {
        const safeUrl = await validateUrl(req.query.url);
        const response = await axios.get(safeUrl, {
            timeout: 5000,
            maxRedirects: 0
        });

        res.status(200).json({ data: response.data });
    } catch (error) {
        res.status(400).json({ error: error.message });
    }
}

NestJS Service

import { Injectable, BadRequestException } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
import { validateSafeUrl } from './ssrf-protection';

@Injectable()
export class ProxyService {
    constructor(private httpService: HttpService) {}

    async fetchUrl(url: string): Promise<any> {
        try {
            const safeUrl = await validateSafeUrl(url);

            const response = await firstValueFrom(
                this.httpService.get(safeUrl, {
                    timeout: 5000,
                    maxRedirects: 0
                })
            );

            return response.data;
        } catch (error) {
            throw new BadRequestException('Invalid or blocked URL');
        }
    }
}

Typical SSRF Findings

  1. "User-controlled URL in HTTP request"

    • Location: axios.get(req.query.url)
    • Fix: Implement URL allowlist validation before making request
  2. "Server-Side Request Forgery via unvalidated URL"

    • Location: fetch(userSuppliedUrl)
    • Fix: Add domain allowlist and IP range validation
  3. "Potential access to cloud metadata endpoint"

    • Location: Request to user-controlled destination without blocking 169.254.169.254
    • Fix: Explicitly block link-local IP range and metadata hostnames
  4. "DNS rebinding vulnerability"

    • Location: Single DNS check followed by request
    • Fix: Pin DNS resolution using custom HTTP agent
  5. "Unrestricted URL redirect following"

    • Location: maxRedirects: 5 with user-controlled initial URL
    • Fix: Disable redirects or validate each redirect target
  6. "File protocol SSRF"

    • Location: Accepting file:// URLs in fetch operations
    • Fix: Restrict allowed protocols to http: and https: only

Defense in Depth

Layer 1: URL Allowlist (Primary)

  • Maintain explicit list of permitted domains
  • Validate scheme (HTTPS only when possible)
  • Validate path if needed
  • Use exact hostname matching

Layer 2: IP Range Blocking (Secondary)

  • Block private ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
  • Block loopback: 127.0.0.0/8, ::1
  • Block link-local: 169.254.0.0/16 (cloud metadata)
  • Use ipaddr.js for reliable IP parsing

Layer 3: DNS Validation (Tertiary)

  • Resolve DNS and validate all IPs
  • Pin DNS resolution to prevent rebinding
  • Re-validate before actual request

Layer 4: Network Controls (Infrastructure)

  • Implement egress firewall rules
  • Use network segmentation
  • Deploy in VPC with restricted outbound access
  • Monitor outbound connections

Security Checklist

  • Implement URL allowlist (domain and optionally path)
  • Block private IP ranges using ipaddr.js
  • Block loopback addresses (127.0.0.0/8, ::1)
  • Block link-local range (169.254.0.0/16) for cloud metadata protection
  • Restrict protocols to http: and https: only
  • Disable automatic redirect following or validate each redirect
  • Implement DNS resolution with IP validation
  • Use DNS pinning via custom HTTP agent
  • Set request timeouts (3-5 seconds recommended)
  • Set maximum response size limits
  • Never trust Host header for internal requests
  • Monitor and log all outbound requests
  • Test with SSRF payloads: localhost, 169.254.169.254, file://, redirects

Additional Resources