urljoin() //host Input Silently Switches Host (tested)
urllib.parse.urljoin() is the standard way to resolve a relative link against a base URL. We measured what happens when the "relative" input starts with // instead of a single / — and it silently resolves to an entirely different domain.
1. A //host input replaces the host, not just the path
from urllib.parse import urljoin
base = "https://trusted.example.com/path/page.html"
print(urljoin(base, "//evil.example.com/x"))
print(urljoin(base, "/absolute"))
https://evil.example.com/x
https://trusted.example.com/absolute
Measured: a single leading / resolves to an absolute path on the same host, exactly as expected. Two leading slashes do something different: per RFC 3986, //evil.example.com/x is a scheme-relative URL — it keeps the base's scheme (https) but takes its entire authority (host) from the input. The result lands on evil.example.com, not trusted.example.com, with no exception and no warning.
2. This is a real risk the moment the second argument is user input
next_param = "//evil.example.com/phish"
redirect_target = urljoin("https://trusted.example.com/login", next_param)
print(redirect_target)
https://evil.example.com/phish
Measured: code that does urljoin(request_base_url, user_supplied_next) to build a post-login redirect — a common pattern — sends the user to a completely different domain if user_supplied_next starts with //. It looks like a harmless typo'd absolute path, not a host override, which is exactly what makes it easy to miss in review.
3. Fix: verify the resulting host explicitly with urlparse()
from urllib.parse import urlparse
base = "https://trusted.example.com/login"
joined = urljoin(base, "//evil.example.com/phish")
same_host = urlparse(joined).netloc == urlparse(base).netloc
print(joined, "-> same host?", same_host)
https://evil.example.com/phish -> same host? False
Measured: urljoin() itself has no option to forbid a host change — it's working as RFC 3986 defines. The fix is to check the output: parse the joined URL with urlparse() and compare .netloc against the expected host (or an allow-list of hosts) before using the result for a redirect, link, or request. Rejecting any user-supplied "relative" input that starts with // outright is an equally valid, simpler guard for the common redirect-parameter case.
Frequently asked questions
Why does urljoin() change the domain when I pass a path starting with //?
We measured //host-style input being parsed as a scheme-relative URL per RFC 3986, which replaces the entire authority (host) of the base URL, not just the path. No error is raised.
Is a //evil.com/path input to urljoin() a security risk?
Yes in practice — we confirmed that if the second argument to urljoin() is user-controlled (e.g. a 'next' redirect parameter), a //host-prefixed value silently redirects to an attacker-chosen domain.
How is //host different from /path in urljoin()?
We measured a single leading slash resolving to an absolute path on the same host as the base URL, while two leading slashes take over the host entirely, keeping only the base's scheme.
How do I stop urljoin() from changing the host?
We verified checking urlparse(result).netloc against the expected host (or an allow-list) after calling urljoin(), since urljoin() itself has no flag to disable scheme-relative resolution.
Does Python's urljoin() validate that the result stays on the same domain?
No — in our test it always followed RFC 3986 resolution rules exactly, including host takeover via //, with no built-in same-origin check.
Written with AI assistance. Measured on 2026-10-04 with Python 3.13.5's built-in urllib.parse module: called urljoin() with a base URL and a //host-style input, comparing the resulting host to a plain /absolute-path input and verifying the fix with urlparse().netloc. PlainHeaders is our extension; Python's urllib.parse module is built in.