Repository navigation
security: sweep the 56 call sites that interpolate a credential into an Authorization header (#15204 follow-up) #16809
Description
Activity
Classification complete — 4 of 5 HTTP libraries echo, and the remediation set is 2 files, not 56 sites
Answered empirically rather than by reading error paths: each library was driven with an
Authorizationheader carrying a distinctive canary and an illegal value, and the exception was searched for the canary in its message, its__cause__/__context__chain, and the formatted traceback.Negative control first, per this issue's own instruction.
http.client— the confirmed #15204 leak — was probed by the same harness before anything else, and it lit up. A run where it had come back clean would have meant the harness was broken and every other answer worthless.Verdicts
Library Verdict The exception it actually produces http.client(stdlib) [control]echoes ValueError: Invalid header value b'Bearer <canary>\nX-Injected: 1'urllib32.7.0echoes same ValueError— it hands the header tohttp.clientrequests2.34.2echoes InvalidHeader: Invalid leading whitespace, reserved character(s), or return character(s) in header value: 'Bearer <canary>…'httpx0.28.1 (viah110.16.0)echoes LocalProtocolError: Illegal header value b'Bearer <canary>…'aiohttp3.14.3does not echo ValueError: Forbidden control character detected in headers. Potential header injection attack.PyJWT2.13.0decode(token)does not echo DecodeError: Not enough segmentsPyJWT2.13.0encode(key)does not echo InvalidKeyError: Could not parse the provided public key.Nothing landed in could not determine — but two nearly did, and both were false cleans caught before they were written down:
- aiohttp first came back "does not echo" because the probe died on connection-refused before header validation ran. That is did not look, not nothing found. Re-probed at the writer, which is where it would actually be sent.
- httpx first came back "no exception at all". It accepts the illegal value at
Requestconstruction and stores the raw CRLF inheaders.raw; the rejection happens later, at send, insideh11. Probing only construction would have scored it safe.
The httpx detail is worth carrying forward: because validation is deferred to send, a call site that wraps only request construction in a try block will not see this error at all, and the leak surfaces from a different frame than the one a reader would suspect.
aiohttp's message is the model for what the others should do — it names the fault class and refuses to quote the value.What this means for the count
The original "56" counted line hits. Those live in 33 non-test files, and the library is what determines exposure:
Client Files Exposure aiohttp23 none — does not echo httpx2 echoes — the remediation set urllib/http.client1 already fixed by #15204 no client imported 7 undetermined So the real remediation set is two files:
autobot-slm-backend/api/llm_config.pyautobot-slm-backend/services/hf_token_validator.py
Undetermined, recorded rather than rounded down
Seven files build an
Authorizationheader and import no HTTP client themselves, so the library is whichever the eventual caller uses, and settling each one means tracing where the dict goes:autobot-backend/integrations/whatsapp_integration.pyautobot-backend/integrations/slack_integration.pyautobot-backend/voice_processing/providers/cloud/deepgram_provider.pyautobot-backend/services/mcp_server_credentials.pyautobot-backend/services/command_extraction_service.pyautobot-backend/services/execution/claude_code_backend.pyautobot-backend/skills/builtin/community_growth.py
A file in this list is not a file that is safe. It is a file nobody has checked. Given 23 of the 25 determined files use aiohttp, the base rate suggests most of these are aiohttp too — but that is an expectation, not a measurement, and it should not be recorded as one.
Caveats
- Verdicts are for the installed versions named above. A library that does not echo today can start;
aiohttp's andPyJWT's good behaviour is not a contract, and the only durable form of this answer is a test per library that fails if the message starts carrying the value. - The probe tests one fault class: a control character in the header value. A library could be quiet about that and loud about something else.
Revised acceptance criteria
- Determine, per library, whether an invalid credential value reaches the exception message — done, table above
- Route the two httpx call sites through a check equivalent to
pipeline-scripts/header_safe_secret.py'srequire_header_safe. That module currently sits inpipeline-scripts/, whichautobot-slm-backend/cannot import — it needs to move somewhere shared first, and that move is the larger half of this work - Pin each verdict in a test, including the negative ones, so a version bump that changes the behaviour is caught rather than assumed
- Resolve the seven undetermined files by tracing where their header dict is sent
The per-library test is the deliverable, not a caveat — revising the order
Reordering the acceptance criteria above, because I filed the version-pinning point as a caveat and it belongs first.
aiohttpandPyJWTare safe in the versions installed today. Nobody upstream owes us that. Without a test, a routine dependency bump silently converts two non-echoing libraries into two echoing ones, with no signal at all — and the classification above becomes a document asserting something that stopped being true, which is worse than never having measured it, because it will be believed.So the ordering is:
- A test per library that fails the day its message starts carrying the value — including the ones that currently pass. A negative verdict with no test is an expectation with a date on it.
- Route the two
httpxcall sites (autobot-slm-backend/api/llm_config.py,autobot-slm-backend/services/hf_token_validator.py) through a header-safety check. - Resolve the seven undetermined files by tracing where their header dict is sent.
That test also costs less than the remediation and covers more: seven libraries, one file, no call-site surgery.
Two scope notes for whoever picks this up
Where the
tryhas to go.httpxaccepts an illegal header value atRequestconstruction and defers rejection toh11at send. A call site that wraps only construction will never see the error, and the leak surfaces from a frame the reader is not looking at. Guarding construction alone would look like a fix and change nothing.header_safe_secret.pycannot be imported by the code that needs it. It sits inpipeline-scripts/, and both remediation targets are inautobot-slm-backend/. Moving it somewhere both trees can import is the larger half of item 2 — the call-site edit is two lines, the move is the actual work. Worth knowing before anyone estimates this as small.
What this is
#15204 fixed one call site where a malformed credential could reach a CI log:
ci_dispatch_watchdoghandedGITHUB_TOKENto an HTTP header, andhttp.clientrefuses an illegal header value withValueError("Invalid header value %r" % value)— the%rbeing the credential.Its acceptance criteria asked for a sweep of the same shape elsewhere, with a reported count. This is that count, plus the part of it I could not settle.
The count
56 non-test call sites, across the backend and shared packages, build an
Authorizationheader by interpolating a credential-named expression — thef"Bearer {self.token}"/"Bearer %s" % self.config.tokenshape. Measured overautobot-backend/,autobot_shared/,autobot-slm-backend/,pipeline-scripts/,scripts/, excluding test files.A separate mechanical pass over other value-echoing callees found 7 sites passing a credential directly to a library call: 5
jwt.encode/jwt.decodeinautobot_shared/auth/jwt_core.py, and 1base64.urlsafe_b64decodeinpipeline-scripts/tracked_key_material.py.What is NOT established
Whether any of those libraries actually echo the value into its exception.
http.clientprovably does — #15204 pins that in a test.aiohttp,httpx,requests/urllib3andPyJWTeach validate differently and I have not checked any of them. So this issue reports a shape, not 56 defects, and the next step is to determine per library whether the value survives into the message. Until someone does that, the honest reading is "56 sites worth checking", not "56 leaks".I would start with
urllib3, since it backsrequestsand is the most used of the four here.A note on how the count was nearly missed
My first sweep specified "a credential-named expression passed as an argument" and reported zero matches for
add_headerand header-dict assignment — while the defect #15204 exists for was arequest.add_header("Authorization", f"Bearer {self.token}")two lines from where I was working.The spec was the defect: the credential is inside an f-string, so it is not the argument, and the pattern could not see it. A sweep that cannot find its own known-positive reports a clean result indistinguishable from a real one. Worth repeating for anyone writing the follow-up: run the sweep against the known instance first, and if it does not light up, fix the sweep before trusting any zero.
Acceptance criteria
aiohttp,httpx,urllib3/requestsandPyJWT, determine whether an invalid credential value reaches the exception message, with a test pinning the answer either waypipeline-scripts/header_safe_secret.py'srequire_header_safe(or an equivalent that lives where the backend can import it — the current module sits inpipeline-scripts/, which is not importable fromautobot-backend/)Refs
Refs #15204, which fixed the one confirmed instance and added the reusable check.