Skip to content

fix: URLAllowList inspects URLs nested inside containers - #30

Merged
higagan merged 3 commits into
mainfrom
fix/urlallowlist-nested
Jul 30, 2026
Merged

fix: URLAllowList inspects URLs nested inside containers#30
higagan merged 3 commits into
mainfrom
fix/urlallowlist-nested

Conversation

@higagan

@higagan higagan commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Version → 0.3.4.

The gap

0.3.2 taught URLAllowList to return None for non-string values, so it could coexist on a multi-argument tool like http_post(url, body) without flagging body. That fix was right, but incomplete: it taught the rule to skip non-strings without teaching it to look inside them.

http_post(url="https://api.internal.com/v1",
          payload={"redirect": "http://evil.com"})    # -> executed

A redirect or callback field is as much an exfiltration route as the url parameter. And SensitiveDataFilter already recursed into containers, so the two shipped rules disagreed about how deep to look.

This was a regression I introduced in 0.3.2. For the record, comparing versions on the same input:

Case 0.3.1 0.3.3 0.3.4
legit multi-arg call BLOCKED (wrongly) ALLOWED ALLOWED
evil URL in dict BLOCKED (by accident) ALLOWED BLOCKED
evil URL in list BLOCKED (by accident) ALLOWED BLOCKED
evil URL top-level BLOCKED BLOCKED BLOCKED

0.3.1 blocked the nested case only because urlparse() raised on a dict and its bare except Exception returned Invalid URL — it was blocking everything non-string, which is why it also blocked legitimate calls. It looked secure because it was uniformly broken.

The change

URLAllowList now walks dict, list, tuple, set and frozenset, applying the existing URL check to every string it reaches. The per-URL logic is unchanged — it moved into _check_url untouched.

Two decisions worth flagging, both slightly beyond "recurse into containers":

  • Dict keys are checked, not just values. An endpoint map can carry a URL as a key. This also means URLAllowList is not subject to the dict-key blind spot that SensitiveDataFilter still has (tracked in the local audit backlog).
  • Sets and frozensets are walked too, not just the three container types named in the request. payload={"http://evil.com"} is the same gap, and leaving it out would have meant filing a follow-up issue immediately.

A seen-set of container ids guards against self-referential arguments, so a cyclic payload can't hang the check.

Verification

=== 0.3.2 behaviour preserved ===
legit URL + prose body       -> ALLOWED
legit URL + int timeout      -> ALLOWED
legit URL + benign dict      -> ALLOWED
legit URL + allowed nested   -> ALLOWED
UPPERCASE host               -> ALLOWED

=== the gap is closed ===
evil host top-level          -> BLOCKED  (URL domain not in allowlist: evil.com)
evil host in dict VALUE      -> BLOCKED  (URL domain not in allowlist: evil.com)
evil host in dict KEY        -> BLOCKED  (URL domain not in allowlist: evil.com)
evil host in nested list     -> BLOCKED  (URL domain not in allowlist: evil.com)
file:// in dict              -> BLOCKED  (URL scheme not allowed: file)

71 tests pass (was 60), rules.py at 97%. New tests cover the dict value/key cases, arbitrary nesting depth, a parametrized sweep over all five container types, cyclic containers, and — importantly — that an allowlisted URL inside a container is not turned into a violation, plus that a benign structured payload still passes. The existing multi-argument integration test was extended rather than replaced.

Note

README.md's Limitations section says "only str, list, tuple, and dict values are inspected". That remains accurate for SensitiveDataFilter but now understates URLAllowList, which also reaches sets and dict keys. Understating protection is the safe direction so I've left it, but it's worth a wording pass next time the README is touched.

higagan added 3 commits July 30, 2026 18:28
0.3.2 taught URLAllowList to skip non-string values so it could coexist on a
multi-argument tool like http_post(url, body). It never taught the rule to look
*inside* those values, so a URL hidden one level down passed unchecked:

    http_post(url="https://api.internal.com/v1",
              payload={"redirect": "http://evil.com"})    -> executed

A redirect or callback field is as much an exfiltration route as the url
parameter itself, and SensitiveDataFilter already recursed into containers, so
the two shipped rules disagreed about how deep to look.

URLAllowList now walks dict, list, tuple, set and frozenset, checking every
string it reaches. Dict keys are checked as well as values, since an endpoint
map can carry a URL as a key. A seen-set guards against self-referential
containers, which a hand-built argument can contain even though a JSON-derived
one cannot.

Verified the 0.3.2 behaviour is preserved: a legitimate call with a prose body,
an int timeout, a benign dict, or an allowlisted nested URL all still pass;
evil.com blocks whether it appears at the top level, in a dict value, in a dict
key, or in a nested list; file:// still blocks inside a container.

Tests 60 -> 71.
@higagan

higagan commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

Added two .gitignore commits to this branch. They were originally pushed to the #29 branch but landed after that PR merged, so they never reached main — cherry-picked here rather than opening a third PR for three lines.

hn_proof.py
scratch_*.py
*.local.md

Covers local scratch scripts and notes so they can't be swept into a commit. The sdist was already protected by the explicit allowlist added in 0.3.2, so this is about accidental git add, not about the published artifact.

Still 71 tests passing.

@higagan
higagan merged commit 7734fce into main Jul 30, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant