fix(url-crawler): SSRF — the scanner fetched attacker-chosen addresses - #63
Merged
Merged
Conversation
The URL crawler follows links found in skill content, and skill content is
hostile input by definition: it is the thing under analysis. URLs come from
the body, from source_url and author_url, and from arbitrary frontmatter
keys, so the destination is entirely attacker-controlled. sdk.scan defaults
to use_urls=True, and url_crawler is in scan_default_layers, so this is the
ordinary path, not an opt-in one.
There was no address check. Scanning a skill containing
http://169.254.169.254/latest/meta-data/iam/security-credentials/
made the scanner fetch cloud instance credentials, from CI or from whatever
machine the user ran it on. Only the scheme was constrained, by the URL
extractor, to http/https.
The guard resolves the hostname before connecting and refuses any answer
that is private, loopback, link-local, multicast, reserved or unspecified,
unwrapping IPv4-mapped and 6to4 forms so a private v4 address cannot be
smuggled inside a v6 literal. Resolution matters: an attacker controls DNS
for their own domain, so a textual hostname check is no check at all.
httpx no longer follows redirects. Every hop is validated before it is
requested, because a URL that passes on the first request can redirect
inward on the second, and validating only the entry point catches nothing.
A refused fetch is returned as a FetchResult carrying the reason rather
than dropped, so it appears in the report instead of looking like a URL
with nothing behind it.
Residual risk, recorded rather than papered over: the name is resolved
here and again by httpx at connect time, so DNS rebinding between the two
defeats the check. Closing that needs a transport pinned to the validated
address.
The resolver is injectable. respx mocks at the transport layer, so mocked
hosts never resolve and the guard would reject every existing test; the
alternative -- a flag to skip checking -- would leave those tests covering
nothing, which is how a control ends up green while doing its job for
nobody.
Also wires crawler_max_urls, crawler_timeout, crawler_max_redirects,
crawler_max_response_bytes and crawler_concurrency, five documented
settings that nothing read. The values match SafeFetcher's constructor
defaults, so behaviour is unchanged; the knobs now do what they say.
Verified by removing only the guard call and re-running: both fetcher-level
tests fail, including the redirect bypass. Full suite 1,780 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DNoTXU8k3pfSBzR7aJubqL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The vulnerability
The URL crawler follows links found in skill content. Skill content is hostile input by definition — it is the thing being analysed. URLs are extracted from the body, from
source_urlandauthor_url, and from arbitrary frontmatter keys, so the destination is entirely attacker-controlled.This is the default path, not an opt-in one:
There was no address check anywhere in
fetcher.py,extractor.pyordetector.py. Scanning a skill containingmade the scanner fetch cloud instance credentials — from CI, or from whatever machine a user ran it on. The only constraint was the extractor limiting schemes to http/https.
The fix
safety.check_urlresolves the hostname before connecting and refuses any answer that is private, loopback, link-local, multicast, reserved or unspecified, unwrapping IPv4-mapped and 6to4 forms so a private v4 address cannot ride inside a v6 literal.Resolution is the point. An attacker controls DNS for their own domain, so
totally-normal.examplewith an A record of169.254.169.254walks past any textual hostname check.httpx no longer follows redirects. Hops are walked manually and each one is validated before it is requested:
A URL that passes on the first request can redirect inward on the second, so validating only the entry point catches nothing. A refused fetch comes back as a
FetchResultcarrying the reason rather than being dropped, so it shows in the report instead of looking like a URL with nothing behind it.Residual risk, stated
The name is resolved here and resolved again by httpx at connect time. A DNS record that changes between the two (rebinding) defeats the check. Closing it needs a transport pinned to the validated address. Recorded in the module docstring as a known limit rather than left implied.
Why the resolver is injectable
respx mocks at the transport layer, so mocked hosts never resolve and the guard would reject every existing fetcher test. The alternative — a flag to skip checking in tests — would leave those tests covering nothing. Injecting the DNS answer keeps scheme checks and address classification fully live.
Verification
Removed only the guard call, leaving the manual redirect walk in place, so the tests measure "no SSRF check" rather than a different design:
test_public_redirect_is_still_followedpasses both ways, confirming the guard doesn't break ordinary redirects.Full suite: 1,780 passed, 16 skipped. Ruff clean.
Also in here
Wires
crawler_max_urls,crawler_timeout,crawler_max_redirects,crawler_max_response_bytes,crawler_concurrency— five documented settings that nothing read, so tuning them silently did nothing. Values matchSafeFetcher's constructor defaults, so behaviour is unchanged; the knobs now do what they claim.How this was found
Auditing config for dead fields after the ambiguous-slug discovery. 14 of 56 settings are never referenced outside
config.py; five of them clustered in the crawler, which is what prompted reading it. Related and not fixed here:scan_max_file_size(512 KB) is also dead. That one needs thought rather than a quick wire-up — Unit 42 foundomnicoggevading scanners with 22 MB of junk padding specifically to exceed file-size limits, so adding a cap that silently skips oversized files would import the exact weakness it exploited. Oversized input should probably be scanned and flagged.🤖 Generated with Claude Code
https://claude.ai/code/session_01DNoTXU8k3pfSBzR7aJubqL
Generated by Claude Code