Blog / Eight named reasons our scanner refuses a URL before fetching it

Eight named reasons our scanner refuses a URL before fetching it

A public URL box is a server-side request forgery machine unless something stops it. Lantad's guard names eight rejections, refuses 14 IPv4 ranges and applies eight IPv6 rules, re-runs in full on every redirect hop, and carries one gap we document rather than close.

In short

  • Lantad's SSRF guard, read in core/src/ssrf.ts on 6 August 2026, names exactly eight rejection reasons: invalid_url, scheme_not_http, credentials_in_url, port_not_allowed, hostname_forbidden, private_address, dns_failure and too_many_redirects.
  • The guard refuses 14 IPv4 address blocks and applies eight IPv6 rules, and 13 of the 14 IPv4 blocks appear in the special-purpose registries recorded by RFC 6890, published April 2013 as BCP 153.
  • Redirects are followed manually rather than by the fetch runtime, the full guard including DNS resolution re-runs on every hop, and FETCH_LIMITS.maxRedirects is set to 5 in core/src/config.ts.
  • One gap is documented rather than closed: the guard resolves a hostname and then fetches it, so a hostile authoritative nameserver could rebind between the check and the connection, with Cloudflare's egress restrictions named in the source as the backstop.
  • Two of these defences were once partial and the source comments record it: the scanning opt-out ran on two of five outbound fetch paths, and the scan pipeline buffered a whole response body before testing the size cap.

Paste a URL into a form on a public website and you are asking a stranger's server to make a request on your behalf. That is not an abuse of the feature, it is the feature. It is also the textbook definition of server-side request forgery, and any scanner that takes a string and hands it to fetch is one by default. The interesting engineering is not the measuring. It is everything that has to happen before the first byte moves.

This is a post about the part of our scanner that produces no score at all. It refuses URLs. There are eight named reasons it can refuse one, a list of address ranges it will not touch, a rule that re-runs the whole check every time a site redirects, and one weakness we have written down in the source rather than fixed. All of it is in the repository and all of the figures below were read there on 6 August 2026. Where a number is a setting somebody chose rather than something measured, this post says so.

RejectionWhat triggers itStage
invalid_urlThe string does not parse as a URL even after a scheme is suppliedStatic
scheme_not_httpA scheme other than http or https, such as ftp, mailto, data or javascriptStatic
credentials_in_urlA username or password embedded in the authorityStatic
port_not_allowedAny explicit port other than 80 or 443Static
hostname_forbiddenlocalhost, local, internal, or any name ending .local or .internalStatic
private_addressA private or reserved address, as a literal or as a DNS answerStatic and DNS
dns_failureThe name does not resolve, or resolution throwsDNS
too_many_redirectsMore hops than FETCH_LIMITS.maxRedirects, which is set to 5Redirect
The eight members of the SsrfRejection union in core/src/ssrf.ts, read 6 August 2026. These are design decisions in our own source, not measurements of the web.

What a public URL box actually is

The threat is not exotic. A scanner runs somewhere, and that somewhere has a network position no visitor has. It may sit inside a private network, next to an internal admin service, or on infrastructure where a link-local address answers with instance credentials. A URL is an instruction to open a socket, and if a stranger writes the URL then a stranger chooses the socket. The attacker does not need a bug. They need the feature to work as advertised.

That is why the guard is a separate module with no network code in it at all. Everything in core/src/ssrf.ts is pure: URL normalisation, hostname rules, and address classification for both IP families. DNS is injected through a Resolver interface, which is Node's resolver in the command line tool and DNS over HTTPS in the worker. Pure logic can be tested exhaustively without a network, and the module is exercised by a dedicated spec file covering IPv4 ranges, IPv6 forms, normalisation, bare hosts, the DNS half, redirects and IP literal detection.

The placement matters as much as the logic. In the worker, the guard runs inside a single function that every outbound path funnels through: scans, compare, batch, the micro tools, the brand check, the opportunity analysis and the bulk sitemap audits. That consolidation is recent and the comment above it says why. The scanning opt-out list used to be an optional callback with two call sites, so four of five fetch paths ignored it, and the public promise on our crawler conduct page that an opted-out domain gets a notice instead of a fetch was false on most of them. A lever that works sometimes is not a lever.

