feat: credential injection via --inject-header#1
Open
pjcdawkins wants to merge 17 commits into
Open
Conversation
dfa3432 to
f6e43c5
Compare
Turn curb's CONNECT proxy from a passthrough egress filter into an egress
proxy that keeps real credentials out of the sandbox. For selected hosts the
proxy terminates TLS with a per-run CA, injects a credential header, and
forwards the request; the sandbox holds only a placeholder. A credential is
bound to its destination and never stapled to another host.
- proxy/inject.go: per-run EC CA (leaf per host) and a host->{header,value}
Injector. Handler gains an optional *Injector; nil keeps passthrough.
- CLI: --inject-bearer HOST=SOURCE and --inject-header HOST=HEADER=SOURCE
(Linux). SOURCE is @ENV_VAR (kept out of argv), @?ENV_VAR (optional, inject
only if set), or a literal. Also settable via CURB_INJECT_BEARER/
CURB_INJECT_HEADER and the inject-bearer:/inject-header: config/profile keys,
merged additively.
- CA delivery: a combined bundle (system roots + per-run CA) is written to the
temp dir; the standard CA env vars point at it. The CA key and tokens stay in
the parent, never serialized to the child.
- env markers: VAR=?value sets an env var only when set on the host. With @?,
this seals a credential conditionally.
- claude profile: seals ANTHROPIC_API_KEY out of Claude Code as the x-api-key
header when set on the host; a no-op for OAuth users.
- --dry-run reports the bindings (host + header names + CA-trust env vars)
without printing the token.
- Tests: unit (binding correctness, no cross-stapling, placeholder override)
and end-to-end (real binary, local HTTPS server, sandbox trusts the CA).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
f6e43c5 to
7564dbf
Compare
curb advertised its SOCKS proxy via ALL_PROXY/all_proxy unconditionally, overwriting any user value. Set it only when not already provided, mirroring the existing NO_PROXY precedence, so an embedder can pass --env ALL_PROXY= to suppress the SOCKS env for HTTP-only workloads. This matters for clients that eagerly build a transport for ALL_PROXY at construction (httpx raises without the socks extra); ssh routing is unaffected (it uses its own config, not ALL_PROXY). HTTP/HTTPS continue to prefer HTTP_PROXY/HTTPS_PROXY. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds host-bound credential injection to curb’s network proxy on Linux: for selected hosts, curb terminates TLS with a per-run CA, injects a configured header (e.g. Authorization: Bearer …), and keeps real credentials out of the sandboxed process. It also wires the feature through CLI/env/config/profile inputs, updates help/docs, and adds unit + integration coverage.
Changes:
- Add a per-run CA +
Injectorto the HTTP CONNECT proxy for per-host TLS termination and header injection. - Add
--inject-bearer/--inject-headerwith env/config/profile plumbing, plus conditional env-set marker support and improved proxy env precedence behavior. - Add unit and end-to-end tests and update documentation (README + configuration/comparison docs + Claude profile).
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| sandbox/skill.go | Adds SKILL.md messaging when credential injection is active. |
| sandbox/proxy_handler.go | Constructs proxy.Injector and binds per-host injections into the proxy handler. |
| sandbox/plan.go | Adds plan fields for CA/injection bindings; parses inject specs; resolves secrets; writes CA bundle; adds conditional env-set marker + ALL_PROXY precedence changes; prints injection info in dry-run. |
| sandbox/plan_test.go | Adds tests for conditional env-set and ALL_PROXY precedence; minor formatting change. |
| sandbox/plan_linux.go | Parses injection bindings early and auto-allows injection hosts; resolves injection after env resolution. |
| sandbox/plan_darwin.go | Rejects injection flags on macOS. |
| sandbox/inject_test.go | Unit tests for inject spec parsing, secret source resolution, and CA bundle writing. |
| sandbox/inject_integration_test.go | Linux end-to-end tests running the real curb binary to validate injection + placeholder overwrite. |
| sandbox/capabilities_linux_test.go | Verifies --dry-run reports injection without leaking token values. |
| README.md | Documents basic usage for --inject-bearer and adds CLI reference entries. |
| proxy/inject.go | Implements per-run CA, injector bindings, CONNECT TLS termination, and decrypted HTTP forwarding with injection. |
| proxy/inject_test.go | Unit tests verifying correct binding behavior and placeholder overwrite (including arbitrary headers). |
| proxy/handler.go | Adds optional Injector hook for CONNECT handling. |
| docs/configuration.md | Adds configuration documentation for injection flags, sources, CA env vars, and the Claude profile seal. |
| docs/comparison-zerobox.md | Updates comparison to reflect opt-in per-host TLS termination/injection. |
| config/profiles/claude.yaml | Seals ANTHROPIC_API_KEY via conditional placeholder + optional header injection. |
| config/profile_test.go | Tests that the Claude profile merge adds injection + placeholder and removes passthrough of the real key. |
| config/configfile.go | Adds inject-bearer / inject-header keys and validation; merges them into runtime config. |
| config/configfile_test.go | Tests parsing/validation and list-prepend merge semantics for injection fields. |
| config/config.go | Adds config fields, flag parsing, and env merging for injection lists. |
| config/config_test.go | Adds tests for env additive merge behavior for injection lists. |
| cmd/root.go | Adds injection example to root help and registers new flags. |
| cmd/help.go | Includes injection flags in the “Network” help group ordering. |
| CLAUDE.md | Updates operational notes for ALL_PROXY precedence and injection behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Address Copilot review on PR #1: - Reject wildcard hosts (including "*") in --inject-bearer/--inject-header. A wildcard would broaden the domain allowlist while never matching an injection binding, so the credential would silently not be injected. - Normalize injection hosts to lowercase with no trailing dot, both when parsing the bindings and when looking them up against CONNECT targets, so a binding for api.example.com matches API.EXAMPLE.COM and api.example.com. - Set tls.Certificate.Leaf to the parsed leaf certificate instead of the CA certificate. - Strip hop-by-hop headers (Proxy-Authorization, Connection, etc.) on the decrypted injected request before forwarding upstream, via a shared stripHopByHop helper. Strip before setting injected headers so a client cannot drop an injected header by naming it in Connection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address Copilot re-review on PR #1: - Fail credential injection with a clear error when no system CA bundle can be located or read, instead of silently delivering a bundle with only the per-run CA (which would break TLS trust for every other host). - Validate injection header names against the RFC 7230 token charset at parse time, so an invalid name fails clearly instead of as a runtime 502 from net/http on the upstream round-trip. - Give the injector's upstream transport the same dial and TLS-handshake timeouts as passthrough traffic, so an injected request cannot hang indefinitely. - Reject wildcard injection hosts at config-file load time (previously only the plan builder rejected them, giving a CLI-attributed error for a config-file field). Factored the validation into a shared policy.ValidateInjectHost used by the CLI, config file, and plan. - Reword the SKILL.md note from "auth headers" to "credential headers" since --inject-header injects arbitrary headers (e.g. x-api-key). - Document that injected hosts are served over HTTP/1.1 only (no h2 ALPN), so HTTP/2-only clients may not work for those hosts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address Copilot re-review on PR #1: - Validate the injection header name at config-file load time, not just in CLI parsing, so an invalid token (spaces, ':') fails early with an inject-header[i] message instead of later during plan building. Moved the RFC 7230 token check into policy.ValidHeaderName, shared by both paths. - Set req.Host (not just req.URL.Host) on the decrypted upstream request so the Host header matches the bound host rather than whatever the client sent. Drop the default :443 so the header looks like a direct request. - docs: list inject-header among the additively-merged config list fields. - docs/README: stop describing --inject-bearer as literal sugar for --inject-header HOST=Authorization=Bearer … (--inject-header has no Bearer prefix segment); describe the behavior instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
--inject-bearer is not a literal sugar form of --inject-header HOST=Authorization=Bearer … (--inject-header has no Bearer prefix segment); describe the mechanism instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework --inject-header to placeholder substitution bound to a host, and remove --inject-bearer. curb generates a stable per-variable placeholder, seals the sandbox's copy of the variable to it, reads the real value from the host's variable, and the proxy (terminating TLS for the named host only) replaces the placeholder wherever the client placed it among the request headers. This is header-agnostic: no auth-scheme knowledge is configured, so Authorization: Bearer and x-api-key both work with one binding. It keeps curb decoupled from process-internal wire details, which matters for a fast-changing API surface and for end users writing config. Syntax changes: - --inject-header is now ENV_VAR=HOST (was HOST=HEADER=SOURCE). - --inject-bearer is removed; scan-replace handles the bearer case. - The @/@? source markers are gone. The left side is always an env var name; injection is opt-in and skipped silently when the variable is unset. No literal sources (they could not carry a placeholder into the sandbox). - Var-first ordering leaves room for a future comma-separated host list. The claude profile collapses two coordinated lines (inject + env seal) into one: ANTHROPIC_API_KEY=api.anthropic.com. curb seals the variable itself. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Prevent placeholder prefix collisions: wrap the env var as curb-sealed-<VAR>-placeholder. Because a valid env var name cannot contain "-", no placeholder can be a prefix of another (e.g. TOK vs TOK2), so the substring substitution in replaceInHeaders cannot corrupt one credential's placeholder while replacing another bound to the same host. - Reject "=" in domains/injection hosts. Previously --inject-header A=b.com=x parsed into a host "b.com=x" that no CONNECT target matches, silently binding nothing instead of reporting the typo. - Remove the "VAR=?value" conditional env-set feature (added earlier in this branch for the old sealing approach). Credential injection now seals its variable automatically, so it had no remaining user; drop the code, its test, and its docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Trim the verbose comments added for credential injection and remove the "sealing" terminology in favor of "credential injection" throughout. - claude.yaml: drop the long credential-sealing block; keep one short comment on the inject-header block. - plan.go: condense the injectSpec/parseInjectHeader/injectPlaceholder comments; rename the `seals` variable to `placeholders`. - Rename the placeholder prefix curb-sealed- to curb-inject- (updating the integration test and the configuration.md example, which also had the env-var/-placeholder ordering wrong). - CLAUDE.md, README.md, profile_test.go, comparison-zerobox.md, configuration.md: reword the remaining "seal" prose. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consolidate three copies of host normalization into policy.NormalizeHost, shared by the domain matcher, the injection-host validator, and the proxy's binding lookup so they agree on what counts as the same host. Consolidate the ENV_VAR=HOST parse-and-validate (previously duplicated in config/configfile.go and sandbox/plan.go) into policy.ParseInjectHeader; each caller wraps the error with its own flag/field prefix. Drop a redundant req.WithContext(context.Background()) on the proxy forward path: ReadRequest-produced requests already carry a Background context, so the wrap allocated a shallow request copy per request for no effect. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ensure --inject-header does not add domains to the allowlist. Enable active injection planning on macOS while keeping inactive bindings as no-ops. Document the explicit allowlist requirement. Written by Codex.
Inject credentials on all egress paths and harden trust delivery, from the high-effort branch review. - Wire injection into the SOCKS5 path and refuse plain HTTP. Extract the shared TLS-terminate-and-serve logic onto Injector.Serve, consulted by both the HTTP CONNECT and SOCKS5 proxies via a shared buildInjector. A bound host reached over plain HTTP is refused (502) rather than forwarded, since injecting over cleartext would expose the credential. - Extend a user-provided SSL_CERT_FILE as the CA-bundle base instead of discarding it, so a custom trust store still applies to non-injected hosts. - Sign leaf certs without holding the CA lock (RWMutex double-check), so a cache hit for one host no longer blocks behind a sign for another. - Make Injector.binding nil-safe so the three call sites need no separate guard. - Document the custom ANTHROPIC_BASE_URL gateway case and the HTTPS-only / SOCKS5 injection behavior. - Remove a dead empty-placeholder guard, drop the repetitive env-var-name error wording, and rename a leftover "Seals" test. Per-port termination is left as-is by design (a bound host is expected to be HTTPS). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the ENV_VAR=HOST separator with ENV_VAR:HOST[:PORT][,HOST...]. The "=" read as assignment and collided with --env KEY=VALUE, where the right side genuinely is a value; in inject-header it is a destination. A colon reads as a binding and parses unambiguously because an env var name cannot contain one. The right side is now a comma-separated list of HOST[:PORT] targets, where HOST is a hostname or IP literal and PORT defaults to 443: - Host list: one credential valid for several destinations. - Custom port: bindings are keyed by host:port, so the credential is injected only on that exact destination; a connection to the same host on another port is relayed unchanged. - IP targets: authorized against --ips (hostnames against --domains). Bindings are carried as map[policy.InjectTarget][]proxy.Injection through the plan, so the structured target is never serialized to a string and re-parsed. This is a pre-1.0 interface change with no backwards compatibility. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Normalize the host once at the start of Injector.Serve so the minted leaf cert, SNI, and upstream Host header use the same canonical form the binding matched on (a CONNECT like API.EXAMPLE.COM. no longer mints a cert for the unnormalized name). - Attribute inject-host validation errors to "injection host" instead of "--domains" so a bad --inject-header target reports the right source. Reorder the wildcard check ahead of pattern validation for a clearer message. - Reword the SKILL.md credential-injection note: the credential is substituted into requests the agent already sends (placeholder in an env var), not added to requests automatically. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Preserve pass-through SSL_CERT_FILE as the base for injected CA bundles. Treat ALL_PROXY and all_proxy as one override so either casing suppresses SOCKS env injection. Bracket IPv6 authorities when injected HTTPS uses the default port. Written by Codex.
Preserve comma-separated inject target lists by avoiding pflag StringSlice splitting. Let exact env passthrough disable injection for that variable while keeping wildcard passthrough protected. Extend existing CA bundle env values before installing injection trust, and allow degraded plans when inject bindings are inactive. Written by Codex.
Extend the per-run injection CA lifetime and renew cached leaf certificates before they expire. Add tests covering long-lived generated certs and cache renewal. Generated by Codex.
Comment on lines
+739
to
+747
| func (p *SandboxPlan) caBundleBase(name string) string { | ||
| if base := p.EnvSet[name]; base != "" { | ||
| return base | ||
| } | ||
| if base, _ := p.passthroughEnvValue(name); base != "" { | ||
| return base | ||
| } | ||
| return "" | ||
| } |
Comment on lines
+115
to
+120
| // A bare IP literal (no port): ParseAddr accepts an unbracketed IPv6 | ||
| // address, which SplitHostPort below would reject. | ||
| if addr, err := netip.ParseAddr(item); err == nil { | ||
| return InjectTarget{Host: addr.String(), Port: "443", IsIP: true}, nil | ||
| } | ||
| host, port := item, "443" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Turn curb's CONNECT proxy from a passthrough egress filter into a proxy that keeps real credentials out of the sandbox. For a selected destination the proxy terminates TLS with a per-run CA and substitutes a placeholder with the real credential in the request headers; the sandboxed process only ever holds the placeholder. A credential is bound to its destinations and is never sent to any other host.
How it works
--inject-header ENV_VAR:HOST[:PORT][,HOST...]binds the credential inENV_VARto one or more destinations:curb-inject-<VAR>-placeholder), sets the sandbox's copy ofENV_VARto it, and reads the real value from the host'sENV_VAR.Authorization: Bearer …andx-api-key: …both work with one binding. It keeps curb decoupled from process-internal wire details.HOST[:PORT], whereHOSTis a hostname or IP literal andPORTdefaults to 443. Bindings are keyed byhost:port, so the credential is injected only on that exact destination — a connection to the same host on a different port is relayed unchanged, with no credential.GH_TOKEN:api.github.com,uploads.github.com).ENV_VARis unset or empty on the host, the binding is skipped silently (so theclaudeprofile is a no-op for OAuth users).Each destination must be in the network allowlist — a hostname via
--domains, an IP via--ips— and the proxy must be active (incompatible with--unrestricted-net);--inject-headerdoes not add the destination to the allowlist itself.Syntax
The separator is a colon: it reads as a binding (the variable is bound to a destination, not assigned a value) and parses unambiguously because an env var name can never contain one. In a config file or profile, write the list item without a space after the colon (
- GH_TOKEN:api.github.com); a space makes YAML parse it as a mapping, which curb rejects.What's added
host:port→placeholder→valueInjector, plus a host-level set for the plain-HTTP refusal.Handlergains an optional*Injector; when nil, behavior is unchanged (passthrough).--inject-header ENV_VAR:HOST[:PORT][,HOST...], also settable viaCURB_INJECT_HEADERand theinject-header:config/profile key, merged additively.map[policy.InjectTarget][]proxy.Injectionthrough the plan, so the structured target is never serialized to a string and re-parsed.SSL_CERT_FILE,CURL_CA_BUNDLE,GIT_SSL_CAINFO,REQUESTS_CA_BUNDLE,NODE_EXTRA_CA_CERTS) point at it. The CA key and the real credential stay in the parent — never serialized to the child.inject-header: ANTHROPIC_API_KEY:api.anthropic.comkeeps the API key out of the sandboxed process; a no-op for OAuth users.:443is dropped so the common case reads as a bare host.Safety properties
host:portit was provisioned for; any other destination the program connects to is relayed without it.curb-inject-<VAR>-placeholder. Since an env var name cannot contain-, no placeholder is a prefix of another (e.g.TOKvsTOK2), so the substring substitution cannot corrupt one credential's placeholder while replacing another bound to the same destination.policy.NormalizeHost, with IP-literal canonicalization for the proxy lookup) across the domain matcher, the injection-target parser, and the binding lookup, so they agree on what counts as the same destination.Tests
--ips, and that a client-supplied placeholder is overwritten with the real value regardless of which header carries it.curlagainst a local Go HTTPS server on a custom port, asserting the credential is injected, the sandbox trusts the per-run CA, and the placeholder is overwritten.Known gaps
ANTHROPIC_BASE_URLis not covered by the profile binding (it targetsapi.anthropic.com); bind the key to the gateway host as well, or pass it through with--env.🤖 Generated with Claude Code