In short

Never let a user-supplied URL choose where your server connects. Prefer an exact host allowlist. If any public URL must work, parse it with the HTTP client's own parser, allow only https on port 443, resolve the name once, refuse every non-public IPv4 and IPv6 address, and connect to the checked address. Follow no redirects, cap size and time, forward no credentials, and require IMDSv2 so cloud credentials stay out of reach.

  • Check the address the connection actually uses; a separate validation lookup can be rebound to an internal address.
  • Deny every special-purpose range, including 0.0.0.0/8, 100.64.0.0/10, 169.254.0.0/16, fc00::/7 and IPv4-mapped IPv6.
  • Let the URL parser normalize alternative spellings such as 2130706433 and 0x7f.1 before checking.
  • Treat a redirect as a new destination; the simplest safe fetcher follows none.
  • Harden cloud metadata: IMDSv2 with a hop limit of 1 on AWS, the required headers on Google Cloud and Azure, and least-privilege roles.

Verify it

  1. Request an http: URL, a non-443 port and a URL with userinfo; each must fail before any connection.
  2. Request decimal, octal and hexadecimal spellings of 127.0.0.1 and 169.254.169.254; all must be blocked.
  3. Point a hostname at a loopback listener and confirm the listener records zero connections.
  4. Serve a 302 to an internal address from an allowed host and confirm the redirect is not followed.
  5. Stream one byte more than the cap and confirm the fetch aborts with nothing published.
  6. On AWS, run aws ec2 describe-instances and confirm MetadataOptions.HttpTokens is required on fetcher hosts.
Applies to

Server-side URL fetching. The effective destination and egress path matter beyond a hostname string check.

Part of
Learning path: AppSec in practice (step 6 of 18). Workflows: Fix a finding, Secure a feature

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 32 minutes: 9 to read, 5 on the code and 18 for the exercise

What you will learn

  • Locate authority in the actual connected destination.
  • Handle redirects and DNS consistently with the URL policy.
  • Verify forbidden targets receive no connection attempts.

Before you start

Backend developers building URL previews, imports, webhooks or other server-side fetchers, and reviewers of egress controls.

  • Trace a URL through parsing, DNS resolution, redirects and the final network connection.
Go to the guided practice

A URL fetcher reaching cloud metadata, and the controls that stop it

Your cloud instance and VPCAttackerURL fetcherEgressfirewall /proxyInstancemetadataPublic sitePreview a URL for169.254.169.2541Unchecked GET to the metadata address2IMDSv1: role credentials2Credentials inthe preview2Resolve once,denylink-local3Deny metadataand internalranges4IMDSv2: nosessiontoken, 4014HTTPS to thechecked publicaddress5Allowed public destination5Response within size and time caps5Trust boundaryUntrusted input1Step, described below
  1. Supply a URLThe attacker submits http://169.254.169.254/latest/meta-data/iam/security-credentials/, or a hostname that resolves to that address, to a URL preview or import feature.
  2. Without controlsA naive fetcher connects to the link-local metadata address. With IMDSv1 the service answers a plain GET with the instance role’s temporary credentials, and the preview returns them to the attacker.
  3. Check in the fetcherThe fetcher parses the URL with its HTTP client’s parser, allows only https on port 443, resolves the name once and refuses link-local, private and other special-purpose addresses before any connection is made.
  4. Enforce at egress and harden metadataA host firewall rule or egress proxy denies the metadata address and internal ranges, so a bypassed application check still gets no connection. With IMDSv2 required (HttpTokens=required, hop limit 1), a GET without a session token is refused with 401.
  5. Fetch a permitted URLA permitted URL resolves to a public address. The fetcher connects to that checked address, follows no redirects, forwards no credentials and caps the response size and time.
The application check and the network control are independent: either one alone stops the metadata request, and IMDSv2 limits what a request that gets through can read.

Define the server capability the URL would grant

A server-side request forgery flaw turns a supplied URL into a request made with the application's network position and credentials. A public user may be unable to reach an internal service directly, while a backend fetcher can. Even without returning the response body, status, timing or downstream side effects can expose or change internal state. The security question is which destinations and protocols the feature actually needs.

A document-import feature that needs files only from files.partner.example.test should accept a document id or constrain an HTTPS URL to that exact origin. A general-purpose URL previewer has a harder requirement: it must handle arbitrary public hosts while reliably excluding internal destinations. Do not use a substring such as partner.example.test as the policy. In exercise case B the trusted name is followed by .attacker.invalid, so the actual host belongs elsewhere; in case C it comes before @, so it is only user information and the request goes to internal.example.

