Validate a fetch URL with ipaddress

An image importer fetches any URL a user submits, so a crafted URL reaches the cloud metadata service or an internal host, directly or through an encoded IP literal. Repair the validator so it parses the URL, resolves the host and allows only public addresses.

Requires Node.js 22 or newer, which runs the tests, and Python 3.12 or newer, which runs your code (the command python3, or the program named in the PYTHON environment variable). No package installation or server needed. To work in a container instead, open the folder with VS Code Dev Containers or GitHub Codespaces: .devcontainer/devcontainer.json uses the Dev Containers Node.js 24 image, pinned by digest.
1. Unzip all files into one folder.
2. In that folder, run: node --test exercise.test.mjs
3. Edit url_policy.py, then run the same command again.

The starter deliberately fails some tests. A successful repair passes every case, including cases that catch common wrong fixes. The browser runs this same grader with your code in Pyodide (CPython compiled to WebAssembly); locally it runs your code with python3. If you completed the optional first stage in the browser, Download my work adds your input as stage1-input.json and the tests include it. Only run code you wrote or reviewed.

1. Reproduce the reach inside
Run the starter. It allows every http(s) URL, so the metadata address and an internal name both pass.

2. Parse and limit the scheme
Split raw_url with urlsplit, allow only http and https, and reject parts.username or parts.password. Compare the parsed parts, not the raw text: parts.scheme and parts.hostname are lowercased and the hostname loses an IPv6 literal’s brackets, but urlsplit does not decode encoded IP forms such as 2130706433.

3. Classify the addresses with ipaddress
For an IP-literal host, ipaddress.ip_address succeeds on the hostname; for a name, call resolve. Block when the list is empty or any address has ip.is_global false, is multicast, or is an IPv4-mapped address whose mapped form is not global.

4. Keep legitimate fetches
A name that resolves to a public address, or a public IP literal, is allowed. ipaddress rejects 2130706433 and 0x7f000001 as literals, so they go to resolve, which, like glibc’s resolver, returns 127.0.0.1: loopback, so they are blocked, as is the link-local 169.254.169.254.

SUPPLIED CONTRACT
check_url(raw_url, resolve) returns "allow" or "block". raw_url is the user’s URL. resolve(hostname) returns a list of IP address strings for a name, or an empty list when it does not resolve; an IP-literal host is not resolved. Parse raw_url with urllib.parse.urlsplit; allow only the http and https schemes and reject a URL carrying a username or password. Determine the addresses the host would use — the literal itself for an IP host (parse it with ipaddress.ip_address; parts.hostname has already dropped the brackets of an IPv6 literal), or resolve(hostname) for a name — and block the request unless every address is global unicast. Use the ipaddress module: an address is acceptable only when ip.is_global is true and it is not multicast, and an IPv4-mapped IPv6 address is judged by its mapped IPv4 address. Block a host that does not resolve and a URL that does not parse.

REFERENCE
reference.py contains an explained solution: A name resolving to 93.184.216.34 is allowed, and that address as a literal is allowed. metadata.internal resolves to 169.254.169.254, whose is_global is false, so it is blocked, as are db.internal (10.0.0.5) and a name that resolves to ::ffff:127.0.0.1 (loopback by its mapped form). ipaddress.ip_address rejects 2130706433, 0x7f000001 and 0177.0.0.1 (it accepts only dotted-decimal IPv4 without leading zeros), so they are resolved as names, and the fixture, like glibc’s resolver on Linux, returns 127.0.0.1 for each: all blocked. [::1] is the IPv6 loopback. A name that returns one public and one private address is blocked, because every address must be global. 224.0.1.1 is blocked as multicast, although is_global is true for it, and http:///latest/meta-data/ has no host to judge. gopher and file URLs, a URL with credentials, an unparseable URL and a name with no address are blocked before or during classification.

SCOPE
resolve is a deterministic fixture, not real DNS, and the lab validates one URL rather than following redirects (the JavaScript SSRF lab adds redirects). Even a correct is_global check cannot stop DNS rebinding on its own: the address the policy checks must be the address the socket connects to, so a real client pins the resolved address or re-checks after connecting. Prefer an allowlist of destinations, and also bound the response size and time and deny unexpected ports.

NEXT TASK
In your own fetchers, is the host resolved and its address range checked before the request, and is that the same address the connection then uses?

SOURCE
https://docs.python.org/3/library/ipaddress.html#ipaddress.IPv4Address.is_global
