fix(skills): block private SSRF targets and revalidate redirects in importer#5261
Conversation
…e redirects per hop The skill importer validated only the initial URL with the lenient SSRF guard (block_private=False) and then fetched with follow_redirects=True, so a 3xx to an internal/metadata address (169.254.169.254, 127.0.0.1, RFC-1918) was still connected to — inconsistent with the hardened services/search/content.py :_get_public_url path. Add a _get_checked() helper that follows redirects manually and re-runs the SSRF guard with block_private=True on every hop, and route all three fetch sites (skills.sh unwrap, _fetch_bytes, _list_github_dir) through it. GitHub's own redirects and the final-host _assert_github_url checks are preserved. Adds hermetic regression tests (IP-literal hosts, faked HTTP layer) and updates the existing mock signature for the new block_private kwarg. Defense-in-depth: the endpoint is admin-gated (require_admin) and admins are trusted per THREAT_MODEL.md, so this is not a cross-boundary vulnerability. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
f274686 to
c0f352b
Compare
…afts Per the search-before-staging rule, each affected draft now states complements/conflicts vs merged and in-flight upstream work (see upstream-recon-2026-07-07.md): tool-parsing (odysseus-dev#5033/odysseus-dev#5275/odysseus-dev#5199), provider hosts + native-tool toggle (odysseus-dev#4729/odysseus-dev#5206), injection header + guard benchmark (odysseus-dev#4991), GGUF include filter (odysseus-dev#5136/odysseus-dev#5137), ntfy/note refactor (odysseus-dev#5208/odysseus-dev#5236), skills + teacher volatility (odysseus-dev#5261/odysseus-dev#5215/odysseus-dev#4962). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
StressTestor
left a comment
There was a problem hiding this comment.
nice first PR, and a real catch. follow_redirects=True with the SSRF check on the initial URL only was a genuine blind-SSRF gap, and _get_checked closes the redirect vector cleanly. hermetic tests too, good to see.
one note, not a blocker: the summary says this matches services/search/content.py:_get_public_url, and it's very close but not quite the same guarantee. that path doesn't just re-validate each hop. it resolves the hop, checks the IP, then pins the actual TCP connect to that resolved IP via _PinnedTransport, which closes the DNS-rebinding TOCTOU between the check and the connect. _get_checked runs check_outbound_url(...) and then hands the hostname back to a plain httpx.Client that re-resolves at connect time, so a hostname whose DNS flips between check and fetch could still land on an internal IP. same admin-only, response-discarded envelope you already scoped, so practical risk is low. just flagging so the parity note isn't taken at face value later.
if you're up for it, one option is to route these fetches through the existing _resolve_public_ips + _PinnedTransport helpers (or call _get_public_url directly) so there's a single hardened fetch path rather than two similar ones. happy to help wire that up if useful. either way, solid contribution.
|
Hi @StressTestor , thank you for the detailed review and the explanation about the DNS-rebinding/TOCTOU gap! I completely understand your point about _PinnedTransport. Since I used AI assistance to prepare this PR and closed my session, I would love to route these fetches through _get_public_url or _PinnedTransport as you suggested. Could you give me a quick pointer on how best to wire that up here? I'd appreciate the help to make it a single hardened fetch path |
|
thanks for digging back in on this. honestly i'd land your PR as-is: the per-hop revalidation is the right fix for the redirect vector and it's self-contained. the remaining bit (the DNS-rebinding window i mentioned) can only really be closed by pinning the TCP connect to the IP you validated, and the only pinning helper in the repo today is private inside so nothing needed from you here. nice work chasing the redirect gap down, this is a real hardening. |
|
Sounds great! Thank you for the detailed explanation and for taking care of the follow up refactoring. I'm glad this PR helps harden the system. Looking forward to contributing again in the future |
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Verified: flipping the production helper back to |
|
good catch @RaresKeY, this one's real. confirmed the classification locally: 100.64.0.1 comes back is_private=False, is_global=False, is_loopback=False. the strict-mode branch keys on ip.is_private or ip.is_loopback, so CGNAT space falls straight through, and since _get_checked re-runs the same guard per hop, a public-to-100.64.0.1 redirect would pass the check and still issue the GET. link-local metadata (169.254.x) is already covered, so this is the residual. P2 is right. one note on the fix so it doesn't bite later: not ip.is_global is the clean version, but it's python-version-dependent for this range. on 3.13+, CGNAT reads is_global=False and gets blocked; on older pythons is_global was just not is_private, so 100.64/10 read as global and would slip through. so either pin a 3.13+ floor or add an explicit 100.64.0.0/10 reject alongside it. this lives in url_safety.py (the shared validator), not the importer, so i'll fold it into the shared-fetch-path follow-up i already picked up for the DNS-pinning item. that way the web-fetch path, the importer, and the admin embedding endpoint all get it from one place. @EmirCobanOfficial nothing needed from you, your redirect fix and the test tightening are solid, this is validator-level and i'll carry it. |
|
Thanks @RaresKeY for catching that CGNAT edge case, and thank you @StressTestor for stepping in to handle it at the validator level I learned a lot from this PR process. |
|
@RaresKeY before i merge this, mind a final pass? i ran a deeper audit on the current head and it's clean from my side. what i checked:
residuals, none blocking:
one thing the audit turned up: the same CGNAT class hits |
StressTestor
left a comment
There was a problem hiding this comment.
approving. audit came back clean on my side, and rareskey's out for a few days so no reason to keep this open longer. nice work @EmirCobanOfficial, solid hardening.
Upstream odysseus-dev#5261: harden the skill importer against SSRF — block private targets + revalidate redirects per hop (_get_checked helper). Conflict was imports only: the fork's earlier ruff cleanup had dropped List/Optional/urljoin as unused; upstream's new redirect-following code needs Optional + urljoin, so took upstream's imports minus the still-unused List. Verified: 24 skills-importer tests pass (incl. the new SSRF-redirect regressions), ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
The skill importer (
services/memory/skill_importer.py) makes outbound fetches for the admin-onlyPOST /api/skills/import-from-url. It validated only the initial URL with the lenient SSRF guard (check_outbound_url→block_private=False) and then fetched withhttpx.Client(follow_redirects=True), so a3xxto an internal/metadata address (169.254.169.254,127.0.0.1, RFC-1918) was still connected to — inconsistent with the hardenedservices/search/content.py:_get_public_urlpath, which pins the resolved IP and re-validates every redirect hop. This PR adds a_get_checked()helper that follows redirects manually and re-runs the SSRF guard withblock_private=Trueon every hop, then routes all three fetch sites (skills.shunwrap,_fetch_bytes,_list_github_dir) through it. GitHub's own redirects and the final-host_assert_github_urlchecks are preserved.Scope / severity: defense-in-depth, not a cross-boundary vulnerability — the endpoint is
require_admin-gated and admins are trusted perTHREAT_MODEL.md; the response is discarded by the final GitHub-host check, so the effect today is a blind request. Prepared with AI assistance (Claude Code): a single, human-reviewed change with regression tests, not a bulk auto-generated PR.Target branch
dev, notmain.Linked Issue
Part of #5262
Type of Change
Checklist
devdocker compose uporuvicorn app:app) and verified the change works end-to-end. — Verified at the unit level (see How to Test); I did not run the full app end-to-end, so this box is intentionally left unchecked. A maintainer / CI run is recommended.How to Test
Hermetic unit tests (IP-literal hosts, faked HTTP layer — no network):
test_get_checked_blocks_redirect_to_internal[127.0.0.1]/[169.254.169.254]— a redirect to an internal/metadata target now raisesSkillImportError(before this PR the request was still sent).test_fetch_bytes_blocks_redirect_to_internal[...]— same via the public fetch helper.test_skills_sh_entry_blocks_redirect_to_metadata— theskills.shunwrap path revalidates hops.test_get_checked_follows_public_redirect— a legitimate public→public redirect is still followed (no over-blocking).What changed
services/memory/skill_importer.py— add_check_fetch_url()(block_private=True) +_get_checked()(manual, per-hop-revalidated redirects); route the three fetch sites through it.tests/test_skill_importer_ssrf_redirect.py— new hermetic regression tests.tests/test_skill_importer.py— update the existingcheck_outbound_urlmock signature for the newblock_privatekwarg.🤖 Prepared with assistance from Claude Code.