Keep URL policy attached to the actual connection

Parse once with the same URL semantics used by the HTTP client. Reject unsupported schemes, credentials and ports; compare normalized host values to the precise allowlist. Resolve DNS and evaluate every candidate address under the network policy, including IPv6, using the special-purpose ranges in the table below. The resolution used for checking must not be discarded in favor of an independent resolution at connection time, or an attacker can change DNS answers between the two steps. The WHATWG parser also normalizes alternative IPv4 spellings, so https://2130706433/, https://0x7f.1/ and https://0177.0.0.1/ all become 127.0.0.1 before the check; a regular expression over the raw string misses them.

Redirects are new destination decisions. The simplest narrow importer disables them; a feature that needs redirects must validate each target and enforce a hop limit. Do not forward credentials or authorization headers to a new origin. Bound response bytes, connection duration, total duration and decompressed data; a permitted destination can still return an endless stream. Use an egress proxy or network policy as an additional enforcement point and keep internal service credentials out of the fetcher's environment where possible. Network isolation makes parser mistakes less consequential but does not replace the feature's destination rule. A WebRTC TURN server is a relay of the same kind and needs the same address policy (/domains/appsec/secure-features/webrtc-security/).

The Node.js module below implements this contract. checkedLookup replaces DNS resolution for the request: it resolves the name once, rejects the whole answer if any address is non-public, and hands the checked addresses to the socket, so there is no second lookup to rebind. IP literals skip DNS, so safeFetch checks them directly. Node's https client does not follow redirects, so a 3xx is reported as redirect_not_followed, and the body is abandoned as soon as it passes the byte cap. The test file run with it checked 27 addresses, URL spellings and literals, confirmed a loopback listener received zero connections, and exercised the cap and an unfollowed redirect against public hosts. Fixes for other stacks are at /fix/ssrf/.

Fixedsafe-fetch.mjsJavaScript
// safe-fetch.mjs (Node.js 22+): fetch a URL only over HTTPS to public addresses.
import {BlockList, isIP} from 'node:net';
import dns from 'node:dns';
import https from 'node:https';

const denied = new BlockList();
for (const range of ['0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8',
  '169.254.0.0/16', '172.16.0.0/12', '192.0.0.0/24', '192.0.2.0/24', '192.88.99.0/24',
  '192.168.0.0/16', '198.18.0.0/15', '198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4',
  '240.0.0.0/4']) denied.addSubnet(...range.split('/').map((p, i) => (i ? +p : p)), 'ipv4');
// IPv6: allow only global unicast 2000::/3, minus special blocks that embed IPv4 or are reserved.
const globalV6 = new BlockList();
globalV6.addSubnet('2000::', 3, 'ipv6');
for (const range of ['2001::/23', '2001:db8::/32', '2002::/16', '3fff::/20'])
  denied.addSubnet(...range.split('/').map((p, i) => (i ? +p : p)), 'ipv6');

export function isPublicAddress(address) {
  const family = isIP(address) === 6 ? 'ipv6' : 'ipv4';
  if (family === 'ipv6' && !globalV6.check(address, 'ipv6')) return false;
  return !denied.check(address, family);
}

// One resolution, checked and then used for the connection: no second lookup to rebind.
export function checkedLookup(hostname, options, callback) {
  dns.lookup(hostname, {...options, all: true}, (err, addresses) => {
    if (err) return callback(err);
    const bad = addresses.find(a => !isPublicAddress(a.address));
    if (bad || !addresses.length)
      return callback(Object.assign(new Error('blocked_address'), {code: 'EBLOCKED'}));
    if (options.all) callback(null, addresses);
    else callback(null, addresses[0].address, addresses[0].family);
  });
}

export function safeFetch(rawUrl, {allowedHosts, maxBytes = 2 * 1024 * 1024, timeoutMs = 5000} = {}) {
  return new Promise((resolve, reject) => {
    const url = new URL(rawUrl); // the same WHATWG parser the request will use
    const host = url.hostname.replace(/^\[|\]$/g, '');
    if (url.protocol !== 'https:' || url.port || url.username || url.password)
      return reject(new Error('unsupported_url'));
    if (allowedHosts && !allowedHosts.includes(host)) return reject(new Error('host_not_allowed'));
    if (isIP(host) && !isPublicAddress(host)) return reject(new Error('blocked_address'));
    const req = https.get(url, {lookup: checkedLookup, signal: AbortSignal.timeout(timeoutMs),
      headers: {'user-agent': 'preview-fetcher'}}, res => { // no caller headers or cookies
      if (res.statusCode >= 300 && res.statusCode < 400) {
        res.resume();
        return reject(new Error('redirect_not_followed'));
      }
      const chunks = [];
      let size = 0;
      res.on('data', chunk => {
        size += chunk.length;
        if (size > maxBytes) req.destroy(new Error('too_large'));
        else chunks.push(chunk);
      });
      res.on('end', () => resolve({status: res.statusCode, body: Buffer.concat(chunks)}));
      res.on('error', reject);
    });
    req.on('error', reject);
  });
}

