Skip to content

feat!: generated clients keep credentials on the origin a request was sent to across redirects - #146

Merged
Michael McQuade (giraffesyo) merged 3 commits into
canaryfrom
feat/safe-redirects
Oct 25, 2026
Merged

Michael McQuade (giraffesyo) merged 3 commits into
canaryfrom
feat/safe-redirects

Conversation

@giraffesyo

@giraffesyo Michael McQuade (giraffesyo) commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

What

Generated clients now follow redirects through an exported policy, CheckRedirect. send applies it on each request, through SafeRedirects, to any http.Client that has no CheckRedirect of its own.

  • A redirect on the origin that was asked is followed as before. The origin is scheme, host and port, with default ports filled in; http on 80 up to https on 443 of the same host also counts as the same origin.
  • A redirect to another origin is followed only for a GET or HEAD without a body. A write (POST, PUT, PATCH, DELETE) follows to another origin only after a 303, which asks for a GET of another resource (RFC 9110 section 15.4.4), so it goes there as a bodiless GET. net/http also turns a write into a GET after a 301 or 302, so another origin's page (a proxy's login page, say) would become the write's answer; those, and a 307 or 308 of a write, are handed back.
  • A redirect to another origin carries only Accept, Accept-Encoding, Accept-Language, User-Agent and the conditional and range headers (Range, If-Range, If-Match, If-None-Match, If-Modified-Since, If-Unmodified-Since). The headers are rebuilt from that list, so a header set under a non-canonical key is dropped too.
  • Any other cross-origin redirect (a 301, 302, 307 or 308 of a write, or anything with a body) gets the 3xx back as an APIError, so no body is resent elsewhere and no other origin answers for the write.
  • A redirect from https to plain http is refused with ErrInsecureRedirect.
  • The 10th redirect fails with ErrTooManyRedirects.

The retry loop declines ErrInsecureRedirect and ErrTooManyRedirects by errors.Is. It still declines an error starting "stopped after", which a caller's own CheckRedirect may return, as net/http's does.

Why

http.DefaultClient's policy drops Authorization and Cookie only when the target is not the same host or a subdomain of it. It still sends Authorization to another port on the same host and to any subdomain, sends every other header (such as an APIKeyAuth header) anywhere, resends a body across a 307 or 308 to another origin, and follows https to http. A client generated from a spec carries a credential on nearly every request, so the default should not hand it to whatever a redirect names.

A GET to another origin still works, which a redirect to a CDN or an avatar host needs, and a ranged or conditional GET keeps its range and validators.

Breaking

The policy applies to any http.Client passed with WithHTTPClient that has no CheckRedirect of its own, not only to the default client.

  • A GET or HEAD redirected to another origin now carries only the headers listed above; every other header is dropped, not just Authorization and Cookie.
  • A request with a body, or a write redirected with a 301, 302, 307 or 308, to another origin now returns the 3xx instead of following it. Only a 303 turns a write into a GET to another origin.
  • An https to http redirect now fails instead of being followed.

A caller that wants the old behavior passes an http.Client with its own CheckRedirect, which is kept.

Testing

TestE2E_Redirects generates a client and runs it against httptest servers:

  • A GET followed to another origin without Authorization, an API key header or a non-canonically keyed header.
  • A POST answered with 303 followed to another origin as a GET without credentials, and a POST answered with 301 or 302 handed back without reaching it.
  • A ranged, conditional GET keeping Range, If-Range and If-None-Match on another origin.
  • A POST and a GET with a body handed back without reaching the other origin.
  • A POST followed on its own origin, with and without userinfo, keeping its credential.
  • http to https on another port treated as another origin.
  • An https to http redirect refused after one attempt with retries on.
  • The redirect limit not retried, and a caller's own "stopped after" refusal not retried.
  • A client's own policy kept, and a Transport set after NewClient used.

go vet ./..., go test ./... and golangci-lint run pass.

… sent to across redirects

NewClient now follows redirects through the exported CheckRedirect policy,
applied to any http.Client without a policy of its own:

- A redirect on the origin that was asked (or from http up to https on the
  same host) is followed as before.
- A redirect to another origin is followed only for GET and HEAD, carrying
  only Accept, Accept-Language and User-Agent. net/http alone would carry
  Authorization to another port or a subdomain of the same host, and every
  custom header (an API key header, for one) anywhere.
- Any other method gets the 3xx back instead of resending its body elsewhere.
- A redirect from https to plain http is refused with ErrInsecureRedirect.

The retry loop no longer matches the "stopped after" text of a redirect
error: it declines ErrInsecureRedirect and ErrTooManyRedirects by errors.Is.

BREAKING CHANGE: a non-GET request redirected to another origin returns the
3xx as an APIError instead of following it, and an https to http redirect
fails instead of being followed.
…eep more of a request on the same origin

- CheckRedirect decides on the redirected request (RFC 9110 section 15.4):
  a 301, 302 or 303 after a POST is a GET without a body and may follow to
  another origin; a 307 or 308 of any other method, or any request with a
  body, gets the 3xx back.
- A redirect to another origin rebuilds its headers from an allowlist, so a
  header set under a non-canonical key no longer survives. The allowlist
  keeps the conditional and range headers (Range, If-Range, If-Match,
  If-None-Match, If-Modified-Since, If-Unmodified-Since) and
  Accept-Encoding, which carry no credential.
- The origin compares scheme, host and port with default ports; http
  counts as upgrading to https only from 80 to 443. Userinfo makes a
  redirect another origin only when it differs from the request's.
- send applies SafeRedirects on each request, so a change to the caller's
  http.Client after NewClient takes effect.
- The retry loop declines ErrInsecureRedirect and ErrTooManyRedirects by
  errors.Is and still declines a "stopped after" error, which a caller's
  own CheckRedirect may return.
…2 no longer takes that origin's page for its answer

net/http turns a POST, PUT, PATCH or DELETE into a GET after a 301 or 302 as
well as a 303. Followed to another origin, that GET's 200 (a proxy's login
page, say) became the write's answer. CheckRedirect now lets a write follow
to another origin only after a 303, which asks for a GET of another
resource (RFC 9110 section 15.4.4); a 301 or 302 is handed back.
@giraffesyo
Michael McQuade (giraffesyo) marked this pull request as ready for review October 25, 2026 22:47
@giraffesyo
Michael McQuade (giraffesyo) merged commit 3824f78 into canary Oct 25, 2026
8 checks passed
@giraffesyo
Michael McQuade (giraffesyo) deleted the feat/safe-redirects branch October 25, 2026 22:49
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