Server-side request forgery makes a server issue a request selected or influenced by an attacker. A thumbnail service, importer, webhook tester, or document renderer may reach destinations that the requesting user cannot reach directly. The server contributes its network position and whatever credentials that particular fetch path actually attaches. A URL fetch does not automatically inherit every account credential available to the process.

Identify the authority being delegated

Internal services, loopback administration endpoints, and cloud metadata services are important targets when reachable. The consequence depends on authentication, response visibility, allowed methods, and network policy. Blind SSRF can still trigger actions or reveal timing without returning the target’s response. Do not rank severity from the feature’s name alone; identify what it can contact and what that contact can accomplish.

On EC2, instance-role credentials can be available through metadata when a role is attached and the relevant access is permitted. The role-name listing and the role-specific credential document are separate requests. Those temporary credentials represent that role’s permissions, not unlimited AWS access or necessarily the credentials used by every application on the instance.

IMDSv2 requires a token obtained through a PUT request and supplied in subsequent metadata requests. Requiring it blocks a simple unauthenticated GET-only path, but does not solve arbitrary SSRF that can perform the necessary methods and headers. Pair metadata configuration with least-privilege roles and effective network restrictions. Metadata can have IPv4 and, when enabled and supported, IPv6 reachability; review both.

Parse, resolve, and preserve the checked destination

An allowlist should describe the feature’s intended destinations. When possible, accept a resource identifier rather than an arbitrary URL. Otherwise constrain schemes, hosts, and ports with a parser whose interpretation matches the HTTP client. Reject unsupported userinfo and ambiguous syntax rather than approving a URL merely because an allowed name appears somewhere inside it.

For example, trusted.example before an at-sign is userinfo, not proof that the destination host is trusted. Legacy numeric IPv4 forms may be interpreted differently by different URL parsers and resolvers. IPv6 loopback is ::1, not 127.0.0.1. A string check for one spelling is therefore insufficient, and one runtime’s parsing result is not a portable guarantee about another client’s behavior.

Resolve allowed names and evaluate all returned addresses against the intended network policy. For a public-only fetcher, reject loopback, private, link-local, multicast, and other disallowed ranges. Address libraries and registries evolve, so test the versions actually deployed. A destination being globally routable does not itself make it an authorized or benign service.

When it breaks

Checking DNS and then allowing the client to resolve again leaves a time-of-check gap. Pin the validated connection address using a supported transport mechanism while preserving the intended TLS hostname, certificate verification, and HTTP authority. A proxy may resolve independently; validate the actual egress path, retries and fallback connections rather than assuming one local preflight controls them.

Redirects create new destinations. Disable them unless needed, or validate each hop before following it, including resolution and address selection. A previously approved hostname cannot authorize an arbitrary Location target. Egress rules provide a separate containment layer; they should match the workload’s required services and cover relevant address families.

A 200 response does not by itself prove SSRF, and a 4xx response does not prove that no forbidden request occurred. Applications can wrap errors, return cached data, or reject after making contact. Confirm the request against a controlled observation endpoint and distinguish response handling from actual network activity. Never probe real metadata credentials merely to make an example convincing.

Destination parsing and all-address checks lead to a retained public snapshot versus rejected mixed snapshot, followed by a separately required transport-binding layer, redirects, proxy paths and IMDS method/header conditions.
Approved data is not proof of actual transport. Match policy to the real fetch path, including resolution, redirects, retries, proxies and TLS identity.

Check it quickly

Save as ssrf_policy.py and run python3 ssrf_policy.py. This deliberately narrow offline policy uses supplied address snapshots instead of DNS. The public address is only an input value; the script never contacts it. All decisions happen in memory, without credentials, sockets, metadata calls, or changes to network controls.

import ipaddress
from urllib.parse import urlsplit

ALLOWED = {'cdn.example.test'}


def approve(url, addresses):
    # Deliberately narrow input language for this offline model.
    if any(ord(c) <= 32 or ord(c) >= 127 for c in url) or '\\' in url:
        raise ValueError('unsupported URL characters')
    parts = urlsplit(url)
    if parts.scheme != 'https' or parts.hostname not in ALLOWED:
        raise ValueError('scheme or host denied')
    if parts.username is not None or parts.password is not None or parts.fragment:
        raise ValueError('userinfo or fragment denied')
    if parts.port not in (None, 443):
        raise ValueError('port denied')
    if not addresses:
        raise ValueError('empty resolution')
    checked = []
    for text in addresses:
        address = ipaddress.ip_address(text)
        if '%' in text or address.is_multicast or not address.is_global:
            raise ValueError('non-public address denied')
        checked.append(str(address))
    return parts.hostname, tuple(checked)


def rejected(label, url, addresses):
    try:
        approve(url, addresses)
    except ValueError:
        print('rejected:', label)
        return
    raise RuntimeError('unexpected acceptance: ' + label)


url = 'https://cdn.example.test/logo.png'
# Address values only. There is no DNS lookup, socket, or HTTP request.
public_snapshot = ['8.8.8.8']
name, pinned = approve(url, public_snapshot)
rejected('userinfo on allowed host', 'https://attacker@cdn.example.test/logo.png', public_snapshot)
rejected('userinfo host confusion', 'https://cdn.example.test@169.254.169.254/', public_snapshot)
rejected('decimal host', 'https://2130706433/', ['127.0.0.1'])
rejected('loopback IPv6', 'https://[::1]/', ['::1'])
rejected('link-local answer', url, ['169.254.169.254'])
rejected('mixed answers', url, ['8.8.8.8', '127.0.0.1'])
rejected('multicast answer', url, ['224.0.0.1'])
rejected('redirect destination', 'https://internal.example.test/', ['10.0.0.1'])
rejected('changed DNS answer', url, ['127.0.0.1'])
if pinned != ('8.8.8.8',) or name != 'cdn.example.test':
    raise RuntimeError('approved snapshot changed')
print('PASS: public fixture accepted; nine denied cases; approved address snapshot retained')

Expect nine rejected cases and PASS. The allowed synthetic hostname with one public address establishes a positive path. Userinfo on an allowed host, userinfo host confusion, numeric and IPv6 destinations outside the allowlist, link-local and mixed answers, multicast, a redirected hostname, and a changed DNS answer fail. The approved tuple remains unchanged. Numeric and IPv6 URLs fail the host allowlist first, so these cases do not independently test IPv6 address classification. No example ran in this source review.

This tests decisions and retained data, not a production fetch implementation. It does not exercise DNS, transport pinning, proxies, TLS, redirect execution, or response limits. Passing it cannot establish that an HTTP library connects to the approved address. Integration tests must verify that wiring, including timeouts and resource limits, using isolated destinations with synthetic responses.

Sources