Deny every special-purpose address range

A short list of private ranges is the most common SSRF gap. The table lists the blocks the code above denies, from the IANA IPv4 and IPv6 special-purpose registries plus multicast. For IPv6 the code is stricter still: it accepts only global unicast addresses in 2000::/3 and then removes the special blocks inside that range.

Deny every special-purpose address range
RangeNameWhy it is denied
0.0.0.0/8This network0.0.0.0 reaches local services on some systems
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16Private useInternal networks
100.64.0.0/10Shared address spaceCarrier-grade NAT and internal cloud networks
127.0.0.0/8, ::1/128LoopbackServices on the fetcher itself
169.254.0.0/16, fe80::/10Link localIncludes the metadata address 169.254.169.254
fc00::/7Unique localInternal IPv6; includes AWS metadata at fd00:ec2::254
::ffff:0:0/96IPv4-mapped IPv6Another spelling of an IPv4 address
64:ff9b::/96, 64:ff9b:1::/48IPv4/IPv6 translationA NAT64 gateway can reach internal IPv4 addresses
2002::/16, 2001::/236to4, Teredo and IETF assignmentsEmbed IPv4 addresses or are not public destinations
192.0.0.0/24, 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, 2001:db8::/32, 3fff::/20Protocol assignments and documentationNever a legitimate public destination
198.18.0.0/15, 192.88.99.0/24Benchmarking, deprecated 6to4 relayNot public destinations
224.0.0.0/4, 240.0.0.0/4, 255.255.255.255/32, ::/128Multicast, reserved, broadcast, unspecifiedNot unicast destinations

Harden the cloud metadata service

The instance metadata service is the most valuable internal target, because it can return the workload's cloud credentials. In the 2019 Capital One breach, the Justice Department's complaint says a firewall misconfiguration let an outsider's commands reach a server and obtain credentials for a web application firewall role, which were then used to copy data from cloud storage; Capital One says it covered about 100 million people in the US and 6 million in Canada. Security researchers widely reported the mechanism as server-side request forgery against the EC2 metadata service; the primary sources do not name it. Block the metadata addresses in the fetcher and at the network layer, and harden the service itself.

Metadata credentials are not the only cloud credential a URL can carry. A signed storage URL needs no SSRF at all: in 2023 a Microsoft researcher published an Azure Storage link with an over-permissive SAS token in a public repository, and anyone who read it could reach an internal storage account (/domains/runtime/cloud-posture/case-study-microsoft-sas-token/). Treat signed URLs, presigned URLs and SAS tokens as credentials, scoped and short-lived.

  • AWS: require IMDSv2 with aws ec2 modify-instance-metadata-options --instance-id <id> --http-tokens required --http-put-response-hop-limit 1. IMDSv2 needs a session token obtained with a PUT request, which most SSRF primitives cannot send; the hop limit of 1 stops the token crossing an extra network hop such as a container bridge. Disable IMDS where nothing uses it.
  • Google Cloud: the metadata server rejects requests without the Metadata-Flavor: Google header. That stops SSRF primitives that cannot set headers, not one that controls them.
  • Azure: IMDS requires the Metadata: true header and rejects requests that contain X-Forwarded-For.
  • Everywhere: give the fetching workload its own identity with only the permissions it needs, so stolen credentials grant little.

Check the connected address in Go

In Go, put the check in net.Dialer.Control, which runs for every connection attempt with the address actually being dialed, after DNS resolution and for each address the dialer tries. The client below also stops at the first redirect and uses no proxy, because through a proxy the dialer would see only the proxy's address. ReadCapped reads one byte past the limit to detect an oversized body. Its tests, run with Go 1.27, check the address table, prove a loopback test server is never reached and confirm a 302 to a second server is not followed.

Fixedfetch.goGo
package fetch

import (
	"errors"
	"io"
	"net"
	"net/http"
	"net/netip"
	"syscall"
	"time"
)

