From 7a38b8da230a9acffe67c145cac070b7312c573d Mon Sep 17 00:00:00 2001 From: didericis Date: Thu, 7 May 2026 23:24:39 -0400 Subject: [PATCH] docs: add research note on OAuth token exposure to claude Walks the current `docker run -e CLAUDE_CODE_OAUTH_TOKEN` flow, why claude can read the token trivially via its Bash tool, why no Linux primitive hides an env var from its own process, and why a root-owned localhost auth-injecting reverse proxy (paired with an egress allowlist) is the realistic mitigation. Documents `ANTHROPIC_BASE_URL` caveats (SSE, header passthrough, issue #36998, out-of-band traffic). --- .../oauth-token-exposure-to-claude.md | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 docs/research/oauth-token-exposure-to-claude.md diff --git a/docs/research/oauth-token-exposure-to-claude.md b/docs/research/oauth-token-exposure-to-claude.md new file mode 100644 index 0000000..4ed5d93 --- /dev/null +++ b/docs/research/oauth-token-exposure-to-claude.md @@ -0,0 +1,160 @@ +# OAuth token exposure to claude inside the bottle + +Research into whether `CLAUDE_CODE_OAUTH_TOKEN` — as currently forwarded into +each claude-bottle container — is reachable by claude itself, what (if any) +deeper-than-prompt mechanisms could hide it, and whether routing claude +through an auth-injecting proxy is viable. + +## Summary + +Yes, claude can read `CLAUDE_CODE_OAUTH_TOKEN` trivially today. There is no +Linux primitive for "this env var exists in my process but I cannot read it"; +the only credible boundary is to put the credential in a *different* process +that claude cannot read. Default Docker enforces that boundary at the kernel +level (a non-root process cannot read `/proc//environ`), so a +root-owned auth-injecting reverse proxy listening on `127.0.0.1` is a +realistic design. Claude Code's `ANTHROPIC_BASE_URL` officially supports +this routing pattern with bearer auth, with documented caveats around SSE, +header passthrough, and out-of-band outbound traffic (telemetry, npm, etc.) +that does not route through `ANTHROPIC_BASE_URL` at all. + +## How the token reaches claude today + +1. `cli.sh:526–528` — host's `CLAUDE_BOTTLE_OAUTH_TOKEN` is exported into + the launcher process as `CLAUDE_CODE_OAUTH_TOKEN`, then forwarded with + `docker run -e CLAUDE_CODE_OAUTH_TOKEN` (no `=value`, so the value + never lands on argv — good). +2. `cli.sh:603–605` — claude is launched via + `docker exec -it claude …`, which inherits the container + PID 1's env, including the token. +3. claude runs as `node` (UID 1000) with `--dangerously-skip-permissions`. + Its Bash tool can run `printenv CLAUDE_CODE_OAUTH_TOKEN`, + `cat /proc/self/environ`, `node -e 'console.log(process.env)'`, etc. + and capture the value into the conversation. + +A prompt-injection vector — a poisoned skill, a malicious string in a file +claude reads, or a hijacked MCP server — can extract the token and +exfiltrate it through any allowed outbound channel. The +`local-vs-remote-agent-execution.md` note already flags static long-lived +tokens as the biggest credential risk; this is exactly that risk, present +in the local topology today. + +## Hiding env vars "at a deeper level" + +Linux has no primitive to mark an individual env var as unreadable to the +process that holds it. Once a var is in a process's `environ`, the process +and its descendants have full access. The deeper-level lever is process +boundary, not env-var ACL: put the credential in a *different* process +that claude cannot read. + +Default Docker enforces this for you. The kernel's `ptrace_may_access` +check rejects `/proc//environ` reads when the caller's UID/GID don't +match the target's and the caller lacks `CAP_SYS_PTRACE` or `CAP_PERFMON`. +A `node`-uid claude process attempting to read a root-owned proxy's +environ gets `EACCES`. Escape hatches are explicit and not used by +claude-bottle: `--cap-add=SYS_PTRACE`, `--cap-add=PERFMON`, +`--privileged`. Yama `ptrace_scope` is irrelevant here because it only +relaxes the *same-UID* relationship check; the cross-UID UID-match +requirement still blocks the read. + +The `apiKeyHelper` setting in claude-code is **not** a boundary. The +helper is invoked by claude's own process, so claude can just call it via +Bash and capture stdout. Same trust domain. + +The only credible designs: + +- **Header-injecting reverse proxy** — claude points at a localhost URL; + proxy holds the credential; proxy adds `Authorization: Bearer` and + forwards. (See next section.) +- **Network namespace + outbound proxy** — claude runs with + `--network none` and a unix-socket proxy that holds the credential and + enforces an egress allowlist. Anthropic's secure-deployment docs + describe this pattern; the existing research note on remote agents + recommends adding it locally first as the highest-leverage change. +- **Don't ship the OAuth token at all** — fall back to per-session login + or short-lived tokens. Operationally heavier, and the long-lived OAuth + token is the chosen design here precisely because it's portable across + hosts (Keychain on macOS, file on Linux). + +## Proxy auth: viable, with caveats + +Pattern: + +- Run a small reverse proxy as **root** inside the container, listening on + `127.0.0.1:N` (or a root-owned unix socket with `SO_PEERCRED` checks). +- Set `ANTHROPIC_BASE_URL=http://127.0.0.1:N` (or the socket path) in + claude's env. Claude as `node` only sees the URL, not the token. +- The proxy injects `Authorization: Bearer $TOKEN` and forwards to + `https://api.anthropic.com`. +- Token lives only in the root proxy's env; node-uid claude cannot read + `/proc//environ` (kernel-enforced). + +`ANTHROPIC_BASE_URL` is documented as routing for proxies/gateways, not +just Bedrock/Vertex, and works alongside bearer auth. Confirmed gotchas: + +- **SSE streaming**: proxy must not buffer responses (nginx + `proxy_buffering off`, or use a streaming-aware proxy). Claude Code + uses SSE only — no websockets. +- **Forward `anthropic-version`, `anthropic-beta`, and + `X-Claude-Code-Session-Id` untouched** — stripping them breaks tool + use / extended thinking / session aggregation. +- **GitHub issue [#36998](https://github.com/anthropics/claude-code/issues/36998)**: + interactive mode historically bypassed `ANTHROPIC_BASE_URL` for some + startup calls (auth validation / org lookup), connecting directly to + `api.anthropic.com`. Marked closed but verify with `tcpdump` or + `strace -e connect` against the pinned 2.1.126 build before trusting + the isolation. +- **Tool search** (`ENABLE_TOOL_SEARCH`): disabled by default when + `ANTHROPIC_BASE_URL` is non-Anthropic; re-enable explicitly if needed. +- **Out-of-band outbound traffic** is the weak link. None of these route + through `ANTHROPIC_BASE_URL`: + - `statsig.anthropic.com` — telemetry + (disable: `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`, + `DISABLE_TELEMETRY=1`) + - Sentry error reporting (disable: `DISABLE_ERROR_REPORTING=1`) + - `registry.npmjs.org`, `github.com`, `release-assets.githubusercontent.com` + — MCP installs and autoupdater + - `pypi.org`, `bun.sh` — if the Bash tool installs Python or Bun + packages during a session + + A hijacked claude could exfiltrate the captured token (or any other + data) through these channels even with the proxy in place. Pair the + proxy with an explicit egress allowlist (iptables / Docker network + policy) for the full benefit. +- **Token refresh**: `claude setup-token` issues a ~1-year token with no + client-side refresh, so a static proxy value is fine. +- **No request signing / anti-replay** on the Messages API; header + rewriting is safe. +- **`--bare` mode** does not read `CLAUDE_CODE_OAUTH_TOKEN` at all (only + `ANTHROPIC_API_KEY`). Not relevant to the interactive flow claude-bottle + ships, but worth noting if `--bare` is ever wired in. + +## Recommended path forward + +In priority order: + +1. **`--network none` + a localhost (or unix-socket) auth-injecting + proxy** that holds the token. Highest-leverage change: credential + isolation **and** egress containment in one pass. Aligns with the + recommendation already in `local-vs-remote-agent-execution.md`. +2. Layer in `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` plus an explicit + egress allowlist (api.anthropic.com only, plus the per-agent set of + MCP / git / package-registry hosts) so a hijacked claude can't + exfiltrate through statsig / Sentry / npm. + +The current `docker run -e CLAUDE_CODE_OAUTH_TOKEN` pattern is fine for +argv hygiene on the host, but inside the container the token is fully +exposed to claude. The proxy pattern moves it across a real +kernel-enforced boundary. + +## Sources + +- [Authentication — Claude Code Docs](https://code.claude.com/docs/en/authentication) +- [LLM gateway configuration — Claude Code Docs](https://code.claude.com/docs/en/llm-gateway) +- [Claude Code Environment Variables](https://code.claude.com/docs/en/env-vars) +- [GitHub issue #36998 — Interactive mode ignores ANTHROPIC_BASE_URL](https://github.com/anthropics/claude-code/issues/36998) +- [GitHub issue #11587 — Auth conflict: CLAUDE_CODE_OAUTH_TOKEN and apiKeyHelper](https://github.com/anthropics/claude-code/issues/11587) +- [proc_pid_environ(5) Linux manual page](https://man7.org/linux/man-pages/man5/proc_pid_environ.5.html) +- [Documenting ptrace access mode checking — LWN.net](https://lwn.net/Articles/692203/) +- [StepSecurity — Claude Code Action outbound network analysis](https://www.stepsecurity.io/blog/anthropics-claude-code-action-security-how-to-secure-claude-code-in-github-actions-with-harden-runner) +- [Manage API key environment variables — Claude Help Center](https://support.claude.com/en/articles/12304248-manage-api-key-environment-variables-in-claude-code)