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::/7and IPv4-mapped IPv6. - Let the URL parser normalize alternative spellings such as
2130706433and0x7f.1before 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.
| Setting | Value | Note | Source |
|---|---|---|---|
| Schemes | https only, unless the feature documents another | — | OWASP SSRF Prevention Cheat Sheet |
| Ports | 443 only | — | OWASP SSRF Prevention Cheat Sheet |
| Destination addresses | Public unicast only: every IANA special-purpose IPv4 and IPv6 block, multicast and IPv6 outside 2000::/3 denied | — | IANA special-purpose address registries |
| Redirects | Not followed; if the feature needs them, re-check each hop with a small hop limit | — | OWASP ASVS v5.0.0 V15.3.2 |
| Response size | A per-feature decoded-byte cap; 2 MiB in this example | Abort at the cap plus one byte. | — |
| Total deadline | A per-feature limit; 5 seconds in this example | — | — |
| AWS instance metadata | HttpTokens=required (IMDSv2) and HttpPutResponseHopLimit=1 | Use a hop limit of 2 only where containers must reach IMDS. | Amazon EC2: configure the instance metadata options |
| Outbound credentials | None copied from the incoming request | — | OWASP ASVS v5.0.0 V13.2.4 |
- Request an
http:URL, a non-443 port and a URL with userinfo; each must fail before any connection. - Request decimal, octal and hexadecimal spellings of
127.0.0.1and169.254.169.254; all must be blocked. - Point a hostname at a loopback listener and confirm the listener records zero connections.
- Serve a 302 to an internal address from an allowed host and confirm the redirect is not followed.
- Stream one byte more than the cap and confirm the fetch aborts with nothing published.
- On AWS, run
aws ec2 describe-instancesand confirmMetadataOptions.HttpTokensisrequiredon 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
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
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.
A URL fetcher reaching cloud metadata, and the controls that stop it
- 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. - Without controlsA naive fetcher connects to the link-local metadata address. With IMDSv1 the service answers a plain
GETwith the instance role’s temporary credentials, and the preview returns them to the attacker. - Check in the fetcherThe fetcher parses the URL with its HTTP client’s parser, allows only
httpson port 443, resolves the name once and refuses link-local, private and other special-purpose addresses before any connection is made. - 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), aGETwithout a session token is refused with 401. - 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.
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.
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/.
// 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);
});
}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.
| Range | Name | Why it is denied |
|---|---|---|
0.0.0.0/8 | This network | 0.0.0.0 reaches local services on some systems |
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 | Private use | Internal networks |
100.64.0.0/10 | Shared address space | Carrier-grade NAT and internal cloud networks |
127.0.0.0/8, ::1/128 | Loopback | Services on the fetcher itself |
169.254.0.0/16, fe80::/10 | Link local | Includes the metadata address 169.254.169.254 |
fc00::/7 | Unique local | Internal IPv6; includes AWS metadata at fd00:ec2::254 |
::ffff:0:0/96 | IPv4-mapped IPv6 | Another spelling of an IPv4 address |
64:ff9b::/96, 64:ff9b:1::/48 | IPv4/IPv6 translation | A NAT64 gateway can reach internal IPv4 addresses |
2002::/16, 2001::/23 | 6to4, Teredo and IETF assignments | Embed 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::/20 | Protocol assignments and documentation | Never a legitimate public destination |
198.18.0.0/15, 192.88.99.0/24 | Benchmarking, deprecated 6to4 relay | Not public destinations |
224.0.0.0/4, 240.0.0.0/4, 255.255.255.255/32, ::/128 | Multicast, reserved, broadcast, unspecified | Not unicast destinations |
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 aPUTrequest, 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: Googleheader. That stops SSRF primitives that cannot set headers, not one that controls them. - Azure: IMDS requires the
Metadata: trueheader and rejects requests that containX-Forwarded-For. - Everywhere: give the fetching workload its own identity with only the permissions it needs, so stolen credentials grant little.
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.
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
}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.
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.
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
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. 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.invalidand fails exact comparison. C's host isinternal.example;files.partner.example.testis userinfo. B and C create zero outbound connections.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. 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. 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.
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
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.
- OWASP Server Side Request Forgery Prevention
- MITRE CWE-918: server-side request forgery. CWE 4.20.
- IANA IPv4 special-purpose address registry
- IANA IPv6 special-purpose address registry
- Amazon EC2: configure the instance metadata options
- Azure Instance Metadata Service
- Google Cloud: view and query VM metadata
- Node.js: net.BlockList. Node.js v26.11.1.
- Go: net.Dialer
- Krebs on Security: what we can learn from the Capital One hack. 2 August 2019.
- KrebsOnSecurity: Capital One data theft impacts 106M people. 30 July 2019.
- United States v. Paige A. Thompson: criminal complaint (W.D. Wash.). Case 2:19-mj-00344, filed 29 July 2019.
- Capital One: information on the 2019 cybersecurity incident
- 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.
- NVD: CVE-2021-26855, SSRF in Microsoft Exchange (ProxyLogon)
Reference information
- SDLC phase
- DesignDevelopment
- 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