var globalUnicast = netip.MustParsePrefix("2000::/3")
var denied = func() (p []netip.Prefix) {
	for _, s := range []string{"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
		"169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24", "192.88.99.0/24",
		"192.168.0.0/16", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4",
		"240.0.0.0/4", "2001::/23", "2001:db8::/32", "2002::/16", "3fff::/20"} {
		p = append(p, netip.MustParsePrefix(s))
	}
	return
}()

// IsPublic reports whether a is a public unicast address a fetcher may connect to.
func IsPublic(a netip.Addr) bool {
	a = a.Unmap() // ::ffff:127.0.0.1 is checked as 127.0.0.1
	if a.Is6() && !globalUnicast.Contains(a) {
		return false
	}
	for _, p := range denied {
		if p.Contains(a) {
			return false
		}
	}
	return true
}

// NewClient checks the address of every connection the dialer actually makes, after DNS
// resolution, so a second lookup cannot swap in an internal address. Redirects are not
// followed and no proxy is used: through a proxy, the dialer would only see the proxy.
func NewClient(allow func(netip.Addr) bool) *http.Client {
	dialer := &net.Dialer{
		Timeout: 5 * time.Second,
		Control: func(network, address string, _ syscall.RawConn) error {
			ap, err := netip.ParseAddrPort(address)
			if err != nil || !allow(ap.Addr()) {
				return errors.New("blocked address")
			}
			return nil
		},
	}
	return &http.Client{
		Timeout:   5 * time.Second,
		Transport: &http.Transport{DialContext: dialer.DialContext, Proxy: nil},
		CheckRedirect: func(*http.Request, []*http.Request) error {
			return http.ErrUseLastResponse
		},
	}
}

// ReadCapped reads at most max bytes of the body and fails if there is more.
func ReadCapped(body io.Reader, max int64) ([]byte, error) {
	b, err := io.ReadAll(io.LimitReader(body, max+1))
	if err == nil && int64(len(b)) > max {
		return nil, errors.New("response too large")
	}
	return b, err
}

Use controlled connection evidence for verification

The paper fixture supplies DNS answers and redirect targets so no real internal address is contacted. In an integration test, use a controlled resolver and HTTP stub that record attempted connections. Assert that a denied destination receives zero connection attempts, not merely that the importer eventually returns an error. Test a trusted URL whose first response redirects toward an internal endpoint; the first permitted connection may occur, but the second must not. The labs at /labs/ssrf-url-policy/ (JavaScript) and /labs/ssrf-validation-python/ (Python) grade a fetcher you repair against literals, encodings and redirects.

Check both address families and deployment paths. A container's loopback interface, cloud metadata route and service network differ from a developer laptop. If an egress proxy resolves names itself, the proxy is the enforcement boundary and its policy must match the application assumptions. Debug blocked imports using parsed destination, policy reason and request id; avoid logging URL credentials or sensitive query strings. Distinguish DNS failure, policy rejection, TLS failure and resource exhaustion so operators do not 'fix' availability by disabling the destination checks.

Real-world failures

In March 2019 a firewall misconfiguration at Capital One let an outsider run commands that obtained credentials for its web application firewall role and copied data from cloud storage, according to the Justice Department's complaint; Capital One says the data covered about 100 million people in the US and 6 million in Canada, the largest category being credit card applications. Public analyses, not those primary sources, describe the mechanism as SSRF to the EC2 instance metadata service; if that is right, blocking the link-local metadata address in the fetcher and at the network layer would have stopped the credential theft. IMDSv2, which adds a session token that most SSRF requests cannot obtain, did not exist yet: AWS released it on 19 November 2019. CVE-2021-26855 (ProxyLogon) was an SSRF in Microsoft Exchange Server that unauthenticated attackers chained into remote code execution.

Consider mixed DNS answers and authority carried by the fetcher

A hostname may resolve to several addresses. If the policy rejects internal addresses but the implementation checks only the first answer, connection fallback can select a forbidden second answer. A narrow fetcher should validate the candidate address set under its documented policy and ensure the connection uses only approved destinations. The policy must cover IPv4 and IPv6 rather than assuming IPv6 literals or mapped representations are harmless variations. A proxy that resolves DNS on behalf of the application must enforce the same destination restriction at its own connection boundary.

The fetcher's ambient authority matters as much as the host. A request to an allowed partner may accidentally include the caller's Authorization header, cookies or a cloud credential attached by a generic client. Construct a fresh outbound request with the minimum required headers. If partner authentication is needed, choose a credential scoped to that partner and operation, not a broadly privileged application identity. Never reuse incoming headers wholesale across the trust boundary.

