security: fix four allowlist bypasses and a ReDoS found by a full-library audit - #75
Open
higagan wants to merge 2 commits into
Open
security: fix four allowlist bypasses and a ReDoS found by a full-library audit#75higagan wants to merge 2 commits into
higagan wants to merge 2 commits into
Conversation
An adversarial sweep of the whole library (78 candidates, each survivor
independently re-executed by a refuter) found that the flagship rule
decides "is this a URL?" by substring, and fails OPEN when it guesses
wrong.
URLAllowList tested `"://" in url`, true only of the authority-bearing
spelling. http:/evil.com, file:/etc/passwd, data:, javascript: and
//evil.com were therefore "not URLs" and passed untouched, defeating the
host allowlist and the scheme allowlist together. Detection now keys on
the parsed scheme, and the scheme is checked before an authority is
required -- an empty netloc used to return early, so the scheme check
never ran for exactly these forms.
Three more, same rule and its caller:
- a hostname was never validated, so evil.com\0.api.internal.com
satisfied the endswith() subdomain match while the resolver
truncates at the NUL and reaches evil.com;
- bytes were not walked at all, the only walker in the library
missing that branch;
- reasons quoted the raw URL into WARNING logs, leaking basic-auth
passwords and allowing forged log lines.
_enforce iterated kwargs.values() only, so for any tool declaring
**params the payload could travel in the key. Declared parameter names
stay unchecked on purpose: they are the author's, not the model's.
Finally, the JWT secret pattern backtracked quadratically -- 1 MB of
"eyJ-" burned ~140 s of CPU and was then allowed, so nothing blocked and
nothing logged. A lookbehind replaces the leading \b.
higagan
force-pushed
the
fix/url-allowlist-and-enforce-bypasses
branch
from
August 13, 2026 13:24
758332f to
f45d353
Compare
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.
Summary
A whole-library adversarial audit (14 lenses, 78 candidates, every survivor independently re-executed by a refuter whose job was to refute it) confirmed 18 findings. This PR fixes the five that were release-blocking. All are in pre-existing code on
mainand in the published PyPI package β none were introduced by #74.The headline:
URLAllowList, the flagship rule the README leads with, decides "is this a URL?" by substring β and fails open when it guesses wrong.1. The
"://"gate (high) β one root cause, three bypasseslooks_like_url = "://" in urlis true only of the authority-bearing spelling. Everything else was classified "not a URL" and allowed straight through:file://was blocked, which is what made this invisible: the existing test used the authority-bearing spelling. The asymmetry proves it was a gap, not a stance.Detection now keys on the parsed scheme, and the scheme allowlist is evaluated before an authority is required β an empty netloc used to return early, so the scheme check never ran for exactly these forms.
Getting the permissive direction right mattered as much as the strict one. The rule must still pass non-URLs, so
_looks_like_urlkeeps"://"as a sufficient signal and only applies the stricter heuristic below it. Pinned by tests:hello world,a/b/c,note: the deploy failed,TODO:fixthis,key:value,ns:tag, andC:\Users\bob(a drive letter is one character β the scheme regex requires two) all still pass.I also caught a regression in my own first attempt: disqualifying on whitespace outright turned
http://evil.com/a bβ blocked before this PR β into an allowed string. There's a test for that now.2. Hostname was never validated (medium)
http://evil.com\x00.api.internal.comsatisfies.endswith(".api.internal.com"), so it read as an allowlisted subdomain β whilegetaddrinfo,socket.create_connectionand curl all truncate at the NUL and reachevil.com(verified). Now rejected, along with spaces and other illegal host characters.Worth noting honestly:
urllib,httpxandsubprocessall reject NUL themselves, so end-to-end exfiltration needs a tool body built on raw sockets or curl. That's why the refuter downgraded it from critical. But depending on each client's incidental rejection contradicts the premise that this is the boundary.3.
byteswere never walked (high)URLAllowList._check_recursivewas the only walker in the library without a bytes branch β_iter_stringsand_iter_commandsboth decode. Every check in the rule was off for that carrier.README.mdandAGENTS.mdboth listbytesas walked.4.
**kwargskeys bypassed every policy (high)_enforceiteratedlist(args) + list(kwargs.values()). For any tool declaring**params, the entire payload could travel in the key β and JSON permits arbitrary keys, so a model can emit one. The same dict passed as a value was blocked.Declared parameter names are deliberately not checked. They're chosen by the tool author, not the model, and checking them would make the bundled
SensitiveDataFilterblock any tool that simply has apasswordparameter. Only names absorbed by**kwargsare inspected.5. Quadratic ReDoS in the JWT pattern (high)
Nothing blocked, nothing logged β a silent, stateless, repeatable CPU sink with no audit trail.
-is both a non-word character (so every-eyJis a fresh\banchor) and a member of the body class (so each anchor eats the tail). A lookbehind replaces the leading\b:Real JWTs still match. Two smaller pattern fixes ride along: Google keys ending in
-were undetectable in every context (a trailing\bcannot fire after a non-word char), and Slack covered onlyxox[abprs]-, missingxapp-andxoxe-(which mints fresh bot tokens).Also: block reasons no longer quote the URL
f"URL contains userinfo trick: {url}"wrote live basic-auth passwords into the WARNING record and the exception text β and this fires on ordinary basic auth against the allowlisted host, no attacker needed. Control characters in a rejected URL could also forge log lines. Reasons now name the host or the scheme only, which is the invariantSecretPatternFilterandShellCommandAllowListalready held. The hostname is still named where it's safe, so the audit trail stays useful.Test plan
ruff check/ruff format --check/mypy --strictcleanuv run pytest -qβ 194 passed (39 new), no pre-existing test modifiedBearer, quoted, and in--token=<jwt>Not in this PR
The audit's non-blocking findings β rule-ordering affecting
category, the CLI's raw echo of endpoint-controlled text,INCONCLUSIVEwhen only the attacker endpoint failed, the smoke-test workflow's shell interpolation and it testing the previous release,typeras an unconditional runtime dep, and several doc corrections β are separate and I'll open issues rather than widen this one.What held up under attack (worth stating, since it's where confidence is earned): no fail-open anywhere in the engine or walkers across 5,000 depth-sweep trials; the stdout/MCP guarantee (0 bytes on fd 1 in every configuration);
SecretPatternFilternever quoting matched text; andShellCommandAllowListdefault-deny.