Files
bot-bottle/docs/research/oauth-token-exposure-to-claude.md
T
didericis 7a38b8da23 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).
2026-05-07 23:24:39 -04:00

161 lines
8.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)