Safeguard
Application Security•

ASVS 5.0 V1.3.6: Block SSRF With Strict Allowlists

ASVS V1.3.6 requires allowlisting protocols, domains, paths and ports before server side requests. See a safe fetch helper and a test plan for SSRF.

Safeguard Security Team
5 min read

ASVS 5.0 V1.3.6 (Level 2) requires protection against server side request forgery. Before your server calls another service using data a user influenced, that data must be validated against an allowlist of protocols, domains, paths and ports, and dangerous characters must be removed. Anything outside the list is refused.

Why it matters

Features such as webhooks, URL previews, image imports and PDF generators make your server fetch addresses chosen by users (CWE-918). From inside your network, that fetch can reach things the user cannot: cloud metadata endpoints that hand out credentials, internal admin panels, databases with HTTP interfaces, and localhost services. A single SSRF flaw has often been the first step in a large cloud breach.

The attack has many disguises. An attacker can use http://127.1/, a decimal or hex form of an IP address, an IPv6 address, a hostname that resolves to a private address, a redirect from an allowed host to an internal one, or a DNS record that changes between your check and your request.

How to implement it

Use an allowlist of destinations whenever the set of targets is known. If users may enter arbitrary public URLs (a link preview, for instance), resolve and check the address yourself, and connect to the address you checked.

import ipaddress, socket
from urllib.parse import urlsplit
import requests

ALLOWED_SCHEMES = {"https"}
ALLOWED_PORTS = {443}

def is_public(ip: str) -> bool:
    a = ipaddress.ip_address(ip)
    return a.is_global and not a.is_multicast

def fetch(url: str) -> bytes:
    parts = urlsplit(url)
    if parts.scheme not in ALLOWED_SCHEMES or (parts.port or 443) not in ALLOWED_PORTS:
        raise ValueError("scheme or port not allowed")
    host = parts.hostname
    if not host:
        raise ValueError("no host")
    infos = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
    if not infos or not all(is_public(i[4][0]) for i in infos):
        raise ValueError("destination not public")
    r = requests.get(url, timeout=5, allow_redirects=False, stream=True)
    return r.raw.read(1_000_000)

This example has a known limit: the library resolves the name again when it connects, which leaves a small window for DNS rebinding. For high risk features, connect to the validated IP directly while sending the original Host header, or route all outbound requests through an egress proxy that enforces the policy.

Key controls to combine:

  1. Allowlist the scheme (https only where possible), the port and, if you can, the host names.
  2. Block private, loopback, link local and metadata ranges after DNS resolution, including IPv6.
  3. Disable redirects, or re-validate every hop.
  4. Set short timeouts and response size limits.
  5. Do not return raw responses to the user. Return only the data you need.
  6. Enforce network level egress rules, and require token based metadata access on cloud instances, so a bug in code is not the only barrier.

How to test it

Submit each of these to every feature that fetches a URL:

  • http://127.0.0.1:8080/, http://localhost/, http://[::1]/
  • http://169.254.169.254/ (a metadata address)
  • http://2130706433/ and http://0x7f000001/
  • A public URL that redirects to http://127.0.0.1/
  • A hostname you control that resolves to a private address
  • file:///etc/passwd and gopher:// URLs

Each request must be refused before any connection is made. Use a listener you control inside your network to confirm no request arrives. Dynamic scanning with out of band callbacks is effective here, and the demo below shows a scan against a web app.

Click through it at your own pace, or open it full screen.

Common mistakes

  • Checking the host name string rather than the resolved address.
  • Blocklisting localhost and 127.0.0.1 but missing other encodings and IPv6.
  • Following redirects automatically.
  • Validating once and then reusing a URL that gets resolved again.
  • Treating the metadata endpoint as unreachable because "we are not on that cloud".
  • Parsing the URL with one library and requesting with another, so they disagree about the host.

Try it yourself

Set up a safe lab in five minutes. Start a listener on your machine (python3 -m http.server 8081) and point your fetching feature at http://127.0.0.1:8081/. Before your fix, you will see a request in the listener output. After the fix, there should be none.

Repeat with a redirector: run a second tiny server that answers with a 302 to http://127.0.0.1:8081/, and submit its public address. If the listener still gets a hit, your code follows redirects without re validating.

Finally, wrap your URL validation in a unit test table of hostile addresses, including IPv6 forms and numeric encodings, and keep it in your repository. Each time someone changes the fetch helper, the table runs. Link previews and webhook senders are the features where regressions most often reappear.

Where possible, add the network layer too: an egress rule on the application subnet that drops traffic to private ranges and the metadata address. Code and network controls together mean a single mistake does not become a breach.

Quick check

Why check the resolved IP and not only the hostname?

An attacker can register a public looking hostname that points to a private address. Only the resolved address shows where the request will really go.

Why disable redirects?

An allowed server can redirect to an internal address. Each redirect target needs the same validation as the first URL.

Sources

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.