Let OAuth reach Basecamp's own hosts through fake-IP DNS
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 loginand token refresh toapp.basecamp.comandlaunchpad.37signals.comare 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:
firstPartyOAuthHostsis a list compiled into the CLI:3.basecampapi.com,api.basecamp.com,3.basecamp.com(stored credentials namehttps://3.basecamp.com/oauth/tokens),app.basecamp.comandlaunchpad.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 outIANASpecialUsebecause surfguard'sAllownever overrides that table, soDefaultIssuerPolicy().Allow(...)would still refuse the range.isFirstPartyOAuthURLis true only forhttps, 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.directpicks 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, a198.18.0.1literal andhttp://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
RoundTripwith the new URL and gets the strict policy. The redirect test has a first-party endpoint redirect tohttps://198.18.0.1/…; the real production lane refuses that withErrBlockedand never opens a socket. The existingcheckAuthClientRedirectstill refuses to replay a credential-bearing POST to any redirect target. - The SDK has no path around this.
NewExchanger, the device flow andNewDiscoverer(…, 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
=1plus a proxy already switches the check off. But someone could set10.0.0.0/8or0.0.0.0/0to make an error go away, and configuration is exactly where #867 and #868 went wrong. A hard-coded/15avoids both. - Letting surfguard's
Allowoverride the IANA table. That would weakenIANASpecialUsefor every program using surfguard, not just this one.
Tests
They're in internal/auth/client_test.go:
TestFirstPartyOAuthPolicy_AdmitsTheFakeIPRangeAndNoInternalSpace: every IANA special-use block; checks the exact list above.TestFirstPartyOAuthPolicy_StillRefusesInternalTargets: the addresses an SSRF goes after, including their IPv6 and NAT64 spellings, and both ends of the fake-IP range.TestFirstPartyOAuthPolicy_DialGate: surfguard's connect-time check.198.18.0.1:443passes,:80doesn't, andDefaultIssuerPolicystill refuses198.18.0.1:443(the #849 failure).TestIsFirstPartyOAuthURL: every first-party host, plus scheme, port, case, trailing-dot and look-alike cases.TestOAuthTransport_PicksTheDirectTransportByRequestHost: routing with no proxy, with a proxy in protected mode, and with the opt-out.TestFirstPartyRedirectToAForeignHostIsJudgedByTheLanePolicy: uses the real production lane.TestLanes_InstallTheFirstPartyPolicy: drives the first-party transport each lane actually builds (BC5 and Launchpad, under a production and a local base URL), so wiring the wrong policy intobuildLaneClientfails. It uses only dial-time refusals, so it never touches the network.TestFirstPartyOAuthPolicy_JudgesTranslatedIPv4ByItsEmbeddedAddress: NAT64 addresses embedding public and fake-IP addresses are admitted; ones embedding private, loopback or metadata addresses are refused.
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:
| Breakage | Caught by |
|---|---|
| Always use the strict transport | routing and redirect tests |
| Always use the first-party transport | routing and redirect tests |
Keep IANASpecialUse in the first-party policy | all three policy tests |
| Allow every port | dial-gate test |
| Ignore the scheme | URL 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.comthat resolves into198.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=1is how fake-IP networks get through. Once this lands, that sentence should say fake-IP DNS works without configuration.