Police the endpoints a discovered issuer names, not only the issuer
Closes #806.
The exposure
#804 judges the advertised issuer's address before the metadata GET. That closes the direct route and leaves the indirect one open: an attacker-controlled issuer on public space passes the policy, is selected, and returns correctly issuer-bound metadata whose token_endpoint and device_authorization_endpoint point wherever it likes. parseAndBindASMetadata checks only that token_endpoint is non-empty and copies it verbatim; RequireSecureEndpoint admits any https host, private space included. The credential POSTs then went out on http.DefaultClient — client_id and device_code from the device flow, the authorization code, client secret, and refresh token from the Exchanger. One public host buys private space, and what arrives is credentials rather than a blind GET.
The decision
Dial-time address enforcement on every device and token endpoint request, on the same shared DefaultIssuerPolicy() client as the issuer hop. Not same-origin, and the reasons are written into SPEC §16 so they are not re-litigated: RFC 8414 §2 does not require endpoints on the issuer origin, RFC 8705 §5 publishes off-origin endpoints by design, and an origin comparison judges a hostname string that is resolved twice — publicly at discovery, privately at the POST, under a rebind. Scheme alone is satisfied by any private host.
The device and exchange functions cannot tell a discovered endpoint from a hand-configured one (PerformDeviceLogin takes a *Config; Exchange/Refresh take a string), so the policy applies to every request on the default client. That is the same uniformity call as #804 made for WithExpectedIssuer, for the same reason; a provenance flag on Config is a marker that a consumer round-tripping the config through storage silently drops.
The change
func WithDevicePolicy(p surfguard.Policy) DeviceOption // new
func NewExchanger(httpClient *http.Client, opts ...ExchangerOption) *Exchanger // variadic added
func WithExchangerPolicy(p surfguard.Policy) ExchangerOption // new
Precedence is one rule across all three surfaces of the package: a client you hand in is yours, enforcement included; otherwise your policy; otherwise the shared default. http.DefaultClient is the spelling of "no policy", so no Without* options were added for these two surfaces — there is nothing they would express that passing a plain client does not.
A refusal is a non-retryable *basecamp.Error (api_error) that also matches errors.Is(err, surfguard.ErrBlocked). In the poll loop it is classified ahead of the timeout and transport cases, so the loop neither backs off and re-dials a target it must stop talking to nor reports it retryable.
The shared-client invariant from #804 extends to the new surfaces: WithDevicePolicy builds its transport once at option construction (PerformDeviceLogin hands the same opts to the auth request and the poll, so a transport per newDeviceConfig would be two pools per login), WithExchangerPolicy builds one per Exchanger, and suppressRedirects keeps copying so the shared default is never mutated.
Demonstrated, not asserted
Seven mutations, each run against the new test file, each caught:
| Mutation | Tests that fail |
|---|---|
device default back to http.DefaultClient | PerformDeviceLogin_BlockedEndpointIsNeverContacted, PollDeviceToken_BlockedTokenEndpointTerminates, RefusesLegacyNumericSpelling, DefaultPolicyClientIsSharedAcrossSurfaces, EndpointPolicyIsolation |
exchanger default back to http.DefaultClient | Exchanger_BlockedTokenEndpointIsNeverContacted, RefusesLegacyNumericSpelling, DefaultPolicyClientIsSharedAcrossSurfaces |
poll loop stops classifying ErrBlocked (falls to timeout/transport) | PollDeviceToken_BlockedTokenEndpointTerminates |
device-auth stops classifying ErrBlocked (falls to transport) | PerformDeviceLogin_Blocked…, RefusesLegacyNumericSpelling, EndpointPolicyIsolation |
exchange ErrBlocked branch made dead | Exchanger_Blocked…, RefusesLegacyNumericSpelling |
WithDevicePolicy builds a transport per call | DefaultPolicyClientIsSharedAcrossSurfaces |
refusal marked Retryable: true | all five blocked-endpoint tests |
Every blocked case asserts the endpoint's handler ran zero times, and every mock serves a usable response, so a policy that failed to block yields a successful login or exchange rather than a differently-worded failure. The poll test's injected clock advances by each sleep, so a loop that wrongly kept polling runs out to expiry with a recognizably wrong reason instead of hanging.
go test ./..., go vet, gofmt -s, golangci-lint clean; the conformance runner still builds; make doc-constants-check and make go-check-drift pass. The 74 existing device tests and 10 exchange tests pass unedited — every one that reaches the wire already injected srv.Client(), and the five that do not all fail before the request.
What this does and does not close
basecamp-cli is not protected by this change. It passes its general-purpose m.httpClient to NewExchanger, WithDeviceHTTPClient, and NewDiscoverer alike, and a caller-supplied client is the caller's — the enforcement seam is the transport's dialer, so there is nothing to layer on top of an arbitrary RoundTripper. That is the #804 WithIssuerHTTPClient contract applied consistently; the alternative (clone an *http.Transport and overwrite its DialContext/Proxy) is "the policy applies sometimes" in another form. The consumer-side fix is one line, &http.Client{Transport: oauth.DefaultIssuerPolicy().RoundTripper()} for these three call sites, and it is recorded in MIGRATING and Appendix F rather than hidden. The SDK default is now safe; the CLI needs its own PR.
Still open, recorded in Appendix F:
- The other four SDKs. Only Go and Ruby have surfguard, and the Ruby gem is classification-only by design (the caller pins the resolved address), so Ruby's enforcement would be a
Net::HTTP#ipaddr=pin under the defaultFetchertransport — not reachable from the injected-Faraday lane. TS, Python, and Kotlin have no classification tables at all; the seams (undici connectorlookup, a resolving httpcore backend, OkHttpDns) are named so the follow-ups are specified rather than rediscovered. Same cross-SDK shape as #805. - Exchange redirect-following is not uniform: Go's
Exchanger, Kotlin's default client, and TS's exchange (noredirect:option) follow redirects, where the device flow suppresses them in all four. Under the policy client each redirect hop's dial is judged, so the address policy holds; a 307 re-POST of the secret to a publicLocationdoes not need the policy to be exploitable. Same shape as #805, so noted rather than patched here. AuthManager.refreshLockedposts a refresh token toCredentials.TokenEndpointon the main API client. That endpoint is consumer-stored; if the consumer stored a discovered one, the same reasoning applies at the consumer's boundary.
API break
NewExchanger gains a variadic parameter. Source-compatible for every direct call; a function value of the old type stops compiling, and apidiff reports it incompatible — hence breaking, as #804 was for NewDiscoverer.
Summary by cubic
Polices the addresses of device-authorization and token endpoints, not just the issuer metadata GET, closing the indirect SSRF path where a public issuer could steer credentials into private space.
- Device flow (
PerformDeviceLogin,RequestDeviceAuthorization,PollDeviceToken) andExchangerPOST via a shared client that enforcesoauth.DefaultIssuerPolicy()(same as discovery). Device-flow redirects remain suppressed. - Precedence: caller-provided HTTP client is used as-is; else a provided policy; else the shared default. Passing
http.DefaultClientrestores prior behavior. - New options:
WithDevicePolicy,WithDeviceHTTPClient,WithExchangerPolicy.NewExchangernow accepts variadic options. - Refusals surface as non-retryable
api_errormatchingsurfguard.ErrBlocked; the poll loop terminates on the first attempt. - Scope: this default enforcement applies to the device-flow and
ExchangerPOSTs only.AuthManager.refreshLockedposts to a storedTokenEndpointon the caller’s client and remains outside this default policy. Caller-supplied clients are never wrapped; compose the policy into their transport if desired.
Migration
- If you used defaults with loopback/private AS endpoints, add
WithDevicePolicy(oauth.DefaultIssuerPolicy().AllowLoopback())orWithExchangerPolicy(...), or supply your own client. Otherwise these requests are now refused. - Callers already passing their own client are unchanged and not protected; to enable enforcement, build it with
&http.Client{Transport: oauth.DefaultIssuerPolicy().RoundTripper()}. - If you used
NewExchangeras a function value, update to its new type with...oauth.ExchangerOption. Direct calls remain source-compatible.