Skip to content

fix(scanoss): drop the API key on a cross-host redirect - #123

Merged
matiasdaloia merged 2 commits into
mainfrom
fix/redirect-key-leak
Oct 21, 2026
Merged

matiasdaloia merged 2 commits into
mainfrom
fix/redirect-key-leak

Conversation

@matiasdaloia

Copy link
Copy Markdown
Contributor

Why

net/http strips only its own auth and cookie headers (Authorization, Cookie, Proxy-Authorization, ...) when a redirect changes host. It copies every other header to the next hop. The SDK puts the API key in x-api-key and x-Session, and callers put identifying headers such as X-Scanoss-Client-Instance in Config.Headers. So a 3xx from the API, whether from a misconfigured upstream or from an http:// API URL under MITM, sent the customer's key and instance id to whatever host it named. This is finding M8 of the client-identification security audit.

Scope

  • New wraps the HTTP client in a shallow copy whose CheckRedirect deletes x-api-key, x-Session and every Config.Headers name once a redirect leaves the API's host:port.
  • It wraps the SDK's own client and a caller's Config.HTTPClient alike, so the fix sits in one place. The caller's client is not mutated, and its own CheckRedirect still runs after the strip. With none, the copy keeps net/http's 10-hop limit.
  • CHANGELOG entry under Unreleased / Security.

Tradeoffs

  • Strip rather than refuse. Refusing cross-host redirects would break any legitimate redirect, such as a CDN or a moved endpoint. Stripping matches what net/http already does for Authorization, and a hop that needs the key fails with a 401 instead of leaking it.
  • Host comparison includes the port and is exact, which is stricter than net/http's subdomain rule for Authorization. A different port can be a different service. api.example.com redirecting to eu.api.example.com loses the key, which is the safe default.
  • A caller's RoundTripper that adds its own headers on every hop is outside the SDK's reach. Earnie's identity transport handles that on its side (scanoss/earnie#1828).

Blast Radius

Only requests that get a redirect change behavior. Same-host redirects keep every header. No public API change.

Verification

  • TestCrossHostRedirectDropsIdentity (SDK client and caller's client), TestSameHostRedirectKeepsIdentity and TestRedirectHonorsCallersPolicy use two httptest servers and a 307.
  • With the fix reverted and the tests kept, the cross-host test fails for both clients: X-Api-Key = "secret" reached the other host, and the same for X-Session and X-Scanoss-Client-Instance.
  • go vet ./..., go test ./... and golangci-lint v2.14.0 (built with go1.27.3) pass with 0 issues.

Release

Needs a v0.14.1 release after merge. Earnie pins scanoss.go v0.14.0. Earnie's identity transport strips the same headers on its own, so the Earnie fix does not wait on this release, but Earnie should bump once v0.14.1 is out.

🤖 Generated with Claude Code

matiasdaloia and others added 2 commits October 21, 2026 12:02
net/http strips only its own auth and cookie headers when a redirect
changes host, so a 3xx from the API sent x-api-key, x-Session and every
Config.Headers entry to the host it named. New now wraps the HTTP client,
the SDK's or the caller's, in a copy whose CheckRedirect deletes those
headers once a redirect leaves the API's host:port. Same-host redirects
keep them, and a caller's own CheckRedirect still runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The redirect check compared only URL.Host against the first request. An
https to http redirect on the same host kept x-api-key and sent it in
clear, and a chain that left the API and came back got the key again,
because net/http re-copies the original headers on every hop.

Keep the identifying headers only while every hop shares the API's
hostname (case-insensitive) and effective port, allowing an http to
https upgrade on the default ports. An explicit :443 now counts as the
same origin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@matiasdaloia

Copy link
Copy Markdown
Contributor Author

Independent review: PASS+NOTES (head 287cfa5)

The PR handled the plain cross-host case, but two leaks remained. Both are fixed in 287cfa5.

  1. The check compared only URL.Host. So an https to http redirect on the same host kept x-api-key and sent it in clear.
  2. net/http re-copies the original request headers on every hop. A chain that went API, then attacker host, then back to the API restored the key on the last hop, on a path the attacker chose.

The headers are now kept only while every hop in the chain shares the API's hostname (case-insensitive) and effective port. https://h and https://h:443 count as the same origin. An http to https upgrade on the default ports keeps the key, so a caller configured with http:// does not start getting 401s, and the CHANGELOG says so.

redirect_test.go runs against real HTTP and TLS servers. It covers downgrade, upgrade, explicit default port, hostname case, port change, host change and the leave-and-return chain. Four of those cases failed on the original code. It also checks that New leaves the caller's http.Client unchanged.

Dismissed: transport re-injection. x-api-key and x-Session are set with Header.Set on the request before Client.Do, and caller headers are canonicalized through Header.Add. No RoundTripper adds them, so stripping in CheckRedirect works. Already correct in the PR: the caller's CheckRedirect still runs and ErrUseLastResponse is honoured.

Note: a caller-supplied RoundTripper that adds its own credentials is outside what the SDK can strip.

CI is all green and mergeStateStatus is CLEAN.

@matiasdaloia
matiasdaloia merged commit b8b52f3 into main Oct 21, 2026
9 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