Skip to content

Police OAuth egress with per-provenance address enforcement - #656

Open
jeremy wants to merge 1 commit into
mainfrom
police-oauth-egress
Open

Police OAuth egress with per-provenance address enforcement#656
jeremy wants to merge 1 commit into
mainfrom
police-oauth-egress

Conversation

@jeremy

@jeremy jeremy commented Aug 23, 2026

Copy link
Copy Markdown
Member

The CLI passes its own m.httpClient to every SDK OAuth entry point, and a caller-supplied client is the caller's, enforcement included — so neither basecamp-sdk#804's nor #810's SSRF address policy was live here: a malicious BC5 discovery document could still steer the device-authorization and token POSTs (carrying client_id, device_code, refresh token) into private address space. This PR makes the policy live, pinned to SDK v0.15.0 (1dd547b3 — the exact release commit, recorded via scripts/bump-sdk.sh).

Design: per-provenance lanes, selected at the call sites

One loopback-enabled client shared by every lane would let a localhost BASECAMP_LAUNCHPAD_URL grant loopback to a production BC5 flow. Instead, two lazily built, cached, error-returning lanes on the Manager (bc5Client() / launchpadClient()), each {30 s, checkAuthClientRedirect, per-lane transport}:

  • BC5 lane (policy from cfg.BaseURL; AllowLoopback iff that host is local): discovery both hopsWithIssuerHTTPClient is passed unconditionally so a local resource's local advertised issuer isn't refused by the SDK's internal default right after hop 1 succeeds — plus device authorization + polling, and refreshes of bc5-typed credentials.
  • Launchpad lane (policy from validated launchpadURL(), env override included): web-flow code exchange and launchpad-typed refreshes. Refresh lane selection is by the stored OAuthType (a policy anchor, not a claimed binding — OAuthType and TokenEndpoint persist independently).
  • Loopback derivation parses the anchor URL and lowercases the host before hostutil.IsLocalhost (which is case-sensitive); errors name the anchor without echoing its value.
  • The single injected-client seam remains, documented caller-owned/test-only; appctx now passes nil, and checkAuthClientRedirect (the credential-replay guard, preserved on both lanes) moved into internal/auth with its tests — appctx no longer owns behavior it doesn't use.

Proxy handling: per-request, fail-closed, never unguarded direct

Each lane's transport is a proxy-aware wrapper over two sub-transports — the surfguard-policed direct transport, and (opt-out mode only) a clone of http.DefaultTransport pinned to the one construction-time snapshot of httpproxy.FromEnvironment().ProxyFunc() (never http.ProxyFromEnvironment, whose process-global cache could diverge from the snapshot). Per request, against the actual request URL (discovered issuer, device, polling, persisted refresh endpoints all evaluated):

  • resolver error → the request is refused before either sub-transport runs (the one real error httpproxy produces — CGI REQUEST_METHOD + HTTP_PROXY — is tested, plus an injected-resolver unit test);
  • resolver names a proxy and BASECAMP_OAUTH_USE_PROXY=1 → proxied sub-transport, enforcement off for exactly that request, downgrade logged (deduplicated per endpoint — the device poll re-POSTs the same URL);
  • everything else → the guarded direct transport. A NO_PROXY exclusion stays enforced even in opt-out mode; there is no path to unguarded direct egress (the decisive regression test: opt-out + NO_PROXY-covered private target → surfguard.ErrBlocked, zero dials).

Default (protected) mode enforces unconditionally and warns — deduplicated, driven by effective routing rather than variable presence — when a configured proxy is ignored for OAuth traffic, naming the opt-out knob. Malformed opt-out values (yes, 2) are off, with a warning.

Error taxonomy across the CLI boundary

refreshLocked and exchangeCode used to stringify SDK errors into ErrAPI(0, …), which would discard basecamp-sdk#813's redirect status and break errors.Is(err, surfguard.ErrBlocked). They now wrap preserving code, HTTP status, retryability, and the cause chain — tested for errors.Is(ErrBlocked) and errors.As(*basecamp.Error) survival on both paths.

