Skip to content

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

Merged
jeremy merged 6 commits into
mainfrom
police-oauth-egress
Aug 30, 2026
Merged

Police OAuth egress with per-provenance address enforcement#656
jeremy merged 6 commits 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

Previously one injected client bypassed the SDK's SSRF policy; a malicious discovery doc could steer device/token POSTs into private address space. OAuth egress now rides per-provenance clients that enforce the address policy and preserve SDK error taxonomy.

  • BC5 lane derives policy from cfg.BaseURL (loopback only when that anchor is local) and carries discovery both hops, device auth/polling, and bc5-typed refreshes.
  • Launchpad lane derives policy from validated launchpadURL() and carries web-flow code exchange and launchpad-typed refreshes; loopback allowance does not cross lanes.
  • BASECAMP_OAUTH_USE_PROXY=1 routes only positively-proxied requests through a cloned DefaultTransport pinned to a construction-time httpproxy snapshot (downgrades logged, deduped per endpoint); otherwise the surfguard-guarded direct transport is used with NO_PROXY still enforced — no path to unguarded direct egress. Warnings are sanitized at the sink and render endpoints percent-encoded and proxies as scheme://host only.
  • Refresh/exchange wrap SDK errors without flattening, so errors.Is(err, surfguard.ErrBlocked) and typed code/status/retryability survive.
  • Lane clients block non-GET/HEAD redirects at 10 hops; appctx passes nil so internal/auth builds the policed clients and owns the redirect guard.
  • Pins github.com/basecamp/basecamp-sdk/go to v0.15.0, adds github.com/basecamp/surfguard/go, rebases onto main's basecamp/cli, x/crypto, and mcp bumps, and syncs the vendored MCP model (description/required-field tightening only, no operation changes). tools show omits the name parenthetical when Tool.Name is nil.

Rollout:

  • To proxy OAuth, set BASECAMP_OAUTH_USE_PROXY=1; any other non-empty value is off with a warning; empty is treated as unset.
  • 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 d71a015. 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
@jeremy
jeremy force-pushed the police-oauth-egress branch from ae35477 to 9d950e4 Compare August 23, 2026 02:43

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 thread internal/auth/client_test.go Outdated
Comment thread internal/auth/client.go Outdated
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 Outdated

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 thread internal/commands/tools.go Outdated
Comment thread internal/auth/client.go Outdated
Copilot AI review requested due to automatic review settings August 25, 2026 02:33
@jeremy
jeremy force-pushed the police-oauth-egress branch from 9d950e4 to 6bd254e Compare August 25, 2026 02:33

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 1 comment.

Suppressed comments (2)

internal/auth/client.go:171

  • req.URL can come from OAuth metadata, and this warning is emitted before the guarded transport runs. A percent-encoded escape sequence is decoded into URL.Path, so redactedEndpoint can place raw terminal controls on stderr (for example %1b%5b31m). Sanitize the fully formatted warning with richtext.SanitizeSingleLine before invoking the terminal sink, and add a regression case with encoded ESC/C1 characters; this matches the terminal-sink handling in internal/richtext/sanitize.go:35-49.
		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)

internal/auth/client_test.go:445

  • The test below asserts that set-but-empty is silent, so this test description currently states the opposite behavior. Limit “with warning” to non-empty malformed values.
// 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
Copilot AI review requested due to automatic review settings August 29, 2026 05:28
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-29T06:38:20.563854Z d71a015 Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@jeremy

jeremy commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: c51769c94d

ℹ️ 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".

@jeremy

jeremy commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

Review round converged at c51769c9.

All six threads addressed and resolved (escape-safe endpoint rendering, host-only proxy redaction, nil Tool.Name, two doc-comment corrections), plus the stale Nix vendorHash that had the flake job red. Codex re-review on c51769c9: no issues. All checks green.

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 1 comment.

Comment thread internal/auth/client.go
Copilot AI review requested due to automatic review settings August 29, 2026 05:46
@jeremy

jeremy commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 0adf164b85

ℹ️ 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".

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 1 comment.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

internal/auth/client.go:139

  • This bullet says a malformed HTTP_PROXY cannot degrade into egress, but httpproxy drops unparsable proxy URLs; proxyURL == nil then permits guarded direct egress to public addresses, as TestOAuthTransport_MalformedProxyValueStaysEnforced documents. Distinguish resolver errors (which are refused) from malformed proxy URLs so this security contract matches the implementation.
//   - the snapshot proxy resolver errors → the request is refused before
//     either sub-transport runs. A malformed HTTP_PROXY must not degrade
//     into egress the operator asked to route elsewhere;

Comment thread internal/commands/tools.go
jeremy added 4 commits August 28, 2026 22:56
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).
…tool name, nix hash