None of this is unique to measuring AI crawler access. It is the ordinary cost of accepting a URL from the internet, and anyone building against our API inherits the same problem one layer up.

Order of checks from pasted string to first byte, as implemented in core/src/ssrf.ts and worker/src/scan-intake.ts. Diagram of our own pipeline, not a measurement.

The URL you paste is not the URL we fetch

Almost nobody types a scheme. People paste example.com, or www.example.com/pricing, and a validator that rejects those is a validator nobody reaches. So the first step supplies one, and the rules for doing that are more delicate than they look. A string already carrying a scheme and an authority is left alone, so ftp:// is preserved in order to be rejected honestly a moment later rather than silently rewritten into something fetchable. A protocol-relative string beginning with two slashes keeps its host and gains https. A bare host with an explicit port keeps the port, because the colon there separates a port and not a scheme. An opaque scheme such as javascript: or data: is left untouched so that the scheme check refuses it rather than mangling it into a bad hostname.

Only then does validation run, and by that point the string has already changed in ways worth knowing about. The fragment is stripped, because no server ever sees it. The host is lowercased, which the WHATWG URL standard does during parsing. A trailing dot on a fully qualified name is handled when the forbidden host list is checked, so example.local. cannot slip past a suffix test that example.local fails.

The most useful thing the URL standard does here is arithmetic. Its IPv4 parser accepts hexadecimal parts after 0x, octal parts after a leading zero, and a host given as a single integer, and its serialiser always emits four decimal numbers separated by dots. So 0x7f.1, 017700000001 and 2130706433 all arrive at the range check already written as 127.0.0.1, and the guard only has to understand one form. The specification is at url.spec.whatwg.org, which is not on this site's registered outbound host list, so it is named here rather than linked. Knowing what a machine actually received rather than what a human typed is the same discipline behind what an AI crawler sees on a page, and the reason we refuse to print a grade when the fetch did not happen.

One more static rule comes straight from a specification. Embedded credentials are rejected outright, and RFC 9110, the June 2022 Standards Track document defining HTTP semantics, is explicit about why: a recipient handling an http or https URI reference from an untrusted source should parse for userinfo and treat its presence as an error, because it is likely being used to obscure the authority for the sake of phishing attacks.

Sample Illustrative, not a measurement of any real site.

What a person types

  • example.com/pricing
  • //example.com
  • 0x7f.1
  • 2130706433
  • example.local.
  • https://user:pw@example.com

What the guard evaluates

  • https://example.com/pricing, allowed
  • https://example.com, allowed
  • 127.0.0.1, private_address
  • 127.0.0.1, private_address
  • hostname_forbidden on the .local suffix
  • credentials_in_url, refused
Illustrative inputs against the form each reaches the range check in, following the coercion rules in core/src/ssrf.ts and the WHATWG URL parser. Constructed examples, not scans of any real site.

Fourteen IPv4 ranges, and the document behind each

The IPv4 half of the guard is a table of 14 blocks, checked by masking rather than by string comparison. Thirteen of the 14 appear in the IANA special-purpose address registries recorded by RFC 6890, a Best Current Practice document published in April 2013 as BCP 153, which obsoleted four earlier registry documents in order to put every special-purpose block in one place. Reading the registry is a quicker route to a correct list than reasoning from memory, and it names the defining document for each entry.

Three of the blocks are the private-use ranges from RFC 1918: 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16. Two come from RFC 1122: 0.0.0.0/8, registered as this host on this network, and 127.0.0.0/8, the loopback range. One is shared address space for carrier-grade NAT, 100.64.0.0/10, from RFC 6598. One is link-local, 169.254.0.0/16, from RFC 3927, and the comment beside it in our source names the reason it is not merely tidiness: that range contains the address cloud platforms answer instance metadata on. One is the IETF protocol assignments block 192.0.0.0/24, defined in RFC 6890 itself. Three are the documentation ranges from RFC 5737, 192.0.2.0/24, 198.51.100.0/24 and 203.0.113.0/24. One is the benchmarking range 198.18.0.0/15 from RFC 2544. One is 240.0.0.0/4, registered as Reserved and citing section 4 of RFC 1112.

