Articles / Security

  • ssrf
  • url-parsing
  • go
  • http
  • dns
  • netip

When URL Parsers Disagree, SSRF Finds the Gap

SSRF does not need a broken parser. It only needs two pieces of software to disagree about what a URL means. Your validator sees an allowed hostname, the HTTP client sees an internal address, and the request sails off with everyone technically following their own rules.

This happens in image importers, webhook testers, URL previews and "fetch this document" endpoints. The dangerous part is often not the network call itself. It is the gap between parsing, validation, DNS resolution and the eventual connection.

The string is not the destination

A URL is a structured value, not just a string containing a hostname. Its authority can contain user information, a host and a port. The host is also allowed to be a name, an IPv4 address or an IPv6 literal. RFC 3986 describes that structure, but real URL libraries do not all implement the same rules or the same normalisation.

Take a URL with user information:

https://[email protected]/admin

The hostname is 127.0.0.1. The text at the beginning is user information, not a reassuring statement about the destination. A validator that searches for trusted.example anywhere in the input has already lost.

Always validate the parsed host field. Never validate by looking for a substring in the original URL, and do not assume that a successful parse means the URL is safe or even useful.

Backslashes are where the arguments start

Different URL families have different ideas about separators. A WHATWG-style browser parser treats backslashes specially for HTTP URLs. An RFC-oriented parser does not treat a backslash as the same thing as a slash, and RFC 3986 does not admit it in user information.

That makes strings such as this particularly awkward:

http://trusted.example\@evil.example/

One component may interpret the backslash as a path separator and obtain trusted.example as the host. Another may treat the authority differently, or reject the string. The problem is not that one parser is secretly evil. The problem is that validation and dereferencing have different interpretations.

OWASP's guidance is refreshingly blunt here: do not accept complete URLs when an IP address or domain name is enough. If a URL must cross a service boundary as text, parser disagreement should be a rejection condition, not something to paper over with more string replacements.

Parsing, resolving and connecting are separate decisions

Even if every component agrees on the hostname, DNS adds another layer. The name you validate is not necessarily the address you connect to. DNS can return private, loopback or link-local addresses, and a hostname can return different answers over time.

There are three distinct checks to make:

  • Parse the input using one deliberately chosen URL implementation.
  • Resolve the hostname and reject addresses outside the policy.
  • Connect to one of the checked addresses, rather than resolving the name again behind your back.

Checking DNS once and then dialling the name is not an atomic operation. It leaves room for DNS rebinding, where validation observes a public address but the later connection receives an internal one.

Redirects create the same problem at the HTTP layer. An allowed URL can return a redirect to http://127.0.0.1/. If the client follows redirects automatically, the original validation has become a decorative gesture.

A small Go client with the important guardrails

The example below is intentionally narrow. It only permits HTTP and HTTPS, rejects user information and fragments, checks every resolved address, disables redirects, and dials a checked IP directly.

package fetch

import (
	"context"
	"fmt"
	"net"
	"net/http"
	"net/netip"
	"net/url"
	"strconv"
)

func Fetch(ctx context.Context, raw string) (*http.Response, error) {
	u, err := url.Parse(raw)
	if err != nil {
		return nil, fmt.Errorf("parse URL: %w", err)
	}
	if u.Scheme != "http" && u.Scheme != "https" {
		return nil, fmt.Errorf("unsupported scheme %q", u.Scheme)
	}
	if u.Hostname() == "" || u.User != nil || u.Fragment != "" {
		return nil, fmt.Errorf("unsupported URL form")
	}

	host := u.Hostname()
	port := u.Port()
	if port == "" {
		port = "80"
		if u.Scheme == "https" {
			port = "443"
		}
	}
	if _, err := strconv.Atoi(port); err != nil {
		return nil, fmt.Errorf("invalid port: %w", err)
	}

	resolver := net.DefaultResolver
	dialer := &net.Dialer{}
	transport := &http.Transport{}
	transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
		addresses, err := resolver.LookupNetIP(ctx, "ip", host)
		if err != nil {
			return nil, err
		}
		for _, address := range addresses {
			ip := address.Unmap()
			if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
				ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() {
				continue
			}
			conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
			if err == nil {
				return conn, nil
			}
		}
		return nil, fmt.Errorf("no permitted address for %q", host)
	}

	client := &http.Client{
		Transport: transport,
		CheckRedirect: func(*http.Request, []*http.Request) error {
			return http.ErrUseLastResponse
		},
	}
	return client.Get(u.String())
}

This is a useful shape, not a universal policy. The address checks need to match your threat model and platform. You may also need to reject unusual ports, cap response sizes, set connection and overall deadlines, and limit the number of requests an endpoint can make.

There is one subtle detail in the example: the URL still supplies the original hostname to the HTTP layer, while the dialler connects to the checked IP. That lets TLS use the hostname for certificate verification and SNI, without asking DNS for a new answer during the connection.

The safer design is to remove the URL

The strongest SSRF defence is an allowlist of destinations. Accept an identifier such as github or thumbnail-service, look it up in server-side configuration, and construct the scheme, hostname, port and path yourself.

This removes entire classes of ambiguity:

  • the caller cannot choose a different scheme;
  • the caller cannot add user information or a surprising port;
  • the caller cannot redirect the client to another host through a crafted authority;
  • the server knows which destination it meant to contact.

If arbitrary domains are genuinely required, accept a hostname separately from the path and query, then apply an explicit policy to the hostname. Do not accept a complete URL merely because it is convenient for the frontend.

Test the boundary, not just the happy path

Unit tests should compare what the validator believes with what the actual HTTP client will use. Include user information, mixed case, IPv6 literals, IPv4-mapped IPv6 addresses, empty hosts, invalid ports, encoded delimiters, backslashes and redirects.

Quick detour: fuzzing is unusually good at finding these bugs because the interesting inputs are mostly punctuation. A few thousand generated combinations of @, :, \, brackets and percent-encoding can expose assumptions that a tidy table of example URLs will miss.

Log the decision in a way that is useful without leaking credentials or full query strings. Record the parsed scheme, hostname, resolved address class and whether a redirect was refused. The exact rejected input is often attacker-controlled, so it does not belong in a log line by default.

A URL parser is a syntax tool. It is not an allowlist, a DNS policy, a redirect policy or a firewall. Once those responsibilities are separated, the gap that SSRF relies on becomes much harder to find.