Deviations from the reviewed plan, on the record

  • BASECAMP_OAUTH_USE_PROXY="" (set-but-empty) is treated as unset silently, not warned: env-scrubbing (t.Setenv, direnv) is indistinguishable from intent, and warning would fire in every scrubbed environment. Non-empty malformed values warn as planned.
  • A malformed proxy URL never reaches the resolver-error branch — httpproxy.Config.init silently drops unparsable values — so that case degrades to no-proxy → guarded direct, still enforced (tested as such). The fail-closed branch exists for the errors the resolver does produce.
  • internal/commands/tools.go: Tool.Name became *string in SDK v0.15.0 — one-line deref fallout from the pin, matching the neighboring Position handling.

Follow-up tied to the next SDK re-pin

TestRefreshLocked_RedirectStatusSurvives is committed but skipped: v0.15.0 predates basecamp-sdk#813's token-endpoint redirect classification. Un-skip at the next re-pin.

Verification

go build ./..., go vet ./..., gofmt -s -l clean, full make test, make lint (0 issues), make provenance-check. Existing OAuth tests (which inject clients) unchanged and green. Manual basecamp auth login against production and against a localhost bc3 dev server still deserves a pass before merge — the end-to-end local-chain admission/refusal is covered by TestDiscoverOAuth_LocalIssuerChainFollowsBaseURL, but a live login exercises the browser/device interaction this suite can't.


Summary by cubic

Police OAuth egress with per-provenance address enforcement. Previously a single injected client bypassed the SDK’s SSRF policy; a malicious discovery doc could steer device/token POSTs into private address space. Now OAuth traffic rides lane-specific clients that enforce policy and preserve SDK error taxonomy.

  • BC5 lane: policy from cfg.BaseURL; loopback only if that host is local. Used for discovery (both hops via oauth.WithIssuerHTTPClient), device authorization/polling, and bc5-typed refreshes.

  • Launchpad lane: policy from validated launchpadURL(); used for web-flow code exchange and launchpad-typed refreshes. Lane chosen by stored OAuthType (not TokenEndpoint); loopback allowance does not leak between lanes.

  • Proxy routing: per-request resolver, fail-closed on resolver error. BASECAMP_OAUTH_USE_PROXY=1 routes only positively-proxied requests via a cloned DefaultTransport pinned to a construction-time httpproxy snapshot (downgrades logged, deduped per endpoint). Otherwise uses the surfguard-guarded direct transport; NO_PROXY still enforced; no path to unguarded direct egress.

  • Redirect guard on lane clients: block non-GET/HEAD redirects; cap at 10 hops.

  • Error handling: refresh/exchange wrap SDK errors without flattening; errors.Is(err, surfguard.ErrBlocked) and errors.As(*basecamp.Error) work; code/status/retryability survive.

  • Wiring: appctx now passes nil so internal/auth builds policed clients; redirect guard lives in internal/auth.

  • Dependencies: pin github.com/basecamp/basecamp-sdk/go to v0.15.0; add github.com/basecamp/surfguard/go. Adjust for Tool.Name as *string.

  • Rollout notes:

    • To proxy OAuth, set BASECAMP_OAUTH_USE_PROXY=1; otherwise the proxy is ignored with a warning. Malformed values are off with a warning.
    • Ensure Base URL and BASECAMP_LAUNCHPAD_URL are correct; loopback is allowed only when that lane’s anchor is local.
    • Do not inject a custom OAuth http.Client in production; it disables enforcement by design.

Written for commit 9d950e4. Summary will update on new commits.

Review in cubic

Copilot AI balanced review requested due to automatic review settings August 23, 2026 02:40
@github-actions github-actions Bot added commands CLI command implementations sdk SDK wrapper and provenance tests Tests (unit and e2e) auth OAuth authentication deps labels Aug 23, 2026
The Manager passed its one general-purpose client to every SDK OAuth entry
point, and a caller-supplied client is the caller's, enforcement included —
so the SDK's dial-time SSRF address policy (basecamp-sdk#804/#810) was
never live in the CLI. OAuth traffic now rides two lazily built lanes whose
policies derive from operator configuration: BC5 (cfg.BaseURL; discovery
both hops via WithIssuerHTTPClient, device authorization and polling,
bc5-typed refreshes) and Launchpad (launchpadURL(); web-flow exchange,
launchpad-typed refreshes), each admitting loopback exactly when its own
anchor is local, so a localhost Launchpad override cannot grant loopback to
a production BC5 flow. Each lane wraps a proxy-aware, fail-closed
transport: resolver errors refuse the request outright, an operator opt-out
(BASECAMP_OAUTH_USE_PROXY=1) routes only positively-proxied requests
through a cloned DefaultTransport pinned to the one construction-time
httpproxy snapshot (downgrades logged), and everything else stays on the
surfguard-policed direct transport — NO_PROXY exclusions included, so there
is no path to unguarded direct egress. Protected mode warns, deduplicated
and by effective routing, when a configured proxy is ignored.
checkAuthClientRedirect moves to internal/auth with its tests (appctx now
passes nil and owns none of this), refreshLocked/exchangeCode stop
flattening SDK errors to ErrAPI(0) so surfguard.ErrBlocked and typed
statuses survive the CLI boundary, and the SDK pins to v0.15.0 via
scripts/bump-sdk.sh (Tool.Name *string fallout included).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds provenance-specific, SSRF-policed OAuth clients and proxy routing while upgrading the SDK to v0.15.0.

