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).
This commit is contained in:
2026-05-07 23:24:39 -04:00
parent adaaa2c0e8
commit 7a38b8da23
@@ -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/<root-pid>/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:526528` — 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:603605` — claude is launched via
`docker exec -it <container> 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/<pid>/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/<root-pid>/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)