Let OAuth reach Basecamp's own hosts through fake-IP DNS

Merged
RO
3d ago
+422-16

Problem

On networks that use fake-IP DNS, every hostname resolves into 198.18.0.0/15, and the connection is carried on to the real host. That includes mihomo/Clash Meta and sing-box in fake-ip mode, and the egress of hosted agent machines such as Grok's. Since v0.10.0 (#656), every OAuth request is checked against oauth.DefaultIssuerPolicy, which refuses that range. So on those networks:

  • auth login and token refresh to app.basecamp.com and launchpad.37signals.com are refused before anything is sent: token endpoint "https://app.basecamp.com/oauth/tokens" resolves to an address refused by the address policy.
  • Ordinary API calls aren't checked, so an agent keeps working until its first refresh, days or weeks later, and then can't refresh or log in again.

BASECAMP_OAUTH_USE_PROXY=1 only helps when HTTPS_PROXY names a real proxy. On a transparent fake-IP network nothing is configured, so the only way out today is to run a local proxy. That also turns the address check off entirely for every OAuth request, including endpoints named by discovery documents. The workaround is weaker than what this PR does.

Fixes #849, #867, #868. All three report this same failure. #867 is a Grok Bot customer, and we have more cases in support.

Change

All of it is in internal/auth/client.go:

  • firstPartyOAuthHosts is a list compiled into the CLI: 3.basecampapi.com, api.basecamp.com, 3.basecamp.com (stored credentials name https://3.basecamp.com/oauth/tokens), app.basecamp.com and launchpad.37signals.com.
  • firstPartyOAuthPolicy: surfguard.Policy{}.Allow(198.18.0.0/15).AllowPorts(443). That's surfguard's default deny tables plus the fake-IP range, on port 443 only. It leaves out IANASpecialUse because surfguard's Allow never overrides that table, so DefaultIssuerPolicy().Allow(...) would still refuse the range.
  • isFirstPartyOAuthURL is true only for https, on the default port or :443, with a hostname exactly in the list (case-insensitive). Plain HTTP, any other port, a trailing dot and look-alike names all return false.
  • oauthTransport.direct picks the transport per request, from that request's URL: first-party URLs get the first-party transport, everything else gets the lane's own strict transport. Proxy handling is unchanged. Opt-out mode still sends a first-party request through the proxy, and protected mode still ignores the proxy (it now uses the per-host direct transport).
  • Both lanes wire it in buildLaneClient.

There is no new setting. Fake-IP networks work without any configuration.

Security review, against #656

#656 says what it protects against: "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." basecamp-sdk#810 adds: "One public host buys private space, and what arrives is credentials rather than a blind GET." Here is how this change stands against that, and against the one actor it does give something to.

1. A malicious discovery document (the #656 threat): unchanged

  • The relaxed policy depends on the hostname of each request, checked against a list compiled into the CLI. A discovery document can name any endpoint it likes, but it can't make a host it controls one of ours, so every endpoint it names on its own host gets the strict policy, exactly as before. The routing test sends issuer.example, app.basecamp.com.evil.example, evil.app.basecamp.com, a 198.18.0.1 literal and http://app.basecamp.com, and all of them land on the strict transport.
  • The lane's anchor URL plays no part. A production anchor doesn't lend the relaxation to a host the anchor's discovery document names, and neither does a base URL set by an environment variable, even one that looks like ours.
  • Redirects are judged hop by hop. A redirect from a first-party host to anything else goes back through RoundTrip with the new URL and gets the strict policy. The redirect test has a first-party endpoint redirect to https://198.18.0.1/…; the real production lane refuses that with ErrBlocked and never opens a socket. The existing checkAuthClientRedirect still refuses to replay a credential-bearing POST to any redirect target.
  • The SDK has no path around this. NewExchanger, the device flow and NewDiscoverer(…, WithIssuerHTTPClient(…)) all use the CLI's lane client when one is passed in, and the CLI passes one at every OAuth call site (auth.go, agent.go, agent_connect.go, revoke.go).

2. Addresses newly reachable, for first-party hosts only

The test checks one address from every block in surfguard's IANA special-use table. The strict policy refuses all of them. The first-party policy admits only:

  • 198.18.0.0/15, the fake-IP range;
  • AS112 and AMT anycast blocks (192.31.196.0/24, 192.52.193.0/24, 192.175.48.0/24, 2001:3::/32, 2001:4:112::/48, 2620:4f:8000::/48). These are public internet services, not internal space.
  • NAT64 well-known addresses (64:ff9b::/96) whose embedded IPv4 address is itself admitted. The strict policy refuses that prefix whole because it's in the IANA table; without the table, surfguard decodes the embedded address and judges that instead (it already did this for SIIT under both policies). That's how an IPv6-only network with DNS64 reaches a public host, so it's a side benefit. A private, loopback or metadata address inside a NAT64 address is still refused, and a test covers those.

Still refused for first-party hosts, each one tested: 10/8, 172.16/12, 192.168/16, loopback, 100.64/10 (CGNAT), 169.254.169.254 and fd00:ec2::254 (cloud metadata), 168.63.129.16 (Azure wireserver), 0.0.0.0, multicast, broadcast, ULA, link-local, and IPv4-mapped and NAT64 spellings of a private address. On top of that, only port 443 is allowed.

3. Someone who controls DNS on the machine: the one actor who gains anything

A hostile resolver or network could now point app.basecamp.com at a 198.18.x address and the CLI would connect. What they get:

  • Not the refresh token. We only relax HTTPS, and TLS still verifies the certificate against app.basecamp.com. A machine at that address can't present one, so the handshake fails before the request body, which holds the credential, is sent. The most they get is a TLS connection attempt to a benchmarking-range address on 443. That is the "blind" case #810 calls acceptable, not the "credentials arrive" case.
  • Nothing they couldn't already reach. Ordinary API calls were never address-checked, so an actor who controls DNS can already steer bearer-token API traffic to any address, including 10/8. TLS is what protects that path too.
  • The case where TLS doesn't help: a TLS-intercepting proxy whose CA the user installed through SSL_CERT_FILE. That proxy already sees every API call, and on hosted agent machines it is the egress the operator chose.

4. Compared with today's workaround

BASECAMP_OAUTH_USE_PROXY=1 with a proxy, which is what affected customers run now, turns the address check off for every OAuth request, discovered endpoints included. After this PR those customers can drop it and keep the strict check on everything except one range for five hosts we operate.

Rejected alternatives

  • An environment variable that trusts a given range, as #849 suggests. It gives no new power to anyone who can set environment variables, since =1 plus a proxy already switches the check off. But someone could set 10.0.0.0/8 or 0.0.0.0/0 to make an error go away, and configuration is exactly where #867 and #868 went wrong. A hard-coded /15 avoids both.
  • Letting surfguard's Allow override the IANA table. That would weaken IANASpecialUse for every program using surfguard, not just this one.

Tests

They're in internal/auth/client_test.go:

Checking that the tests catch a broken fix. I broke the code five ways and ran the new tests against each. All five were caught:

BreakageCaught by
Always use the strict transportrouting and redirect tests
Always use the first-party transportrouting and redirect tests
Keep IANASpecialUse in the first-party policyall three policy tests
Allow every portdial-gate test
Ignore the schemeURL test and routing test

An independent review (gpt-6.1-sol at xhigh) found two more breakages the suite didn't catch: wiring the strict policy into the lanes' first-party transport, and wiring one that allows loopback. Both now fail TestLanes_InstallTheFirstPartyPolicy. The same review found the NAT64 admission above, which the first version of this description left out. It found no way for a non-first-party host, or a private address, to get the relaxed policy.

bin/ci passes locally.

Not covered

  • IPv6 fake-IP ranges. Some fake-IP setups also hand out IPv6 placeholders. If a report shows one, it's a one-line Allow.
  • No end-to-end test through real DNS. The tests prove the two pieces separately: which transport each URL gets, and what each policy allows at connect time. No test makes a real request to app.basecamp.com that resolves into 198.18/15, because that needs control of the system resolver.
  • The SDK's error message still points at oauth.DefaultIssuerPolicy. That's a cosmetic follow-up in basecamp-sdk.
  • #866's README paragraph says BASECAMP_OAUTH_USE_PROXY=1 is how fake-IP networks get through. Once this lands, that sentence should say fake-IP DNS works without configuration.