Intercept api.anthropic.com at the network layer so sandboxed sessions carry no base-URL fingerprint #46

Merged
branderson merged 16 commits from anthropic-tls-transport into main 2026-08-01 14:59:30 -07:00
Owner

Summary generated by LLM agent

Replaces the credential proxy's transport so the sandbox stops announcing itself to the API on every request.

Why

The existing credential proxy keeps the real OAuth token out of the container by pointing Claude Code at a local forwarder with ANTHROPIC_BASE_URL=http://127.0.0.1:18300 and a placeholder ANTHROPIC_AUTH_TOKEN, and injecting the real token host-side. The security property is good; the delivery mechanism is not. Claude Code checks whether its base URL is first-party and changes its request attribution headers when it isn't, so every request from a sandboxed session carries a per-request, server-visible marker that the session is unusual. Some client features gate on the same check.

The fix is to stop telling the client anything. Intercept at the network layer instead, so the client's own configuration is byte-for-byte a stock install.

What changed

  • /etc/hosts pins api.anthropic.com to 127.0.0.1 inside the container. This wins inside getaddrinfo, so it takes effect without touching DNS.
  • The entrypoint generates a self-signed cert (CN and SAN both api.anthropic.com) at container start and trusts it via NODE_EXTRA_CA_CERTS.
  • docker/anthropic-proxy-forwarder.js gains a TLS mode: it terminates TLS on :443 and relays raw bytes to the unix socket where the unchanged host-side proxy injects the real token. It stays a byte pipe with no HTTP awareness — ALPN is pinned to exactly ["http/1.1"], because the far end cannot parse HTTP/2 frames. An h2 client gets alert 120 rather than a hang.
  • Binding :443 unprivileged needs net.ipv4.ip_unprivileged_port_start: 0, added as a compose sysctls entry.
  • bin/dejima writes a fake claudeAiOauth credential with a far-future expiry, so the client has a credential to load and never tries to refresh it. Non-secret fields (scopes, subscriptionType, rateLimitTier, clientId) are copied by name from the real file so the session looks normal; no token-bearing field is.
  • No ANTHROPIC_* environment variable is set in the container at all. That absence is what the change is for, and runtime.bats asserts it directly against /proc/1/environ.

The old transport is preserved as SANDBOX_ANTHROPIC_TRANSPORT=loopback and is still tested. SANDBOX_CREDENTIAL_PROXY=0 still works exactly as before.

Egress allowlist

api.anthropic.com is no longer unconditionally allowlisted. With the proxy on, the session reaches the API through the forwarder, so leaving the name unresolvable defeats a bypass that looks it up by DNS. With the proxy off — the kill switch, or API-key auth — DNS is the only path to the API and the name must resolve.

This is conditional rather than absolute, which was a deliberate deviation from the original plan: deleting the entry outright broke both proxy-off paths. The conditional lives in docker/fixed-domains.sh, which both dns-proxy.sh and init-firewall.sh already source specifically so the two can't drift. Putting it there rather than in EXTRA_ALLOWED_DOMAINS matters: extras are deliberately exempt from stop-dns-rebind (they're expected to be self-hosted Gitea/Loki addresses), and an earlier revision of this branch inherited that exemption for a public SaaS domain — a real regression against "the kill switch behaves exactly as before," caught in review and fixed.

Worth stating plainly, since three comments previously got it wrong: this is not a route block. platform.claude.com is still allowlisted and resolves to the same address, so the API stays reachable as an IP literal. What the change defeats is a bypass that resolves the name.

Testing

  • test/static.bats — 127 pass (up from 115).
  • test/proxy.bats — 22 pass (up from 18); the four new ones exercise the real forwarder host-side, including the TLS chain against a generated cert and the ALPN pin.
  • test/runtime.bats — 37 tests (up from 34), covering the TLS chain end-to-end in a container, the kill switch leaving /etc/hosts unpinned, and an invalid transport value aborting at startup.

test/runtime.bats was never executed. This branch was developed inside a dejima sandbox, which has no Docker, so the container-level suite could not run at any point and no image was ever built. Every other check was run; the runtime file was verified by reading each test against the real sources and by bats --count. Please run bats test/runtime.bats on a Docker-capable host before merging. Specifically unproven: the forwarder binding :443 as an unprivileged user, in-image cert generation and both FATAL abort paths, the /etc/hosts pin redirecting real glibc resolution, NODE_EXTRA_CA_CERTS reaching the session process through gosu and tini, stop-dns-rebind covering the domain again on the kill-switch path, and the full TLS → byte pipe → unix socket → host proxy chain.