The fourteenth is the exception, and it is worth stating precisely rather than rounding off. 224.0.0.0/4 does not appear in the RFC 6890 unicast registry at all. It is multicast space: RFC 1112, from August 1989, defines host group addresses as running from 224.0.0.0 to 239.255.255.255. Refusing it is our decision rather than a registry entry, which is the sort of distinction this blog tries to keep visible. A constant is something a person chose, and calling it a finding would be dressing a decision as evidence.

Nothing in that list is a measurement of the web. It is a list of places a scan will not go, and its value to a reader of an AI visibility report is narrow but real: a URL that resolves into one of these ranges never becomes a number, so no report can quietly grade an internal host you did not mean to expose.

BlockName or purposeDefining document
0.0.0.0/8This host on this networkRFC 1122
10.0.0.0/8Private-UseRFC 1918
100.64.0.0/10Shared address spaceRFC 6598
127.0.0.0/8LoopbackRFC 1122
169.254.0.0/16Link local, carries instance metadataRFC 3927
172.16.0.0/12Private-UseRFC 1918
192.0.0.0/24IETF protocol assignmentsRFC 6890
192.0.2.0/24Documentation, TEST-NET-1RFC 5737
192.168.0.0/16Private-UseRFC 1918
198.18.0.0/15BenchmarkingRFC 2544
198.51.100.0/24Documentation, TEST-NET-2RFC 5737
203.0.113.0/24Documentation, TEST-NET-3RFC 5737
224.0.0.0/4Multicast, absent from the RFC 6890 registryRFC 1112
240.0.0.0/4ReservedRFC 1112, section 4
The 14 IPv4 blocks in PRIVATE_V4_RANGES, core/src/ssrf.ts, read 6 August 2026, against the defining document each carries in the RFC 6890 registry. 224.0.0.0/4 is not in that registry.

IPv6 needs eight rules, and two of them look at IPv4

An address family with 128 bits does not get a longer list. It gets a stranger one. The IPv6 half of the guard applies eight rules, and only three of them are the obvious prefix tests: fc00::/7 for unique local addresses, fe80::/10 for link local, and ff00::/8 for multicast. RFC 4193, the Standards Track document from October 2005 that defines the unique local range, states plainly that these addresses are not expected to be routable on the global internet, which is exactly the property that makes them interesting to an attacker and useless to a scanner.

Two more are single addresses rather than ranges: the unspecified address, all zeroes, and the loopback ::1. One more is the documentation prefix 2001:db8::/32, refused for the same reason the IPv4 documentation ranges are.

The last two are the ones that would be easy to miss, and both work by looking at an IPv4 address hidden inside an IPv6 one. The first is the IPv4-mapped form ::ffff:a.b.c.d, where the guard extracts the embedded 32 bits and hands them to the IPv4 rules rather than duplicating the table. The second is NAT64. RFC 6052, from October 2010, defines a Well-Known Prefix of 64:ff9b::/96 for representing IPv4 addresses inside IPv6, and it is explicit that this prefix must not be used to represent non-global IPv4 addresses, requiring translators to drop such packets. That is a rule for translators, not for scanners, so our guard checks the embedded address itself instead of trusting that everything upstream obeyed the specification.

There is also a small rejection worth naming because it is a decision rather than a rule: an address carrying a zone index, as in fe80::1 followed by a percent sign and an interface name, is refused as unparseable. A zone index is never meaningful in a URL we would scan, so treating it as invalid is safer than trying to interpret it. That is the same posture as the robots.txt tester, which reports what a file says rather than guessing what its author meant.

  • Unspecified address, all zeroes Refused outright. Never a fetchable destination.
  • Loopback ::1 Refused outright, the IPv6 counterpart of 127.0.0.1.
  • IPv4-mapped ::ffff:a.b.c.d The embedded 32 bits are extracted and passed to the IPv4 range table.
  • NAT64 Well-Known Prefix 64:ff9b::/96 Embedded address checked against the IPv4 rules. RFC 6052 forbids non-global addresses here, so this catches a translator that ignored it.
  • Unique local fc00::/7 RFC 4193 states these are not expected to be routable on the global internet.
  • Link local fe80::/10 Refused, the IPv6 analogue of the 169.254.0.0/16 case.
  • Multicast ff00::/8 Refused. A scan target is a single host, never a group.
  • Documentation 2001:db8::/32 Refused for the same reason as the IPv4 TEST-NET ranges.
