fix: stop sending the API key across redirects (sable-2s6p) - #223
Conversation
requests' SessionRedirectMixin.rebuild_auth strips Authorization on a host change and leaves arbitrary custom headers intact; axios/follow-redirects does the same. So `x-api-key` rides a 302 to whatever host it points at. Nothing in this CLI needs to follow a redirect, so nothing does any more. PRE-EXISTING, not introduced by #220. The retry loop added there raises exposure from one transmission to as many as five, which is why it matters more today than it did last week, but it is not the cause. The action's curl paths were never affected — verified, no -L or --location on any of the eight curl invocations in github-action/action.yml. Every authenticated call site, not just the poll path: 14 in Node behind a shared `apiClient` (axios instance, maxRedirects: 0), 13 in Python behind api_get/api_post (allow_redirects forced False, so no call site can opt back in). Deliberately left alone: update-checker's npm registry call and the Slack/Discord webhook posts, none of which carry the key. WHAT THE SECURITY REVIEW CAUGHT, and it would have shipped a broken CLI: API/API_BASE end in "/" and 11 call sites concatenated "/static/...", building https://rafter.so/api//static/scan. Production answers that with a 308 to the single-slash form. It worked only because the client followed the redirect — so refusing redirects turned every core command into a hard failure. Verified against the live API: the double-slash URL 308s, the single-slash one reaches the endpoint. Both runtimes now build URLs through apiUrl()/api_url(), and a test in each fails on any `${API}/` or `{API_BASE}/` construction. Every other test mocks the transport, which is why nothing caught this. Also from that review: - The message told users to point --rafter-url at the final URL. That flag does not exist in the CLI — it is a GitHub Action input. Removed the instruction rather than shipping advice nobody can follow. - A redirect Location is attacker-controlled if the endpoint is. Header values cannot carry CR/LF but ESC is legal, so the raw value could rewrite the user's terminal. Both runtimes strip non-printables and cap at 200 chars, asserted with an ANSI sequence in the fixture. - The source-scanning guards only caught the most literal bypass. They now also match the .request() form and flag any second axios.create() / requests.Session() built outside the api utils. - The Node test shim made axios and apiClient the same mock, so a regression to bare axios would still have passed. create() now returns a distinct object and the tests watch that instance; mutation-tested by reverting one call site to bare axios, which the guard catches. A refused redirect now explains itself instead of surfacing a bare 302.
|
Worth recording what just happened on this PR, because it is #221's argument making itself. This change rewrites the HTTP layer of both clients — 27 call sites across Nothing substantive ran. The only green was the job that decides whether to run the others. I dispatched With #221 merged, Not arguing for reverting the gate wholesale — #219's cost case is still sound and the expensive |
Closes sable-2s6p.
The bug
requests'SessionRedirectMixin.rebuild_authstripsAuthorizationon a host change and leaves arbitrary custom headers intact.axios/follow-redirectsdoes the same. Sox-api-keyrides a 302 to whatever host it points at.This is pre-existing and was not introduced by #220. The retry loop added there raises exposure from one transmission to as many as five, which is why it matters more today than it did last week — but it is not the cause. Worth being explicit about, so nobody reading the diff concludes the retry fix created a security bug.
The composite action was never affected. Verified rather than assumed: none of the eight
curlinvocations ingithub-action/action.ymlcarries-Lor--location, so curl never followed a redirect there. That bounds the blast radius to the two CLI runtimes.The fix
Every authenticated call site, not just the poll path I was looking at:
apiClient(axios.create({ maxRedirects: 0 })).api_get/api_post, which forceallow_redirects=False. Forced, not defaulted, so no call site can opt back in; there's a test for exactly that.Deliberately left on plain
axios/urllib:update-checker.ts's npm registry call, and the Slack/Discord webhook posts innotify.ts/notify.py. None carries the Rafter key. Each was checked individually rather than by filename.What the security review caught — this would have shipped a broken CLI
API/API_BASEend in/, and 11 call sites concatenated/static/..., buildinghttps://rafter.so/api//static/scan. Production answers that with a 308 to the single-slash form:It worked only because the client followed the redirect. Refusing redirects turned
rafter run,get,usage,notify --scan-idandissues from-scaninto hard failures in both runtimes. Every existing test mocks the transport, so nothing caught it.Both runtimes now build URLs through
apiUrl()/api_url(), and a test in each fails on any${API}/or{API_BASE}/construction. The latent bug here is worth naming: the CLI has been depending on a redirect to reach its own API.Also from that review:
--rafter-urlat the final URL; that's a GitHub Action input, not a CLI flag. Removed rather than shipping advice nobody can follow.Locationis attacker-controlled if the endpoint is. Header values can't carry CR/LF butESCis legal, so the raw value could emit ANSI sequences that rewrite the user's terminal. Both runtimes strip non-printables and cap at 200 chars; the fixtures assert it with a real escape sequence..request()form and flag any secondaxios.create()/requests.Session()built outside the api utils.axiosandapiClientthe same mock, so a regression to bareaxioswould still have passed.create()now returns a distinct object and the tests watch that instance.Verification
maxRedirectsto 5, defaultingallow_redirectsback to True, and reverting one call site to bareaxioseach produce failures.pnpm run buildclean.Note on test churn
Five Node test files and five Python test files change their mocking seam, because the code now calls
apiClient/api_getrather thanaxios.get/requests.get. That is the point — the tests now mock what the code actually calls, so bypassing the safe client shows up as a failure.