Get unstuck

Hint 1: Resolve, then judge the address

A name like metadata.internal looks harmless; what matters is the address client.resolve returns. An IP-literal host is its own address. Judge the address against the private, loopback, link-local and other special-purpose ranges, documentation addresses included.

Hint 2: A redirect is a new request

Validate and classify the redirect target with the same policy before you follow it, every hop. An allowlist of hostnames alone does not survive a redirect, and a public first hop can send you to 127.0.0.1.

Reveal the reference patch and explanation
Fixedfetch-policy.mjsJavaScript
export async function fetchGuarded(rawUrl, client) {
  let url = rawUrl;
  for (let hop = 0; hop <= 5; hop++) {
    const parsed = URL.parse(url);
    if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
        || parsed.username || parsed.password) return {status: 'blocked'};
    const host = parsed.hostname;
    const literal = ipFromHost(host);
    const addresses = literal ? [literal] : await client.resolve(host);
    if (!addresses.length || !addresses.every(isPublic)) return {status: 'blocked'};
    const response = await client.fetch(parsed.href);
    if (!response.redirect) return {status: 'fetched', url: parsed.href};
    url = new URL(response.redirect, parsed.href).href;
  }
  return {status: 'blocked'};
}

// The IP address of an IP-literal host, or null for a name. The URL parser has already
// normalized 2130706433, 0x7f.1 and 127.1 to 127.0.0.1 and bracketed IPv6 hosts.
function ipFromHost(host) {
  if (host.startsWith('[') && host.endsWith(']')) return host.slice(1, -1);
  return /^\d{1,3}(\.\d{1,3}){3}$/.test(host) ? host : null;
}

function isPublic(address) {
  const h = address.toLowerCase();
  if (h.includes(':'))
    // IPv6: only global unicast 2000::/3, minus documentation 2001:db8::/32. Loopback,
    // unspecified, link-local, unique-local, multicast and IPv4-mapped (::ffff:…) fall outside it.
    return /^[23][0-9a-f]{3}:/.test(h) && !/^2001:0?db8:/.test(h);
  const n = h.split('.').map(Number);
  if (n.length !== 4 || n.some(o => !(o >= 0 && o <= 255))) return false;
  const [a, b, c] = n;
  if (a === 10 || a === 127 || a === 0 || a >= 224) return false;        // private, loopback, this-host, multicast/reserved
  if (a === 169 && b === 254) return false;                              // link-local, incl. 169.254.169.254
  if (a === 172 && b >= 16 && b <= 31) return false;                     // private
  if (a === 192 && b === 168) return false;                              // private
  if (a === 100 && b >= 64 && b <= 127) return false;                    // carrier-grade NAT
  if (a === 192 && b === 0 && (c === 0 || c === 2)) return false;        // protocol assignments, TEST-NET-1
  if (a === 198 && (b === 18 || b === 19)) return false;                 // benchmarking
  if (a === 198 && b === 51 && c === 100) return false;                  // TEST-NET-2
  if (a === 203 && b === 0 && c === 113) return false;                   // TEST-NET-3
  return true;
}

Named public hosts (api.partner.test → 140.82.121.3) are fetched; their safe public redirects are followed. The metadata address 169.254.169.254, and 2130706433, 0x7f000001, 127.1 and [::1] after the parser normalizes them, are blocked because the single literal address is link-local or loopback. A name that resolves to 10.0.0.5, to [::ffff:127.0.0.1] or to the documentation address 198.51.100.7 is blocked, as is a name that returns two addresses where only one is private, because every address must be public. A 302 from a public host to http://127.0.0.1/admin is blocked at the second hop; a relative redirect is resolved against the hop it came from, and a chain of exactly five redirects is followed while a sixth is not. Only a whole dotted quad is an IPv4 literal, so 93.184.216.34.partner.test is resolved like any name. The first and last address of every reserved range are blocked and their neighbors are fetched; for IPv6 only the first group decides, so 2606:4700::2001:db8:1 is public while 2001:db8::1 and fd12:3456::1 are not. gopher://, file:// and a URL with credentials are blocked before any fetch. A name that does not resolve is blocked. The policy still cannot stop DNS rebinding on its own: resolve returns the address used at check time, and a real client must connect to that same address (connect-to-IP or a pinned lookup), because a name can resolve to a public address now and a private one when the socket opens.

Compare it with your own fix, then rerun the tests. The editor is not changed when you open this explanation.

Inspect the actual test harness

The browser and the lab download run the same cases, including cases that catch common wrong fixes, such as comparing with one fixture value instead of the real rule. The download includes the complete test adapter and cryptographic fixtures.

Download cases.mjs
How the browser sandbox runs your code

Your code runs in QuickJS 2025-09-13 (quickjs-emscripten 0.32.0) inside a Web Worker, isolated from the page, with a 32 MiB memory limit and a 2.5-second time limit. The test cases and the pass or fail verdict run outside that sandbox, so changing built-ins such as Array.prototype.push cannot change the result. The lab download runs the same cases under Node.js, where your code runs directly.

The same as in current Node.js:

  • ES2025 syntax and built-ins, including Set methods such as intersection, iterator helpers, Promise.try, RegExp.escape and the RegExp v flag

Added to the sandbox so that it behaves like Node.js:

  • structuredClone, Array.fromAsync, queueMicrotask, atob and btoa
  • URL and URLSearchParams, parsed by your browser’s own URL implementation
  • TextEncoder and TextDecoder for UTF-8
  • Intl.Collator and a locale-aware String.prototype.localeCompare
  • console.log and related methods, whose output appears with your results

Not available in the browser sandbox:

  • Timers (setTimeout, setInterval), fetch and other network or storage APIs; the exercises are deterministic and offline
  • Node.js modules and globals: import, require, process and Buffer
  • Web Crypto (crypto.getRandomValues, crypto.subtle); each workshop supplies the cryptographic operations it needs
  • Intl other than Collator: toLocaleString and toLocaleDateString ignore the locale, so numbers have no digit grouping and dates use one fixed format in the device’s time zone
  • using declarations, Symbol.dispose and DisposableStack (explicit resource management)
  • Uint8Array.fromBase64 and toBase64, JSON.rawJSON, Atomics and Error.captureStackTrace

Use the lesson in your own system

client.resolve and client.fetch are deterministic fixtures: resolve returns fixed addresses and fetch returns a scripted redirect or a final response, so the lab does not perform real DNS or network I/O. It checks the policy and the redirect loop, not a real HTTP client: production also needs connect-to-IP (or a resolver whose address is reused for the connection) against DNS rebinding, a timeout and response-size limit, and a deny of unexpected ports and of IPv6 as well as IPv4; the guide’s full deny list also covers the IPv6 prefixes that embed IPv4 addresses (NAT64, 6to4 and Teredo). Prefer an allowlist of destinations where the feature permits one.

Your next task: In your own outbound-fetch code, where is the address the policy checks and the address the socket connects to the same lookup, and how are redirects handled?

OWASP SSRF prevention cheat sheet

Related guide: Constrain server-side fetches to their intended destinations