The eight branches of isPrivateIpv6 in core/src/ssrf.ts, read 6 August 2026. Design decisions in our own source, not measurements.

A redirect is a new URL, so the whole guard runs again

A guard that runs once is a guard that runs on the wrong URL. Pass a public hostname that answers 302 with a Location header pointing at a link-local address and a scanner that trusted its first check will fetch it happily. The runtime will not stop this for you, because following redirects is what the runtime is for.

So redirects are followed by hand. The fetch call sets redirect to manual, reads the Location header itself, resolves it against the current URL, and runs the complete guard on the result, DNS resolution included, before making the next request. The comment above that loop states the reason directly: the automatic behaviour would let a URL that passed the initial check bounce the fetcher to an internal host, a private IP, or the cloud metadata endpoint. The hop count is capped by FETCH_LIMITS.maxRedirects, which is set to 5 in core/src/config.ts, and exceeding it produces the too_many_redirects rejection rather than an error a caller might mistake for a network problem.

Two properties of that loop are worth separating. It is a security boundary first: every hop is a fresh untrusted URL and gets the treatment of one. It is a correctness boundary second, because a report about a page ought to describe the page that answered rather than the address that was typed. Those are different concerns that happen to share an implementation, and conflating them is how a redirect chain ends up graded as if it were one document.

Size is bounded by the same instinct. The reader streams the body and stops at the cap, cancelling the transfer rather than draining a response it is discarding, with the cap set to 3 MB of HTML. The comment records what it replaced: the scan pipeline used to buffer the entire body and check the size afterwards, with no pre-check on the declared length, so an unbounded response could exhaust the isolate. An isolate memory kill is not catchable, so the queue message retried, killed the next isolate and dead-lettered. A hardened reader already existed for other paths and had never been carried across to the one path every scan takes.

None of this decides whether an AI crawler can read your site. It decides whether we can honestly claim to have looked, which is the same reason a robots.txt result depends on what status code the file answered with and on when a crawler last re-fetched it.

Sample Illustrative, not a measurement of any real site.

GET /pricing, guard re-run on every hop

  • hop 0 GET https://example.com/pricing 301
  • Location: https://www.example.com/pricing guard: pass
  • hop 1 GET https://www.example.com/pricing 302
  • Location: /plans guard: pass
  • hop 2 GET https://www.example.com/plans 307
  • Location: http://169.254.169.254/latest/meta-data/ private_address
  • fetch abandoned, nothing read, no score emitted refused
  • cap FETCH_LIMITS.maxRedirects 5
Illustrative redirect chain against the manual loop in worker/src/fetchutil.ts and the cap in core/src/config.ts. Constructed example, not a scan of any real site.

The gap we document rather than close

Every guard of this shape has the same hole, and pretending otherwise would be worse than having it. The check resolves a hostname to a set of addresses, confirms all of them are public, and then makes a request by hostname. Between those two steps the name is resolved again by the HTTP client, and an authoritative nameserver under an attacker's control can answer differently the second time. That is DNS rebinding, and the source comment names it as a known limitation, accepted and documented.

Closing it properly means connecting to the vetted address directly and carrying the original hostname in the Host header and the TLS SNI, which the Workers runtime does not expose. What we have instead is a backstop rather than a fix: the worker runs on Cloudflare, whose egress cannot reach RFC 1918 space, and the guard re-runs on every redirect hop so a rebind buys one request rather than a session. That is a real mitigation and it is not the same thing as a solution, so the comment says so where an engineer will read it, and this paragraph says so where a customer will.

