Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
"github.com/basecamp/basecamp-sdk/go/pkg/basecamp/oauth"
"github.com/basecamp/cli/pkce"
surfguard "github.com/basecamp/surfguard/go"

"github.com/basecamp/basecamp-cli/internal/config"
"github.com/basecamp/basecamp-cli/internal/hostutil"
Expand Down Expand Up @@ -108,6 +109,12 @@ type Manager struct {
// against (renewal_hold.go). Test seam; nil means time.Now.
clock func() time.Time

// firstPartyPolicy is the address policy the lanes' first-party
// transport enforces (client.go). Test seam, so a test can reach a
// loopback server through that transport; nil means
// firstPartyOAuthPolicy.
firstPartyPolicy func() surfguard.Policy

mu sync.Mutex

// kindMu guards kind, which is read while building an error's remedy
Expand Down
104 changes: 88 additions & 16 deletions internal/auth/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package auth
import (
"fmt"
"net/http"
"net/netip"
"net/url"
"os"
"strings"
Expand Down Expand Up @@ -76,6 +77,62 @@ func oauthEndpointPolicy(anchorURL, label string) (surfguard.Policy, error) {
return policy, nil
}

// firstPartyOAuthHosts are the hosts 37signals operates that the OAuth flows
// reach: the BC5 API and its issuer under both the original and the current
// domains (credentials stored before the move name
// https://3.basecamp.com/oauth/tokens), and Launchpad. The set is fixed at
// build time. Neither a configured base URL nor anything a discovery document
// names can add to it, which is what keeps firstPartyOAuthPolicy from ever
// reaching an endpoint a remote peer chose.
var firstPartyOAuthHosts = map[string]bool{
"3.basecampapi.com": true,
"api.basecamp.com": true,
"3.basecamp.com": true,
"app.basecamp.com": true,
"launchpad.37signals.com": true,
}

// fakeIPRange is 198.18.0.0/15, the RFC 2544 benchmarking block. Fake-IP DNS
// (mihomo/Clash Meta and sing-box in fake-ip mode, and the egress of hosted
// agent machines such as Grok's) answers every name from it and carries the
// connection on to the real host, so on those networks app.basecamp.com
// resolves here.
var fakeIPRange = netip.MustParsePrefix("198.18.0.0/15")

// firstPartyOAuthPolicy is the address policy for requests to
// firstPartyOAuthHosts: surfguard's default deny tables (private, loopback,
// link-local and cloud metadata, CGNAT, multicast) with the fake-IP range
// admitted, on port 443 only. It leaves out IANASpecialUse because surfguard's
// Allow cannot override that table. Leaving it out also admits globally
// reachable anycast services (AS112, AMT), and NAT64 addresses whose embedded
// IPv4 is itself admitted, which is how an IPv6-only network with DNS64
// reaches a public host. TLS still verifies the certificate
// against the first-party name, so a connection that lands anywhere else fails
// its handshake before a credential is sent.
func firstPartyOAuthPolicy() surfguard.Policy {
return surfguard.Policy{}.Allow(fakeIPRange).AllowPorts(443)
}

// firstPartyOAuthPolicy is the Manager's first-party policy: the
// firstPartyPolicy field when a test set one.
func (m *Manager) firstPartyOAuthPolicy() surfguard.Policy {
if m.firstPartyPolicy != nil {
return m.firstPartyPolicy()
}
return firstPartyOAuthPolicy()
}

// isFirstPartyOAuthURL reports whether u is an HTTPS request on the default
// port to one of firstPartyOAuthHosts. Plain HTTP (no certificate vouches for
// the answer), an explicit non-default port, and near-miss spellings such as a
// trailing dot all keep the lane's own policy.
func isFirstPartyOAuthURL(u *url.URL) bool {
if u.Scheme != "https" || (u.Port() != "" && u.Port() != "443") {
return false
}
return firstPartyOAuthHosts[strings.ToLower(u.Hostname())]
}

// proxyEnvState is the Manager's one construction-time snapshot of the proxy
// environment: the resolver both lanes route and warn against, and whether
// the operator opted OAuth traffic out of address enforcement when a proxy
Expand Down Expand Up @@ -127,10 +184,14 @@ func (m *Manager) warnf(format string, args ...any) {
}

// oauthTransport is the per-lane egress RoundTripper for OAuth traffic. It
// owns two sub-transports: the lane's address-policed direct transport
// (surfguard dial-time enforcement; Proxy nil by construction), and — only
// when the operator set BASECAMP_OAUTH_USE_PROXY=1 — a proxying clone of
// http.DefaultTransport.
// owns two address-policed direct transports (surfguard dial-time
// enforcement; Proxy nil by construction): the lane's own, and one under
// firstPartyOAuthPolicy for firstPartyOAuthHosts. Only when the operator set
// BASECAMP_OAUTH_USE_PROXY=1 does it also own a proxying clone of
// http.DefaultTransport. Which direct transport carries a request is decided
// from that request's URL, so every redirect hop is judged afresh, and a
// lane's anchor never lends the first-party allowance to an endpoint a
// discovery document named.
//
// Routing is per-request and fail-closed:
//
Expand All @@ -155,11 +216,12 @@ func (m *Manager) warnf(format string, args ...any) {
// rebuilt per call; idle connections are bounded by each sub-transport's
// IdleConnTimeout, so a process-lifetime CLI Manager needs no explicit Close.
type oauthTransport struct {
guarded http.RoundTripper
proxied http.RoundTripper
resolve func(*url.URL) (*url.URL, error)
optOut bool
warnf func(format string, args ...any)
guarded http.RoundTripper
firstParty http.RoundTripper
proxied http.RoundTripper
resolve func(*url.URL) (*url.URL, error)
optOut bool
warnf func(format string, args ...any)

mu sync.Mutex
warned map[string]bool
Expand All @@ -172,7 +234,7 @@ func (t *oauthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
}
switch {
case proxyURL == nil:
return t.guarded.RoundTrip(req)
return t.direct(req.URL).RoundTrip(req)
case t.optOut:
// Deduplicated per endpoint: the device poll re-POSTs the same URL
// every few seconds, and one downgrade notice per endpoint records
Expand All @@ -183,8 +245,17 @@ func (t *oauthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
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",
redactedProxy(proxyURL), redactedEndpoint(req.URL), oauthUseProxyEnv)
return t.guarded.RoundTrip(req)
return t.direct(req.URL).RoundTrip(req)
}
}

// direct picks the address-policed transport for a request that is not going
// through a proxy.
func (t *oauthTransport) direct(u *url.URL) http.RoundTripper {
if isFirstPartyOAuthURL(u) {
return t.firstParty
}
return t.guarded
}

func (t *oauthTransport) warnOnce(u *url.URL, format string, args ...any) {
Expand Down Expand Up @@ -284,11 +355,12 @@ func (m *Manager) buildLaneClient(anchorURL, label string) (*http.Client, error)
}
st := m.proxyState()
tr := &oauthTransport{
guarded: policy.RoundTripper(),
resolve: st.resolve,
optOut: st.optOut,
warnf: m.warnf,
warned: map[string]bool{},
guarded: policy.RoundTripper(),
firstParty: m.firstPartyOAuthPolicy().RoundTripper(),
resolve: st.resolve,
optOut: st.optOut,
warnf: m.warnf,
warned: map[string]bool{},
}
if st.optOut {
tr.proxied = proxiedTransport(st.resolve)
Expand Down
Loading
Loading