A successful response can still be dangerous when interpreted. Downloading HTML and then rendering it inside an authenticated application origin creates a separate output problem; fetching an archive and extracting it creates a filesystem problem, covered in /domains/appsec/secure-features/archive-path-safety/. The destination policy authorizes the network connection only. Subsequent processing requires its own content and resource contract.

For a useful regression extension, let an allowed name resolve to [P,I], where P is approved and I is internal. Under a reject-mixed-set policy the result is zero connections. Under a carefully constrained selection policy, only P may be attempted and failure must not fall back to I. Choose one behavior explicitly and assert it rather than leaving fallback to an HTTP library default.

Guided practice

Classify URL imports against the complete narrow importer contract.

Supplied scenario material
Plain text
Allowed host: files.partner.example.test; HTTPS/443; no userinfo; redirects disabled.
Controlled DNS P = permitted public test endpoint. Controlled DNS I = forbidden internal endpoint. These are labels, not real addresses.
A: https://files.partner.example.test/a -> P, 1200-byte response.
B: https://files.partner.example.test.attacker.invalid/a -> P.
C: https://files.partner.example.test@internal.example/a -> I.
D: https://files.partner.example.test/a -> P; 302 Location http://internal.example/private.
E: allowed URL; check resolves P; a second DNS lookup would resolve I.
F: allowed URL -> P; decoded stream emits 2097153 bytes.
G: allowed URL -> P; response completes exactly at 2097152 bytes before deadline.
  1. 1. Parse instead of substring matching

    Identify the host and allow/deny decision for A, B and C.

    Expected result and reasoning

    A passes the origin rule. B's host ends in attacker.invalid and fails exact comparison. C's host is internal.example; files.partner.example.test is userinfo. B and C create zero outbound connections.

  2. 2. Trace redirects

    Evaluate D and count permitted connection attempts.

    Expected result and reasoning

    One connection to P may occur. Redirect following is disabled, so zero connections to I occur and no imported content is published. A 302 response is not permission to change destinations.

  3. 3. Eliminate the resolution gap

    Explain the vulnerability in E and state the connection requirement.

    Expected result and reasoning

    Checking P then connecting after a fresh resolution permits a different destination I. Connect using the vetted address while keeping the original hostname for TLS verification, or enforce the complete rule in the egress proxy.

  4. 4. Apply response limits

    Compare F and G and state stored output after failure.

    Expected result and reasoning

    F exceeds the decoded-byte cap by one byte and is aborted; partial content is not published. G is at the inclusive limit and can succeed if other checks pass. Compressed wire length alone would not establish either result.

Your deliverable: A seven-case destination and connection-attempt table, including redirect, DNS and byte-limit outcomes.

Paper exercise: DNS answers, redirects and response sizes are supplied as labels, so no network is needed. The Node.js and Go code above run the same rules against real sockets.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Repair the code

SSRF lab: Guard an outbound fetch against SSRF

SSRF validation lab: Validate a fetch URL with ipaddress

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. OWASP Server Side Request Forgery Prevention
  2. MITRE CWE-918: server-side request forgery. CWE 4.20.
  3. IANA IPv4 special-purpose address registry
  4. IANA IPv6 special-purpose address registry
  5. Amazon EC2: configure the instance metadata options
  6. Azure Instance Metadata Service
  7. Google Cloud: view and query VM metadata
  8. Node.js: net.BlockList. Node.js v26.11.1.
  9. Go: net.Dialer
  10. Krebs on Security: what we can learn from the Capital One hack. 2 August 2019.
  11. KrebsOnSecurity: Capital One data theft impacts 106M people. 30 July 2019.
  12. United States v. Paige A. Thompson: criminal complaint (W.D. Wash.). Case 2:19-mj-00344, filed 29 July 2019.
  13. Capital One: information on the 2019 cybersecurity incident
  14. AWS Security Blog: add defense in depth against open firewalls, reverse proxies and SSRF vulnerabilities with enhancements to the EC2 Instance Metadata Service (IMDSv2). 19 November 2019.
  15. NVD: CVE-2021-26855, SSRF in Microsoft Exchange (ProxyLogon)

Reference information

ASVS 5.0.0 requirements
v5.0.0-1.3.6 · v5.0.0-13.2.4 · v5.0.0-15.3.2
CAPEC attack patterns
OWASP WSTG v4.2 tests
WSTG-INPV-19

Download this guide as Markdown