Writing the limitation down is the cheap part. The expensive part is the habit, and the two repairs quoted earlier are what the habit is for: an opt-out that covered two of five paths, and a size cap tested after the damage it was meant to prevent. Both were found by asking which paths a rule actually reaches rather than whether the rule exists. That is the same question behind treating a user agent as a claim rather than an identity and behind crawler detection being an ordering problem: a control that is present in the code and absent from most of the call sites reads exactly like a control that works.

For a reader deciding whether to point a scanner at their own property, the useful summary is short. A URL that resolves anywhere private is refused, and no partial result is emitted for it. A redirect into private space ends the fetch rather than downgrading the report. Nothing is stored for a domain on the opt-out list, and what we retain is written down separately. And the one weakness in the chain is named above rather than left for someone to find, which is the same commitment behind publishing that text inside shadow DOM never reaches our extractor even though it costs us a signal. Measurement tools should be legible about their own edges, and prose parity is worth nothing from a scanner that will not say what it cannot do.

  • Private IP literal in the pasted URL Closed Rejected statically before any network call, in both address families.
  • Hostname resolving to private space Closed Every A and AAAA answer is checked, and one offending address rejects the whole name.
  • Redirect into private space Closed Manual redirects, full guard re-run per hop, capped at 5 hops.
  • Unbounded response body Closed Streamed and cancelled at the cap rather than buffered then measured.
  • DNS rebinding between check and connect Open, mitigated Named in the source as accepted. Cloudflare egress cannot reach RFC 1918 space, and a rebind buys one request rather than a session.
  • A vetted address pinned into the connection Not available Would require setting the connection address while preserving Host and SNI, which the runtime does not expose.
What the guard closes and what it does not, from the comments and code in core/src/ssrf.ts and core/src/httpbody.ts, read 6 August 2026.

Related

Common questions

Why would an AI visibility scanner refuse to scan a URL?

Because accepting an arbitrary URL from the public internet is server-side request forgery unless something stops it. Lantad's guard names eight rejection reasons, read in core/src/ssrf.ts on 6 August 2026: invalid_url, scheme_not_http, credentials_in_url, port_not_allowed, hostname_forbidden, private_address, dns_failure and too_many_redirects. The most common cause of a refusal in practice is a hostname that resolves into private or reserved address space, which includes staging hosts behind split-horizon DNS.

Which IP address ranges does the scanner refuse?

Fourteen IPv4 blocks and eight IPv6 rules. Thirteen of the IPv4 blocks are registered in RFC 6890, the April 2013 Best Current Practice document that consolidated the IANA special-purpose address registries, covering the RFC 1918 private ranges, loopback, link local, carrier-grade NAT shared space, the three documentation ranges and the benchmarking range. The fourteenth, 224.0.0.0/4, is multicast space defined in RFC 1112 and does not appear in that registry. The IPv6 rules cover unique local, link local, multicast, documentation, loopback, the unspecified address, IPv4-mapped addresses and the NAT64 Well-Known Prefix.

Does the scanner check redirects, or only the URL that was submitted?

Every hop. Redirects are followed manually rather than by the fetch runtime, and the complete guard including DNS resolution runs again on each Location before the next request is made. The hop count is capped at 5 by FETCH_LIMITS.maxRedirects in core/src/config.ts. Without this, a public hostname could answer with a redirect to a private address and a scanner that trusted its first check would follow it.

What weakness does Lantad's URL guard still have?

DNS rebinding. The guard resolves a hostname, confirms every returned address is public, and then makes a request by hostname, so a hostile authoritative nameserver could answer differently on the second lookup. This is recorded in core/src/ssrf.ts as a known limitation, accepted and documented. The mitigations are that the worker runs on Cloudflare, whose egress cannot reach RFC 1918 space, and that the guard re-runs on every redirect hop. A full fix would require pinning the vetted address into the connection while preserving the Host header and TLS SNI, which the runtime does not expose.

See what AI can read on your site

Run a free scan and get a graded report of exactly what AI crawlers can and cannot read, with ranked fixes.