Also unverifiable from this repo: whether Claude Code itself accepts the fake credential without attempting a refresh. The no-fingerprint claim rests on that, and only a live session confirms it.

Review

Seven tasks, each reviewed on completion, plus a whole-branch review that found four Important issues — all fixed here — and no Critical ones. Deferred as not worth fixing: duplicated FATAL remediation text, a redundant chmod 600, and host-side test processes leaking on a failure path (pre-existing pattern).

One thing the branch does not fix: it removes the client-side fingerprint, but the request Anthropic actually receives still originates from the host proxy's CPython http.client, with its own header casing. "Indistinguishable from a stock install" holds at the container layer, not on the wire. Closing that would mean rewriting bin/dejima-anthropic-proxy, which this work deliberately left untouched.

Refs #26 — adds three more per-test containers to the set that issue enumerates. The new ones are registered in teardown(); the interrupted-run reaping that issue asks for is still not implemented.

**Summary generated by LLM agent** Replaces the credential proxy's transport so the sandbox stops announcing itself to the API on every request. ## Why The existing credential proxy keeps the real OAuth token out of the container by pointing Claude Code at a local forwarder with `ANTHROPIC_BASE_URL=http://127.0.0.1:18300` and a placeholder `ANTHROPIC_AUTH_TOKEN`, and injecting the real token host-side. The security property is good; the delivery mechanism is not. Claude Code checks whether its base URL is first-party and changes its request attribution headers when it isn't, so every request from a sandboxed session carries a per-request, server-visible marker that the session is unusual. Some client features gate on the same check. The fix is to stop telling the client anything. Intercept at the network layer instead, so the client's own configuration is byte-for-byte a stock install. ## What changed - `/etc/hosts` pins `api.anthropic.com` to 127.0.0.1 inside the container. This wins inside `getaddrinfo`, so it takes effect without touching DNS. - The entrypoint generates a self-signed cert (CN and SAN both `api.anthropic.com`) at container start and trusts it via `NODE_EXTRA_CA_CERTS`. - `docker/anthropic-proxy-forwarder.js` gains a TLS mode: it terminates TLS on :443 and relays raw bytes to the unix socket where the unchanged host-side proxy injects the real token. It stays a byte pipe with no HTTP awareness — ALPN is pinned to exactly `["http/1.1"]`, because the far end cannot parse HTTP/2 frames. An h2 client gets alert 120 rather than a hang. - Binding :443 unprivileged needs `net.ipv4.ip_unprivileged_port_start: 0`, added as a compose `sysctls` entry. - `bin/dejima` writes a fake `claudeAiOauth` credential with a far-future expiry, so the client has a credential to load and never tries to refresh it. Non-secret fields (`scopes`, `subscriptionType`, `rateLimitTier`, `clientId`) are copied by name from the real file so the session looks normal; no token-bearing field is. - No `ANTHROPIC_*` environment variable is set in the container at all. That absence is what the change is for, and `runtime.bats` asserts it directly against `/proc/1/environ`. The old transport is preserved as `SANDBOX_ANTHROPIC_TRANSPORT=loopback` and is still tested. `SANDBOX_CREDENTIAL_PROXY=0` still works exactly as before. ## Egress allowlist `api.anthropic.com` is no longer unconditionally allowlisted. With the proxy on, the session reaches the API through the forwarder, so leaving the name unresolvable defeats a bypass that looks it up by DNS. With the proxy off — the kill switch, or API-key auth — DNS is the only path to the API and the name must resolve. This is conditional rather than absolute, which was a deliberate deviation from the original plan: deleting the entry outright broke both proxy-off paths. The conditional lives in `docker/fixed-domains.sh`, which both `dns-proxy.sh` and `init-firewall.sh` already source specifically so the two can't drift. Putting it there rather than in `EXTRA_ALLOWED_DOMAINS` matters: extras are deliberately exempt from `stop-dns-rebind` (they're expected to be self-hosted Gitea/Loki addresses), and an earlier revision of this branch inherited that exemption for a public SaaS domain — a real regression against "the kill switch behaves exactly as before," caught in review and fixed. Worth stating plainly, since three comments previously got it wrong: this is not a route block. `platform.claude.com` is still allowlisted and resolves to the same address, so the API stays reachable as an IP literal. What the change defeats is a bypass that resolves the name. ## Testing - `test/static.bats` — 127 pass (up from 115). - `test/proxy.bats` — 22 pass (up from 18); the four new ones exercise the real forwarder host-side, including the TLS chain against a generated cert and the ALPN pin. - `test/runtime.bats` — 37 tests (up from 34), covering the TLS chain end-to-end in a container, the kill switch leaving `/etc/hosts` unpinned, and an invalid transport value aborting at startup. **`test/runtime.bats` was never executed.** This branch was developed inside a dejima sandbox, which has no Docker, so the container-level suite could not run at any point and no image was ever built. Every other check was run; the runtime file was verified by reading each test against the real sources and by `bats --count`. **Please run `bats test/runtime.bats` on a Docker-capable host before merging.** Specifically unproven: the forwarder binding :443 as an unprivileged user, in-image cert generation and both FATAL abort paths, the `/etc/hosts` pin redirecting real glibc resolution, `NODE_EXTRA_CA_CERTS` reaching the session process through gosu and tini, `stop-dns-rebind` covering the domain again on the kill-switch path, and the full TLS → byte pipe → unix socket → host proxy chain. Also unverifiable from this repo: whether Claude Code itself accepts the fake credential without attempting a refresh. The no-fingerprint claim rests on that, and only a live session confirms it. ## Review Seven tasks, each reviewed on completion, plus a whole-branch review that found four Important issues — all fixed here — and no Critical ones. Deferred as not worth fixing: duplicated FATAL remediation text, a redundant `chmod 600`, and host-side test processes leaking on a failure path (pre-existing pattern). One thing the branch does not fix: it removes the client-side fingerprint, but the request Anthropic actually receives still originates from the host proxy's CPython `http.client`, with its own header casing. "Indistinguishable from a stock install" holds at the container layer, not on the wire. Closing that would mean rewriting `bin/dejima-anthropic-proxy`, which this work deliberately left untouched. Refs #26 — adds three more per-test containers to the set that issue enumerates. The new ones are registered in `teardown()`; the interrupted-run reaping that issue asks for is still not implemented.
branderson added 13 commits 2026-08-01 00:38:29 -07:00
The domain is removed from fixed-domains.sh to prevent bypass attempts when
the credential proxy is active. But when the proxy is off (kill switch or
API-key auth), DNS is the only path to the API, so entrypoint.sh allowlists
it conditionally in that case. This preserves the proxy's security guarantee
while keeping kill-switch and API-key modes working.
- The claim that a bypass of the /etc/hosts pin has "no route to the real
  API" was false: platform.claude.com is still allowlisted and resolves to
  the same address, so its IP is in the port-unrestricted allowed-domains
  set. Reword fixed-domains.sh, entrypoint.sh and runtime.bats to say what
  is actually enforced - the name does not resolve, which defeats a bypass
  that resolves by name, not a connection made to the address directly.

- Move the api.anthropic.com allowlist conditional out of entrypoint.sh
  (where it appended to EXTRA_ALLOWED_DOMAINS) and into fixed-domains.sh,
  which both dns-proxy.sh and init-firewall.sh already source. As an extra
  domain it was exempted from stop-dns-rebind and its lookup failures were
  downgraded to a warning; as a fixed domain it regains both, so the
  SANDBOX_CREDENTIAL_PROXY=0 path behaves exactly as it did before the
  branch.

- Reject an unrecognised SANDBOX_ANTHROPIC_TRANSPORT in entrypoint.sh. The
  host and container halves branch on opposite polarity, so a typo silently
  configured them for different transports and failed every API call with
  nothing naming the cause. Checked in the entrypoint rather than bin/dejima
  because every launch reaches it, including a bare docker run.

- docs/security.md presented the ANTHROPIC_UNIX_SOCKET rung as a supported,
  tested mode; nothing here implements or tests it. Describe it as depending
  on client support outside this repo, and name the kill switch as the only
  rung that surrenders the credential.

Static and proxy suites pass (127 and 22). The new runtime test is
unexecuted - Docker is unavailable here; the file parses at 37 tests.
review: correct four residual comment and test-hygiene defects
tests / fast (pull_request) Successful in 5m10s
tests / runtime (pull_request) Successful in 18m55s
10960d1e34
The scoped re-review of 15139df confirmed all five findings addressed and
raised four Minors of its own:

- fixed-domains.sh (and its static test) claimed entrypoint.sh pins the
  name in /etc/hosts unconditionally. That write only happens under the
  tls transport, so the claim was false for a loopback-transport session
  with a proxy socket. Reworded to the property that actually holds in
  both: the session reaches the API through the forwarder rather than by
  resolving the name.
- The transport-validation FATAL blamed a disagreement between the host
  and container halves of the credential proxy, but the check is
  unconditional and also fires under SANDBOX_CREDENTIAL_PROXY=0, where no
  proxy is running. Restated without asserting one is.
- The static test's unset case ran without set -u, unlike both real
  consumers, so dropping the `:-` default would have survived it.
- dejima-test-badtransport was the only per-test container not registered
  in teardown(); an abort between `docker run -d` and the inline `rm -f`
  leaked it and then the per-run network.
branderson added 2 commits 2026-08-01 14:20:11 -07:00
main stopped mounting ~/.claude as a whole (#51) and dropped both
credential masks with it (#50), which is where every conflict came from -
this branch was still written against the masked, whole-directory shape.

- bin/dejima: the mask over .credentials.json becomes a plain mount of a
  file the container would otherwise not have. Renamed to
  SANDBOX_FAKE_CREDENTIALS_FILE, created only on the tls path (the loopback
  transport authenticates with the placeholder token and needs no file at
  all), and added to cleanup()'s rm -f. The .credentials-proxy-tmp mask and
  its mkdir are gone entirely: the proxy's staging directory never crosses
  into the container now. main's proxy-off branch, which mounts the real
  credential explicitly, is kept as-is.
- docker/entrypoint.sh: keep main's staged-config copy step and its
  empty-credentials fallback ahead of this branch's transport validation and
  TLS terminator setup. The fallback's non-existence guard now covers the
  fake credential too, so the tls path is left alone.
- docs/security.md: one credential bullet, describing the TLS terminator on
  top of main's "it simply isn't mounted" framing; this branch's
  cert-pinning ladder kept; its ~/.claude read-only-overlay bullet dropped
  in favour of main's mount inventory, which supersedes it.
- test/static.bats: comments that still described the masks corrected, and
  the tls test now asserts the fake credential mount by name.

Verified: static (134), proxy, audit and project suites pass. remote passes
too - the single failure in this worktree is the suite's `-t` argv grep
matching the "anthropic-tls-transport" path, not a code change.
test: correct the container count in runtime.bats's rate-limit note
tests / fast (pull_request) Successful in 5m9s
tests / runtime (pull_request) Successful in 19m17s
61f5f67030
The three containers this branch adds took the file from thirteen to
sixteen, not fifteen. The number is what the note's arithmetic about the
unauthenticated api.github.com/meta quota rests on.
branderson added 1 commit 2026-08-01 14:30:07 -07:00
proxy: forward the client's header block verbatim instead of http.client's
tests / fast (pull_request) Successful in 6m3s
tests / runtime (pull_request) Successful in 8m30s
efc60c03e8
The branch removes the sandbox's client-side fingerprint - no ANTHROPIC_*
variables, no explicit base URL - but the request the API actually received
was still built by CPython's http.client, which put a different one back on
the wire. Measured against a raw upstream socket, three tells:

  - `Accept-Encoding: identity`, which putrequest() supplies whenever the
    caller passed no accept-encoding. No browser or Node client sends it.
  - every header name lowercased, where the client sent mixed case
    (`Content-Type`, `Anthropic-Version`, `X-Stainless-Lang`).
  - the block reordered: a dict puts every header the proxy touches
    (authorization, content-length) at the end, whatever position the
    client gave it.

A dict also collapses repeated headers to the last value, silently.

Emit the block header by header instead - putrequest(skip_host,
skip_accept_encoding) then putheader() per header in arrival order - so what
goes upstream is the client's own block with only the values that must
change changed: Authorization, Host, Content-Length, and anthropic-beta when
the OAuth value isn't already among the client's own. That last case is now
left completely untouched rather than re-joined, since re-joining normalizes
the client's spacing, which is the same class of tell as the casing. A
bodyless request no longer gains a Content-Length it never sent.

The fixture grew a `header_lines` field (names as they arrived, in order,
repeats included) because its existing `headers` map lowercases keys and so
cannot see any of this. Eight tests, five of which fail against the previous
implementation; the other three guard behavior the rewrite could have broken
(one injected token for a repeated Authorization, chunked de-framing, Host
rewritten to the upstream).

Not closed, and now stated in docs/security.md rather than left implied: the
connection is still made by CPython's ssl, so the TLS handshake (JA3/JA4)
and the absence of connection reuse distinguish it at that layer.

Verified: proxy 30/30, static 134/134, audit 84/84, project 69/69.
runtime.bats still needs a Docker-capable host.
branderson merged commit 260891b983 into main 2026-08-01 14:59:30 -07:00
Sign in to join this conversation.