Police the endpoints a discovered issuer names, not only the issuer - #810
Conversation
There was a problem hiding this comment.
Pull request overview
Adds dial-time address enforcement to Go OAuth device and token endpoint requests, closing an indirect SSRF path.
Changes:
- Adds device-flow and exchanger policy options.
- Classifies blocked endpoints as terminal, non-retryable API errors.
- Adds security tests and migration documentation.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
SPEC.md |
Defines endpoint address-policy requirements and SDK status. |
MIGRATING.md |
Documents behavior changes and overrides. |
go/README.md |
Explains default enforcement and configuration. |
go/pkg/basecamp/oauth/exchange.go |
Enforces policy for token exchanges. |
go/pkg/basecamp/oauth/endpoint_policy_test.go |
Tests blocking, precedence, and isolation. |
go/pkg/basecamp/oauth/discovery.go |
Shares policy-client construction and errors. |
go/pkg/basecamp/oauth/device.go |
Enforces policy across device-flow requests. |
Suppressed comments (1)
SPEC.md:4128
- The PR description says the unprotected
AuthManager.refreshLockedpath is recorded in Appendix F, but this new appendix section ends without mentioning it. Please record that this SDK path uses the caller-ownedAuthManagerclient even whenCredentials.TokenEndpointwas copied from discovery, so consumers do not infer that later automatic refreshes receive the new default policy.
already passes its own client — `basecamp-cli` passes its general-purpose
client to all three entry points — sees no change, and is also not protected
by this: the policy lives in the transport, and that consumer owns its
transport.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…806) It leaves the indirect route open: an issuer on public space passes, is selected, and returns issuer-bound metadata whose token_endpoint and device_authorization_endpoint point anywhere — and those are where the client_id, device_code, authorization code, client secret, and refresh token are POSTed, on http.DefaultClient. The device flow and the Exchanger now default to the same shared DefaultIssuerPolicy client as the issuer hop, with the same override shape: WithDevicePolicy / WithDeviceHTTPClient, WithExchangerPolicy / a non-nil NewExchanger client. A caller-supplied client stays the caller's, enforcement included. A refusal is a non-retryable api_error that matches surfguard.ErrBlocked; in the poll loop it terminates on the first attempt instead of being read as a timeout and backed off. Same-origin is recorded as NOT the control (RFC 8414 does not require it, RFC 8705 publishes off-origin endpoints by design, and a hostname comparison does not survive a rebind between discovery and the POST). SPEC §16 gains requirement 6 [Go-first]; Appendix F records the per-SDK state and the seam each would need; MIGRATING records the tightening.
…ming uniform timeouts Review on #810: AuthManager.refreshLocked posts a stored refresh token — which the documented device-login bridge sources from a discovered Config.TokenEndpoint — on the caller-owned client, outside the new default policy; and the bounded-timeout claim was false for Go's doTokenRequest (caller context only) and Kotlin's postTokenRequest (no HttpTimeout). Requirement 6 now names its boundary and the stored- credentials residual explicitly, in both §16 and Appendix F.
1114242 to
9eb2ddd
Compare
|
Fixed — the review body's suppressed note on Appendix F is addressed alongside the inline thread: Appendix F now records that AuthManager.refreshLocked runs on the caller-owned client even when Credentials.TokenEndpoint was copied from discovery, so later automatic refreshes do not receive the new default policy. Also rebased onto main (#809 landed); the only conflict was both PRs' MIGRATING entries, kept both, and Appendix F's #805 reference now notes #809 closed the download hop, leaving the exchange path as the remaining redirect-following exception. |
* origin/main: Police the endpoints a discovered issuer names, not only the issuer (#810) Refuse redirects on the signed download hop in every SDK (#809) Quiet known-noise CodeQL alerts without losing coverage (#807) Judge the advertised OAuth issuer's address, not just its spelling (#804) Let Go's raw GET retry loop see the Retry-After it already parses (#796) Deflake three tests that raced a wall clock, and gate the class that produced two of them (#794) SPEC §6: decide which statuses honour Retry-After, and how each loop composes it (#793) Pin the conformance runners' fixture reads, and give CI a leg that can see them break (#791) Report an anonymous embed the timestamp walk cannot resolve, instead of skipping it (#790) # Conflicts: # go/go.mod
Main moved by four while this PR was in review: #804, #807, #809, #810. Merged in; what each needed here, verified against the tree: - #809 and #810 wrote their own Unreleased entries when they merged (#805, #806) and carry `breaking` -- nothing to add. - #807 is CI-internal -- nothing to add. - #804 carries `breaking` but had no entry: resource-first discovery's second hop now rides the address-policed shared client, refusing special-use-space issuers non-retryably and dropping the caller's transport for that hop. Entry added beside #806's, with the variadic NewDiscoverer compile note and the remedies in policy order. - The trailer's verification commit moves to fa15fc1 and its in-flight list shrinks to what is actually in flight. Codex round 5's guard finding rides along: the pyproject check now parses the [project] table (awk section-scoped exact line) instead of matching a version assignment anywhere in the file. Proven with the literal bypass -- a stale [project].version plus an exact assignment in another table passed the old whole-file grep and is refused now; restore by copy, diff -q clean.
…nt-feed-go-connector * origin/event-feed-foundations: Make the fake transport honour the oversize sentinel it mirrors Police the endpoints a discovered issuer names, not only the issuer (#810) Refuse redirects on the signed download hop in every SDK (#809) Quiet known-noise CodeQL alerts without losing coverage (#807) Judge the advertised OAuth issuer's address, not just its spelling (#804) Let Go's raw GET retry loop see the Retry-After it already parses (#796) Deflake three tests that raced a wall clock, and gate the class that produced two of them (#794) SPEC §6: decide which statuses honour Retry-After, and how each loop composes it (#793) Pin the conformance runners' fixture reads, and give CI a leg that can see them break (#791) Report an anonymous embed the timestamp walk cannot resolve, instead of skipping it (#790)
…ent-feed-conformance-driver * origin/event-feed-go-connector: Event feed: verdicts that lost coin flips, and a driver blind to eras Fix the filters-clone comment that stated the opposite of the code Event feed: a policy reason never echoes what the server wrote Event feed: wakes are not verdicts, and three more places the order was the bug Make the fake transport honour the oversize sentinel it mirrors Police the endpoints a discovered issuer names, not only the issuer (#810) Refuse redirects on the signed download hop in every SDK (#809) Event feed: the oversize verdict survived the seam but not the sanitizer Event feed: close the rendering-cap class, type the oversize rejection Event feed: cancellation could not reach the one exit, and the origin was checked after the mangle Event feed: own the ledger's bytes, cap the dial rendering, test the store fake Quiet known-noise CodeQL alerts without losing coverage (#807) Judge the advertised OAuth issuer's address, not just its spelling (#804) Let Go's raw GET retry loop see the Retry-After it already parses (#796) Deflake three tests that raced a wall clock, and gate the class that produced two of them (#794) SPEC §6: decide which statuses honour Retry-After, and how each loop composes it (#793) Pin the conformance runners' fixture reads, and give CI a leg that can see them break (#791) Report an anonymous embed the timestamp walk cannot resolve, instead of skipping it (#790)
* Make the release guide and guards tell the truth before v0.15.0 MIGRATING.md and the release tooling carried nine catalogued defects; this repairs the seven that live in the tree. The retro-labels on merged PRs and the Unreleased -> v0.15.0 promotion happen at tag time. - Re-file the two post-tag entries out of "# v0.14.0" into "# Unreleased": the #662 absent-expiry changes and the TimelineEventData pointer retype both landed in #703, after the tag. Proof: `git show go/v0.14.0:MIGRATING.md` contains neither heading. - Write the four entries the section was missing: #773 (the merge-safe Go reads return a transport failure verbatim -- errors.As and Retryable results move), #737 (four TS paginated methods now declare the ListResult they always returned), #735 (every generated Swift model has a public init -- recorded as NOT a break: no existing initializer changed shape, the 35 affected models were previously unconstructible so no consumer code exists against them), and the maxPages runtime cap (`1919e77f7`, a bare commit label-generated notes cannot list). - Fix the #650 miscount: `position` is conditional on the wire but was modeled before #723, so it is not one of "the seven" -- two of the seven new keys are conditional, plus `position`. Derived from the v0.14.0 and current Tool schemas and the bc3 partial's own `if`s. - Refresh the #604 table's Kotlin row to agree with the #750 entry and the KDoc it cites: the SerializationException lands in `decodeFailure`, the discriminator; `cause` mirrors it and is explicitly not one. - Rewrite the "# Not in this release" trailer: "Nothing is in flight ... merged at 9a819e4" was 53 commits stale. It now names the verification commit and the actual in-flight set, and dates the historical record below it. - Close the `make release` guard gap: it grepped seven of the ten files scripts/bump-version.sh writes, so a truncated bump could tag with the root package.json, typescript/src/client.ts or python/pyproject.toml constant stale. All three join the guard. Proven by mutating each file and watching `make release` refuse with the new message; restored by copy, verified with diff -q. - Release bodies now say that a change merged without a pull request appears only in MIGRATING.md, since generate_release_notes builds from merged PRs and structurally cannot list bare commits. * Absorb the bot round: exact guards, honest scope, the missing Ruby entry Seven fixes from the Copilot and Codex reviews, all taken: - The pyproject guard is an exact whole-line match (grep -qxF). The old regex left dots unescaped and the end unanchored, so a valid-PEP-440 "0.15+0" passed it and failed only in the Python release workflow, after other SDKs had published -- the exact post-tag failure class this PR exists to close. - typescript/package-lock.json (both SDK-version fields, via jq) and ruby/Gemfile.lock join the lockfile guards; bump-version.sh rewrites both, and neither was checked. All three new/changed guards proven by mutation: each refused with its message and exit 2, restored by copy, diff -q clean. - The trailer no longer claims every count in the guide was measured at 8fcb39a -- v0.13.0's totals state their own 9a819e4 baseline. The claim is scoped to the Unreleased section and the in-flight survey. - The #735 entry tells the two Swift shapes apart: updateGaugeNeedle was callable only as the nil-payload {} that bc3 400s; updateMyPreferences was not callable at all (outer requires the unconstructible payload). - The maxPages entry described sloppy-mode assignment wrong: [[Set]] on an inherited getter-only accessor creates no own property -- the assignment is silently ignored, not shadowed. - Three Ruby bare commits (2f21c9d, 3281530, 4785146) were consumer-visible -- crashes on mailto:/hostless server-supplied URLs became ApiError refusals -- and had entries nowhere. One combined entry records the class and the rescue that stops matching. - The release-body sentence no longer promises MIGRATING.md is a complete record of PR-less commits; it states the mechanism (the generated notes cannot see them) and points at the guide for consumer-visible changes. * Scope the Ruby entry's no-request claim to the rejected target Codex round 2: the request that returned the malformed Link or Location header was necessarily already sent — only the follow-up to the rejected target is prevented. Saying "before anything is sent" misled anyone reasoning about hooks or request counts. * Absorb Codex round 3: getter scope, PATH-spec anchor, test-import claim - The #773 entry scoped the classification change to the four composites; Documents.Get installs markBodyReadFailures itself and Schedules.GetEntry delegates to getEntryWithBody, so direct getter callers see it too. The entry now names the getters and the composites built on them. - Both ruby Gemfile.lock guards anchor to the 4-space PATH-spec line with grep -qxF; the loose match could be satisfied by the version-bearing 2-space entry while the PATH spec stayed stale. Proven: mutating only the PATH-spec line now refuses with exit 2, and a clean tree passes the guard block. - The #735 entry claimed every Swift test file uses @testable import; the generator-only test files import BasecampGenerator plain. Narrowed to every test file that imports the SDK module. * Read the JSON versions as fields, and check both Gemfile.lock records Codex round 4 and Copilot round 2 converged on the same hole: grep -qF over package.json matches a "version" string anywhere in the document, so a stale top-level version passed while any nested metadata field carried the requested one. Proven literally: a crafted package.json with top-level 0.13.9 and a nested 0.14.0 satisfied the old grep and is refused by the new jq field read. typescript/package.json gets the same treatment -- same shape, same class. Copilot also wanted both version-bearing Gemfile.lock records checked, not just the PATH spec: a lockfile whose CHECKSUMS entry lags the PATH spec would pass the anchored guard and fail only post-tag. Both files now check both exact lines; a CHECKSUMS-only staleness is refused with its own message, proven by mutation with restore-by-copy. * Absorb the four main merges and Codex round 5 Main moved by four while this PR was in review: #804, #807, #809, #810. Merged in; what each needed here, verified against the tree: - #809 and #810 wrote their own Unreleased entries when they merged (#805, #806) and carry `breaking` -- nothing to add. - #807 is CI-internal -- nothing to add. - #804 carries `breaking` but had no entry: resource-first discovery's second hop now rides the address-policed shared client, refusing special-use-space issuers non-retryably and dropping the caller's transport for that hop. Entry added beside #806's, with the variadic NewDiscoverer compile note and the remedies in policy order. - The trailer's verification commit moves to fa15fc1 and its in-flight list shrinks to what is actually in flight. Codex round 5's guard finding rides along: the pyproject check now parses the [project] table (awk section-scoped exact line) instead of matching a version assignment anywhere in the file. Proven with the literal bypass -- a stale [project].version plus an exact assignment in another table passed the old whole-file grep and is refused now; restore by copy, diff -q clean. * Guard the heading promotion, and fix two entry misstatements Copilot round 3 caught the release procedure's last unguarded step: the "# Unreleased" -> "# v(VERSION)" promotion was a hand edit nothing enforced, so a tag could ship with its notes still filed as unreleased. scripts/promote-migrating.sh now does the rewrite (exact-line, idempotent, refusing the both-headings and neither-heading states), bump-version.sh calls it as step 11, and make release guards both directions: the promoted heading must exist and "# Unreleased" must not. Proven: release refuses on today's tree; the script promotes a scratch copy correctly, is idempotent, and errors on both degenerate states. Codex round 6's two rides along: - The TS client guard is an exact whole-line match including the semicolon, so a comment carrying the assignment text cannot satisfy it while the real constant lags. - The #804 entry's remedy list dropped the address-class split in transcription: AllowLoopback re-admits loopback and nothing else, and Allow does not pierce the IANA tables -- for RFC 1918 the policy must be built without them, which is the implementation's own documented spelling. The entry now says so, and the #735 entry stops claiming UpdateGaugeNeedleRequest has a required member (its member is optional; the outer init exists because request models always got one). * Watch promote-migrating.sh with the gate that watches its caller Copilot: delegating the promotion to a new script moved release-bump behavior out of the sensitive-change gate's sight -- bump-version.sh is listed in extra-patterns and the new script was not. It is now.
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_endpointanddevice_authorization_endpointpoint wherever it likes.parseAndBindASMetadatachecks only thattoken_endpointis non-empty and copies it verbatim;RequireSecureEndpointadmits anyhttpshost, private space included. The credential POSTs then went out onhttp.DefaultClient—client_idanddevice_codefrom the device flow, the authorization code, client secret, and refresh token from theExchanger. 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 (
PerformDeviceLogintakes a*Config;Exchange/Refreshtake a string), so the policy applies to every request on the default client. That is the same uniformity call as #804 made forWithExpectedIssuer, for the same reason; a provenance flag onConfigis a marker that a consumer round-tripping the config through storage silently drops.The change
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.DefaultClientis the spelling of "no policy", so noWithout*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 matcheserrors.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:
WithDevicePolicybuilds its transport once at option construction (PerformDeviceLoginhands the same opts to the auth request and the poll, so a transport pernewDeviceConfigwould be two pools per login),WithExchangerPolicybuilds one perExchanger, andsuppressRedirectskeeps copying so the shared default is never mutated.Demonstrated, not asserted
Seven mutations, each run against the new test file, each caught:
http.DefaultClientPerformDeviceLogin_BlockedEndpointIsNeverContacted,PollDeviceToken_BlockedTokenEndpointTerminates,RefusesLegacyNumericSpelling,DefaultPolicyClientIsSharedAcrossSurfaces,EndpointPolicyIsolationhttp.DefaultClientExchanger_BlockedTokenEndpointIsNeverContacted,RefusesLegacyNumericSpelling,DefaultPolicyClientIsSharedAcrossSurfacesErrBlocked(falls to timeout/transport)PollDeviceToken_BlockedTokenEndpointTerminatesErrBlocked(falls to transport)PerformDeviceLogin_Blocked…,RefusesLegacyNumericSpelling,EndpointPolicyIsolationErrBlockedbranch made deadExchanger_Blocked…,RefusesLegacyNumericSpellingWithDevicePolicybuilds a transport per callDefaultPolicyClientIsSharedAcrossSurfacesRetryable: trueEvery 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-lintclean; the conformance runner still builds;make doc-constants-checkandmake go-check-driftpass. The 74 existing device tests and 10 exchange tests pass unedited — every one that reaches the wire already injectedsrv.Client(), and the five that do not all fail before the request.What this does and does not close
basecamp-cliis not protected by this change. It passes its general-purposem.httpClienttoNewExchanger,WithDeviceHTTPClient, andNewDiscovereralike, 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 arbitraryRoundTripper. That is the #804WithIssuerHTTPClientcontract applied consistently; the alternative (clone an*http.Transportand overwrite itsDialContext/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:
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 Download hop 2 follows redirects in four SDKs: needs a cross-SDK destination policy, not a Go patch #805.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 Download hop 2 follows redirects in four SDKs: needs a cross-SDK destination policy, not a Go patch #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
NewExchangergains a variadic parameter. Source-compatible for every direct call; a function value of the old type stops compiling, andapidiffreports it incompatible — hencebreaking, as #804 was forNewDiscoverer.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.
PerformDeviceLogin,RequestDeviceAuthorization,PollDeviceToken) andExchangerPOST via a shared client that enforcesoauth.DefaultIssuerPolicy()(same as discovery). Device-flow redirects remain suppressed.http.DefaultClientrestores prior behavior.WithDevicePolicy,WithDeviceHTTPClient,WithExchangerPolicy.NewExchangernow accepts variadic options.api_errormatchingsurfguard.ErrBlocked; the poll loop terminates on the first attempt.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
WithDevicePolicy(oauth.DefaultIssuerPolicy().AllowLoopback())orWithExchangerPolicy(...), or supply your own client. Otherwise these requests are now refused.&http.Client{Transport: oauth.DefaultIssuerPolicy().RoundTripper()}.NewExchangeras a function value, update to its new type with...oauth.ExchangerOption. Direct calls remain source-compatible.Written for commit 9eb2ddd. Summary will update on new commits.