Skip to content

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

Merged
robzolkos merged 3 commits into
mainfrom
trust-fake-ip-for-builtin-oauth-hosts
Oct 12, 2026
Merged

robzolkos merged 3 commits into
mainfrom
trust-fake-ip-for-builtin-oauth-hosts

Conversation

@robzolkos

@robzolkos robzolkos commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

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 Dangerous mode: let your agent run commands, when only you can give it work #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

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:

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.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.
  • Name a refused credential store instead of asking for a login #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.

Since v0.10 (#656) every OAuth request is judged by DefaultIssuerPolicy,
which refuses 198.18.0.0/15. Fake-IP DNS (mihomo/Clash, sing-box, the egress
of hosted agent machines such as Grok's) resolves every name into that range,
so on those networks login and token refresh to app.basecamp.com and
launchpad.37signals.com are refused before a byte is sent, and an agent that
had been working locks out at its first refresh.

#656 guards against a discovery document steering credential-bearing
requests into private space. Our own hostnames are not something a discovery
document chooses, so requests to a fixed, compiled-in set of first-party
hosts over HTTPS on 443 now use surfguard's default deny tables plus the
fake-IP range, instead of the IANA special-use table, which surfguard's Allow
cannot override. Every other host, every redirect hop to another host, and
anything a base URL override names keep the lane's strict policy. Private,
loopback, link-local and metadata, CGNAT and multicast addresses stay refused
for first-party hosts too, and TLS still has to verify the first-party name.

Fixes #849, #867, #868.
@robzolkos
robzolkos requested a review from a team as a code owner October 12, 2026 19:48
Copilot AI balanced review requested due to automatic review settings October 12, 2026 19:48
@github-actions github-actions Bot added tests Tests (unit and e2e) auth OAuth authentication labels Oct 12, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No issues found across 2 files

You’re at about 95% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

View guided diff | Turn on auto-fix | Re-trigger cubic

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The implementation appears sound, but changing OAuth credential egress and SSRF boundaries warrants final human security review.

0 open findings

What changed in this PR

Allows OAuth to reach trusted Basecamp hosts through fake-IP DNS while retaining strict SSRF controls elsewhere.

Changes:

  • Adds a host- and port-scoped policy for 198.18.0.0/15.
  • Routes each OAuth request and redirect through the appropriate transport.
  • Adds policy, routing, redirect, and lane-wiring tests.

[!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.

File Description
internal/​auth/​client.go Adds and routes the restricted first-party OAuth policy.
internal/​auth/​client_test.go Tests admitted addresses, routing boundaries, and redirects.

🧠 Review effort: Balanced


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

The policy tests exercised firstPartyOAuthPolicy directly, so wiring the
wrong policy into buildLaneClient passed the whole suite: the strict one
(fake-IP still refused) or one admitting loopback. Drive the transport each
lane actually builds instead, with dial-time refusals that never reach the
network.

Leaving out the IANA table also lets surfguard decode NAT64 well-known
addresses and judge the embedded IPv4, so a first-party host is reachable
through DNS64 at a public or fake-IP address. Say so, and pin it with private
embedded addresses still refused.
Copilot AI balanced review requested due to automatic review settings October 12, 2026 20:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The implementation appears sound, but changing OAuth SSRF boundaries warrants final human security review.

0 open findings

🧠 Review effort: Balanced

@jeremy jeremy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Security review, including an adversarial pass. No blocking findings at 8be8c45.

Checked and holding:

  • Who gains what. The relaxed policy only gets a request to a TCP connect and TLS handshake at 198.18.0.0/15. A credential is sent only after the peer presents a certificate trusted for the exact first-party name. The actor who controls fake-IP DNS could already answer with an ordinary public address under the old policy, so this PR gives them nothing new.
  • Host matching (isFirstPartyOAuthURL) uses the same Hostname()/Port() the dialer uses. Trailing dot, IDN/punycode, IPv6 literals, subdomains, look-alike suffixes and :0443 all fall through to the strict lane. A dangling host: matches but dials 443, and token endpoints already reject userinfo.
  • Per-hop selection. Token, refresh and device POSTs don't follow redirects. GET redirects go back through oauthTransport, so each hop is judged by its own URL. A discovery document can name a first-party host, but the traffic then goes to that host under TLS, not back to whoever wrote the document.
  • Separate pools. The strict and first-party transports keep separate connection pools. No proxy is used unless BASECAMP_OAUTH_USE_PROXY=1 is set, and that path is unchanged.
  • NAT64 and anycast. NAT64 is admitted only when the IPv4 it embeds is admitted, and the local-use prefix stays refused. Leaving out IANASpecialUse also admits AS112/AMT, as the comment says, and TLS still applies there.
  • Stored and exchanged endpoints are validated before use, so an http or non-443 first-party endpoint never gets the relaxation.

One non-blocking suggestion: the argument rests on TLS verification, and no test proves the first-party transport verifies certificates. A later InsecureSkipVerify or custom TLSClientConfig on that path would leave these tests green. A test that fails a handshake against a self-signed server on the first-party transport would lock that in. Cheaper: assert the transport carries no insecure TLS config. Optionally, add isFirstPartyOAuthURL table rows for userinfo, host:, :0443, Unicode/IDN and an IPv6 literal.

go test ./internal/auth/ passes locally; CI is green.

The first-party policy admits the fake-IP range on the strength of TLS: a
connection that lands anywhere but the real host fails its handshake before
a credential is sent. Nothing tested that, so an insecure TLSClientConfig on
that transport would have left the suite green.

Drive each lane's first-party transport against a loopback server with an
untrusted certificate and require the handshake to fail on verification,
with no request reaching the server. The policy refuses loopback, so the
Manager gains a test seam for it, like clock. Also pin the URL spellings the
host match must get right: userinfo, an empty port, :0443, a homograph and
its punycode, and IPv6 literals.
Copilot AI balanced review requested due to automatic review settings October 12, 2026 22:24
@robzolkos

Copy link
Copy Markdown
Collaborator Author

@jeremy thanks, both suggestions are in 6f6abdd.

TLS verification. I took the handshake test rather than the config assertion, because a config check only sees the one spelling it inspects. TestLanes_FirstPartyTransportVerifiesCertificates drives the first-party transport each lane builds (BC5 and Launchpad) against an httptest TLS server whose certificate no root trusts. It POSTs a refresh token and requires a *tls.CertificateVerificationError with zero requests reaching the server. The first-party policy refuses loopback, so the Manager gains a firstPartyPolicy test seam, like clock. The test widens the address gate with AllowLoopback and leaves the rest of buildLaneClient as is.

I broke the code three ways and the test caught all three:

  • the policed transport with InsecureSkipVerify set: fails (request succeeds);
  • a cloned http.DefaultTransport with InsecureSkipVerify: fails;
  • the lane ignoring the seam: fails, because the error is an address refusal, not a certificate one. So the test can't pass by never dialing.

URL table. TestIsFirstPartyOAuthURL now covers:

  • userinfo: user@app.basecamp.com matches; app.basecamp.com@issuer.example doesn't;
  • empty port app.basecamp.com:: matches, and dials 443;
  • :0443: doesn't match;
  • the Cyrillic homograph аpp.basecamp.com and its punycode xn--pp-6kc.basecamp.com: don't match;
  • IPv6 literals [::ffff:198.18.0.1] and [64:ff9b::c612:1]: don't match.

bin/ci passes locally.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 issue found across 3 files (changes from recent commits).

You’re at about 99% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="internal/auth/client_test.go">

<violation number="1" location="internal/auth/client_test.go:951">
P2: The test server listens on an ephemeral port, but this policy permits only port 443; `AllowLoopback()` does not relax that port restriction, so surfguard rejects the request before TLS and the certificate-error assertion fails. Allow the test server's port in this test policy.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

t.Cleanup(srv.Close)

m := laneManager(t, "https://3.basecampapi.com")
m.firstPartyPolicy = func() surfguard.Policy { return firstPartyOAuthPolicy().AllowLoopback() }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: The test server listens on an ephemeral port, but this policy permits only port 443; AllowLoopback() does not relax that port restriction, so surfguard rejects the request before TLS and the certificate-error assertion fails. Allow the test server's port in this test policy.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/auth/client_test.go, line 951:

<comment>The test server listens on an ephemeral port, but this policy permits only port 443; `AllowLoopback()` does not relax that port restriction, so surfguard rejects the request before TLS and the certificate-error assertion fails. Allow the test server's port in this test policy.</comment>

<file context>
@@ -920,3 +929,48 @@ func TestLanes_InstallTheFirstPartyPolicy(t *testing.T) {
+	t.Cleanup(srv.Close)
+
+	m := laneManager(t, "https://3.basecampapi.com")
+	m.firstPartyPolicy = func() surfguard.Policy { return firstPartyOAuthPolicy().AllowLoopback() }
+
+	check := func(t *testing.T, client *http.Client) {
</file context>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approved

The relaxation is narrowly scoped by hostname, HTTPS, port, and request hop, with strong regression coverage.

0 open findings

🧠 Review effort: Balanced

@robzolkos
robzolkos merged commit 51f6ba0 into main Oct 12, 2026
33 checks passed
@robzolkos
robzolkos deleted the trust-fake-ip-for-builtin-oauth-hosts branch October 12, 2026 22:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth OAuth authentication tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OAuth login and token refresh refused when DNS returns fake-IP addresses (198.18.0.0/15), with no supported way to trust them

3 participants