- Render OAuth endpoint paths percent-encoded in proxy warnings and dedupe keys: url.Parse decodes escapes into Path, so a hostile discovery document could put terminal control sequences or a newline into stderr.
- Log proxies as scheme://host only. url.URL.Redacted masks passwords but preserves bare usernames and query values, both of which HTTP(S)_PROXY can carry as credentials.
- Omit the "(name)" parenthetical in `tools show` when Tool.Name is nil, which SDK v0.15.0 documents as always for the Get projection.
- Fix two doc comments that claimed set-but-empty BASECAMP_OAUTH_USE_PROXY warns; it is silently treated as unset by design.
- Refresh the Nix vendorHash for the surfguard dependency (the flake job was red).
EscapedPath only covered the path: url.Parse admits UTF-8 C1 controls (U+009B CSI) in a host verbatim, so a discovery-controlled endpoint host could still inject terminal controls through the proxy warnings. Scrub the whole rendered message once in Manager.warnf, which covers the endpoint host and path, the proxy host, and any field a later warning interpolates.
TestCatalogModelProvenance requires the MCP model snapshot to match the SDK version go.mod pins. Synced via scripts/sync-mcp-model.sh from go/v0.15.0 (1dd547b3): API revision 2026-08-05 → 2026-08-11, description and required-field tightening only, no operation changes.
Copilot AI review requested due to automatic review settings August 29, 2026 06:27
@jeremy
jeremy force-pushed the police-oauth-egress branch from 0adf164 to 53c210a Compare August 29, 2026 06:27
The rebase merged main's dependency bumps (basecamp/cli, x/crypto, mcp) with this branch's SDK v0.15.0 + surfguard pins, which invalidates the fixed-output derivation hash. Value from the flake job's own computation.
@jeremy

jeremy commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

@codex review

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 12 out of 13 changed files in this pull request and generated no new comments.

Suppressed comments (1)

internal/commands/tools.go:132

  • The new nil-name branch is the compatibility behavior required by SDK v0.15, but the existing tools show fixtures always include name and no test asserts the rendered summary when it is omitted. Add a tools show case with a response lacking name and verify the summary is Title at position N (without ()), so this SDK fallout cannot regress unnoticed.
			summary := fmt.Sprintf("%s at position %s", tool.Title, posStr)
			if tool.Name != nil {
				summary = fmt.Sprintf("%s (%s) at position %s", tool.Title, *tool.Name, posStr)

Copilot AI review requested due to automatic review settings August 29, 2026 06:32
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: c03a188841

ℹ️ 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".

SDK v0.15.0's Get projection omits name; pin that the summary reads 'Title at position N' with no empty parenthetical, and 'Title (name) at position N' when a name is present.
@jeremy

jeremy commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

@codex review

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 13 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

internal/auth/client.go:27

  • This security-sensitive opt-out is the only supported way for OAuth to work in proxy-only environments, but it is not documented in the README’s OAuth environment-variable table. Please document the exact =1 behavior, that it disables address enforcement only for requests actually routed through a proxy, and that NO_PROXY requests remain guarded; otherwise operators must infer a security-critical rollout setting from runtime warnings or source code.
const oauthUseProxyEnv = "BASECAMP_OAUTH_USE_PROXY"

Copilot AI review requested due to automatic review settings August 29, 2026 06:38
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: d71a015764

ℹ️ 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".

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 13 out of 14 changed files in this pull request and generated 1 comment.

Comment thread internal/auth/client.go
@jeremy

jeremy commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

Review round converged at d71a0157 (rebased onto main @ ec745b01).

Since the last summary:

  • Rebase: merged main's dependency bumps with this branch's SDK v0.15.0 + surfguard pins; synced the vendored MCP model to go/v0.15.0 (TestCatalogModelProvenance requires it after Serve Basecamp over MCP with basecamp mcp #662); refreshed the Nix vendorHash.
  • Warnings are now sanitized once at the Manager.warnf sink (richtext.SanitizeSingleLine), covering endpoint host/path and proxy host in one place — replaces the per-field escaping. Test drives a U+009B host through the ignored-proxy warning.
  • tools show summary has a nil-name test.

Verification: all checks green on d71a0157 (Nix flake included); full bin/ci green on Linux (thelio) for the rebased tree; Codex re-review on d71a0157: no issues.

One thread left open on purpose (RequireSecureURL case-sensitive localhost): true, but pre-existing shared code outside this diff — needs a human decision on a separate hostutil change.

@jeremy
jeremy merged commit fb3966a into main Aug 30, 2026
35 checks passed
@jeremy
jeremy deleted the police-oauth-egress branch August 30, 2026 08:30
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