Changes:

  • Separates BC5 and Launchpad OAuth egress policies.
  • Adds guarded proxy routing and preserves typed SDK errors.
  • Updates dependencies, tests, and SDK pointer compatibility.

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 10 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
internal/auth/client.go Implements guarded OAuth clients and proxy routing.
internal/auth/client_test.go Tests policies, routing, and error preservation.
internal/auth/auth.go Routes OAuth operations through provenance lanes.
internal/auth/auth_test.go Updates redirect-guard test context.
internal/appctx/context.go Enables Manager-owned OAuth clients.
internal/appctx/context_test.go Removes relocated redirect tests.
internal/commands/tools.go Handles nullable SDK tool names.
internal/version/sdk-provenance.json Records the SDK/API revisions.
go.mod Upgrades SDK and adds surfguard.
go.sum Updates dependency checksums.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +443 to +445
// TestOptOutMode_MalformedValuesAreOffWithWarning: only the exact value "1"
// opts out. Anything else — including set-but-empty — is treated as off, with
// a warning, and enforcement stays on.
Comment thread internal/auth/client.go
// its query or userinfo — endpoint paths are diagnostic, query strings can
// carry parameters that don't belong in a terminal.
func redactedEndpoint(u *url.URL) string {
return u.Scheme + "://" + u.Host + u.Path
Copilot AI review requested due to automatic review settings August 23, 2026 02:44

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ae35477f56

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/auth/client.go
// its query or userinfo — endpoint paths are diagnostic, query strings can
// carry parameters that don't belong in a terminal.
func redactedEndpoint(u *url.URL) string {
return u.Scheme + "://" + u.Host + u.Path

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Sanitize endpoint paths before logging them

When a proxy applies, the endpoint may come from an attacker-controlled OAuth discovery document, and url.Parse decodes percent escapes into u.Path. Passing that path directly to warnf means an endpoint containing encoded newlines or terminal control sequences (for example %0a or %1b) can forge warning lines or manipulate the terminal. Render the escaped path or apply the repository's single-line sanitization before using the value in warnings.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 2 comments.

Suppressed comments (1)

internal/auth/client.go:195

  • u.Path is decoded, so an endpoint containing %1b... or %0a puts terminal controls or a newline into this warning. Since this value is written to a single-line stderr sink, retain the escaped path (or apply the repository's terminal sanitizer) before logging it.
	return u.Scheme + "://" + u.Host + u.Path

Comment on lines +128 to +132
name := ""
if tool.Name != nil {
name = *tool.Name
}
summary := fmt.Sprintf("%s (%s) at position %s", tool.Title, name, posStr)
Comment thread internal/auth/client.go
Comment on lines +170 to +175
t.warnOnce(req.URL, "warning: OAuth request to %s routed through proxy %s WITHOUT the SSRF address policy (%s=1)",
redactedEndpoint(req.URL), proxyURL.Redacted(), oauthUseProxyEnv)
return t.proxied.RoundTrip(req)
default:
t.warnOnce(req.URL, "warning: ignoring proxy %s for OAuth request to %s: the SSRF address policy requires direct egress; set %s=1 to route OAuth through the proxy without address enforcement",
proxyURL.Redacted(), redactedEndpoint(req.URL), oauthUseProxyEnv)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth OAuth authentication commands CLI command implementations deps sdk SDK wrapper and provenance tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants