From 7564dbf3f66de80706b4190bdc1ca83f8f04536f Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 15 Jun 2026 08:25:25 +0100 Subject: [PATCH 01/27] feat: host-bound credential injection (--inject-bearer/--inject-header) 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 --- CLAUDE.md | 1 + README.md | 9 ++ cmd/help.go | 2 +- cmd/root.go | 5 +- config/config.go | 19 ++- config/config_test.go | 24 +++ config/configfile.go | 22 +++ config/configfile_test.go | 82 ++++++++-- config/profile_test.go | 18 +++ config/profiles/claude.yaml | 16 +- docs/comparison-zerobox.md | 90 ++++++----- docs/configuration.md | 69 ++++++++- proxy/handler.go | 13 +- proxy/inject.go | 221 +++++++++++++++++++++++++++ proxy/inject_test.go | 238 +++++++++++++++++++++++++++++ sandbox/capabilities_linux_test.go | 28 ++++ sandbox/inject_integration_test.go | 209 +++++++++++++++++++++++++ sandbox/inject_test.go | 110 +++++++++++++ sandbox/plan.go | 194 +++++++++++++++++++++++ sandbox/plan_darwin.go | 3 + sandbox/plan_linux.go | 18 +++ sandbox/plan_test.go | 18 ++- sandbox/proxy_handler.go | 12 +- sandbox/skill.go | 8 + 24 files changed, 1371 insertions(+), 58 deletions(-) create mode 100644 proxy/inject.go create mode 100644 proxy/inject_test.go create mode 100644 sandbox/inject_integration_test.go create mode 100644 sandbox/inject_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 2abe523..dcea204 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ go test ./sandbox/ -run TestCurb_FS_ -v # run a subset - When the proxy is enabled, `NO_PROXY=localhost,127.0.0.1,::1` is set by default so sandbox-internal servers are reachable. `--host-loopback` suppresses this and forwards localhost traffic through the proxy to the host. User-provided `--env NO_PROXY=...` takes precedence. - `--ips` allows connections to specific IP addresses or CIDR ranges. Works alongside or independently of `--domains`. IP-matched connections bypass domain filtering (forwarded directly on any port via SOCKS5 or CONNECT). In IPs-only mode (no `--domains`), DNS queries are not intercepted. - `--unrestricted-net` skips network namespace creation entirely, allowing unrestricted network access while keeping FS sandboxing. Cannot be combined with `--domains` or `--ips`. +- `--inject-bearer HOST=SOURCE` and `--inject-header HOST=HEADER=SOURCE` (Linux) terminate TLS for `HOST` only and set a credential header (`Authorization: Bearer `, or any header e.g. `x-api-key`), so the token never enters the sandbox. `--inject-bearer` is sugar for `--inject-header HOST=Authorization=Bearer …`. The proxy holds the credential and a per-run CA; curb delivers a combined CA bundle (system roots + per-run CA) to the child via the standard CA env vars. `SOURCE` is `@ENV_VAR` (kept out of argv) or a literal. Settable via the flags, `CURB_INJECT_BEARER`/`CURB_INJECT_HEADER`, or the `inject-bearer:`/`inject-header:` config-file/profile keys (all merged additively); the `@ENV_VAR` form resolves at run time regardless of source. The CA key and token are parent-only — never serialized to the child. `@?VAR` is an optional source (inject only if set), and `VAR=?value` sets an env var only if it is set on the host; the `claude` profile uses both to seal `ANTHROPIC_API_KEY` out of Claude Code when present while staying a no-op for OAuth users. - `--domains` validates input: rejects URLs (suggests bare domain), IP addresses (suggests `--ips`), invalid characters, and malformed wildcards. `--ips` validates that values parse as IP addresses or CIDR prefixes. - `!` prefix in list flags removes from defaults AND actively denies via overmount when the path is under an allowed parent. `--read '!/path'` hides (tmpfs/dev-null), `--write '!/path'` makes read-only, `--exec '!/path'` makes noexec. `!*` clears all defaults (not deny-all). `\!` escapes literal `!`. Sub-path denials require mount NS; Landlock-only mode warns. - HOME defaults to a private tmpDir. `--env HOME` passes through the host HOME; `--env HOME=/path` sets it explicitly. `~` and `$HOME` in config/profile paths both resolve to the **host** home (the shell's view), not the sandbox HOME — they are host-side shorthands. When sandbox HOME differs from host HOME and a path uses `~`, curb warns that the sandboxed program cannot reach those paths via its own `$HOME`. Built-in profiles include `HOME` in their env passthrough so sandbox HOME = host HOME and `~` paths align. See `docs/configuration.md` for full details. diff --git a/README.md b/README.md index 39a423a..e1640c8 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,13 @@ Pass through a specific environment variable: curb --env 'DATABASE_URL' -- ./migrate.sh ``` +Inject a credential the sandboxed process never sees — the token stays in +curb's proxy, which adds it to requests bound for the named host: + +``` +GH_TOKEN=ghp_xxx curb --inject-bearer 'api.github.com=@GH_TOKEN' -- gh api user +``` + ## CLI reference ### Network @@ -162,6 +169,8 @@ curb --env 'DATABASE_URL' -- ./migrate.sh |------|---------|-------------| | `--domains` | `CURB_DOMAINS` | Allowed domain patterns (comma-separated). `*.example.com` for subdomains, `*` to allow all. | | `--ips` | `CURB_IPS` | Allowed IP addresses or CIDR ranges (e.g. `10.0.0.1`, `192.168.0.0/16`, `::1`). | +| `--inject-bearer` | `CURB_INJECT_BEARER` | Terminate TLS for a host and inject `Authorization: Bearer ` on the way out, so the token never enters the sandbox. `HOST=@ENV_VAR` reads the token from an env var (kept out of argv); `HOST=literal` also works but exposes it in process arguments. Repeatable; also settable via the `inject-bearer:` config-file/profile key. Linux only. | +| `--inject-header` | `CURB_INJECT_HEADER` | Like `--inject-bearer`, but injects an arbitrary request header: `HOST=HEADER=SOURCE` (e.g. `api.example.com=x-api-key=@KEY`). `--inject-bearer HOST=SOURCE` is sugar for `HOST=Authorization=Bearer …`. Repeatable; also a config-file/profile key. Linux only. | | `--host-loopback` | `CURB_HOST_LOOPBACK` | Forward localhost/127.0.0.1 traffic to the host loopback. Cannot combine with `--unrestricted-net`. | | `--unrestricted-net` | `CURB_UNRESTRICTED_NET` | Skip network filtering entirely. Cannot combine with `--domains`, `--ips`, or `--host-loopback`. | diff --git a/cmd/help.go b/cmd/help.go index 3faf66c..2557e19 100644 --- a/cmd/help.go +++ b/cmd/help.go @@ -20,7 +20,7 @@ type flagGroup struct { // flagGroups defines the order and grouping of flags in help output. var flagGroups = []flagGroup{ {"Filesystem", []string{"read", "write"}}, - {"Network", []string{"domains", "ips", "unrestricted-net", "host-loopback", "allow-unix-sockets"}}, + {"Network", []string{"domains", "ips", "inject-bearer", "inject-header", "unrestricted-net", "host-loopback", "allow-unix-sockets"}}, {"Executables", []string{"exec"}}, {"Environment", []string{"env"}}, {"Profiles & Config", []string{"profiles", "auto", "config-file"}}, diff --git a/cmd/root.go b/cmd/root.go index 32e2bbb..4662089 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,7 +27,8 @@ Use -- before the command when it has its own flags.`, curb --domains example.com -- curl https://example.com # allow specific domains curb -p node -w . -- npm install # profile + writable cwd curb -a cargo build # auto-select profile - curb --dry-run -p node -- npm install # preview sandbox plan`, + curb --dry-run -p node -- npm install # preview sandbox plan + curb --inject-bearer api.github.com=@TOK -- gh pr list # inject a token, kept out of the sandbox`, Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { @@ -329,6 +330,8 @@ func registerFlags(cmd *cobra.Command) { f.StringSlice("ips", nil, "allowed IP/CIDR ranges (e.g. 10.0.0.1, 192.168.0.0/16)") f.Bool("unrestricted-net", false, "skip network restrictions entirely") f.Bool("host-loopback", false, "forward localhost traffic to host loopback") + f.StringSlice("inject-bearer", nil, "terminate TLS for HOST and inject 'Authorization: Bearer ' (HOST=@ENV_VAR reads the token from an env var, keeping it out of argv)") + f.StringSlice("inject-header", nil, "terminate TLS for HOST and inject an arbitrary request header (HOST=HEADER=@ENV_VAR, e.g. api.example.com=x-api-key=@KEY)") // Executables. f.StringSlice("exec", nil, "allowed executables (default: invoked command only)") diff --git a/config/config.go b/config/config.go index d4b1117..be218e4 100644 --- a/config/config.go +++ b/config/config.go @@ -21,6 +21,8 @@ type Config struct { EnvSet []string EnvPassthroughAll bool AllowedIPs []string + InjectBearer []string + InjectHeader []string UnrestrictedNet bool NoFSRestrict bool NoExecRestrict bool @@ -32,7 +34,7 @@ type Config struct { Quiet bool DryRun bool Auto bool - ConfigFilePaths []string + ConfigFilePaths []string Command []string } @@ -68,6 +70,14 @@ func FromFlags(cmd *cobra.Command) (*Config, error) { if err != nil { return nil, err } + injectBearer, err := flags.GetStringSlice("inject-bearer") + if err != nil { + return nil, err + } + injectHeader, err := flags.GetStringSlice("inject-header") + if err != nil { + return nil, err + } if len(allow) > 0 { if err := policy.ValidateDomains(allow); err != nil { return nil, err @@ -123,6 +133,8 @@ func FromFlags(cmd *cobra.Command) (*Config, error) { cfg := &Config{ AllowedDomains: allow, AllowedIPs: ips, + InjectBearer: injectBearer, + InjectHeader: injectHeader, UnrestrictedNet: unrestrictedNet, ROPaths: ro, RWPaths: rw, @@ -137,7 +149,7 @@ func FromFlags(cmd *cobra.Command) (*Config, error) { Quiet: quiet, DryRun: dryRun, Auto: auto, - Command: cmd.Flags().Args(), + Command: cmd.Flags().Args(), } // Wildcard handling: '*' in list flags sets the corresponding escape hatch. @@ -169,6 +181,8 @@ func MergeEnv(cfg *Config, cmd *cobra.Command) { // List values: always additive, with wildcard detection. cfg.AllowedDomains = appendEnvList(cfg.AllowedDomains, "CURB_DOMAINS") cfg.AllowedIPs = appendEnvList(cfg.AllowedIPs, "CURB_IPS") + cfg.InjectBearer = appendEnvList(cfg.InjectBearer, "CURB_INJECT_BEARER") + cfg.InjectHeader = appendEnvList(cfg.InjectHeader, "CURB_INJECT_HEADER") roEnv := appendEnvList(nil, "CURB_READ") if containsStar(roEnv) { @@ -273,4 +287,3 @@ func EnvBool(key string) bool { val := os.Getenv(key) return val == "1" || strings.EqualFold(val, "true") } - diff --git a/config/config_test.go b/config/config_test.go index 91a4ab6..2b62efa 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -22,6 +22,8 @@ func newTestCmd(args []string) *cobra.Command { f.StringSlice("exec", nil, "") f.StringSlice("env", nil, "") f.StringSlice("ips", nil, "") + f.StringSlice("inject-bearer", nil, "") + f.StringSlice("inject-header", nil, "") f.Bool("unrestricted-net", false, "") f.Bool("allow-unix-sockets", false, "") f.Bool("host-loopback", false, "") @@ -124,6 +126,28 @@ func TestMergeEnv_ListsAdditive(t *testing.T) { assert.Equal(t, []string{"b.com", "a.com"}, cfg.AllowedDomains) } +func TestMergeEnv_InjectBearerAdditive(t *testing.T) { + cmd := newTestCmd([]string{"--inject-bearer", "b.com=@B"}) + cfg, err := FromFlags(cmd) + require.NoError(t, err) + + t.Setenv("CURB_INJECT_BEARER", "a.com=@A") + MergeEnv(cfg, cmd) + + assert.Equal(t, []string{"b.com=@B", "a.com=@A"}, cfg.InjectBearer) +} + +func TestMergeEnv_InjectHeaderAdditive(t *testing.T) { + cmd := newTestCmd([]string{"--inject-header", "b.com=x-tok=@B"}) + cfg, err := FromFlags(cmd) + require.NoError(t, err) + + t.Setenv("CURB_INJECT_HEADER", "a.com=x-api-key=@A") + MergeEnv(cfg, cmd) + + assert.Equal(t, []string{"b.com=x-tok=@B", "a.com=x-api-key=@A"}, cfg.InjectHeader) +} + func TestMergeEnv_CommaSeparatedList(t *testing.T) { cmd := newTestCmd(nil) cfg, err := FromFlags(cmd) diff --git a/config/configfile.go b/config/configfile.go index 945cb61..8dd64b1 100644 --- a/config/configfile.go +++ b/config/configfile.go @@ -19,6 +19,8 @@ type ConfigFile struct { Profiles []string `yaml:"profiles"` Domains []string `yaml:"domains"` IPs []string `yaml:"ips"` + InjectBearer []string `yaml:"inject-bearer"` + InjectHeader []string `yaml:"inject-header"` Read pathList `yaml:"read"` Write pathList `yaml:"write"` Exec pathList `yaml:"exec"` @@ -109,6 +111,24 @@ func (cf *ConfigFile) validate() error { return err } } + for i, e := range cf.InjectBearer { + host, source, ok := strings.Cut(e, "=") + if !ok || host == "" || source == "" { + return fmt.Errorf("inject-bearer[%d] must be HOST=SOURCE, got %q", i, e) + } + if err := policy.ValidateDomains([]string{host}); err != nil { + return fmt.Errorf("inject-bearer[%d] host %q: %w", i, host, err) + } + } + for i, e := range cf.InjectHeader { + parts := strings.SplitN(e, "=", 3) + if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + return fmt.Errorf("inject-header[%d] must be HOST=HEADER=SOURCE, got %q", i, e) + } + if err := policy.ValidateDomains([]string{parts[0]}); err != nil { + return fmt.Errorf("inject-header[%d] host %q: %w", i, parts[0], err) + } + } for _, pair := range [...]struct { field string list pathList @@ -152,6 +172,8 @@ func FindConfigFile() string { func mergeConfigLists(cfg *Config, cf *ConfigFile) { cfg.AllowedDomains = append(cf.Domains, cfg.AllowedDomains...) cfg.AllowedIPs = append(cf.IPs, cfg.AllowedIPs...) + cfg.InjectBearer = append(cf.InjectBearer, cfg.InjectBearer...) + cfg.InjectHeader = append(cf.InjectHeader, cfg.InjectHeader...) cfg.ROPaths = append(expandEnvPaths([]string(cf.Read), "read"), cfg.ROPaths...) cfg.RWPaths = append(expandEnvPaths([]string(cf.Write), "write"), cfg.RWPaths...) cfg.ExecAllow = append(expandEnvPaths([]string(cf.Exec), "exec"), cfg.ExecAllow...) diff --git a/config/configfile_test.go b/config/configfile_test.go index 5ce099e..1af8a30 100644 --- a/config/configfile_test.go +++ b/config/configfile_test.go @@ -152,6 +152,58 @@ func TestLoadConfigFile_NotFound(t *testing.T) { require.Error(t, err) } +func TestLoadConfigFile_InjectBearer(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".curb.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +inject-bearer: + - api.github.com=@GH_TOKEN +`), 0o644)) + + cf, err := LoadConfigFile(path) + require.NoError(t, err) + assert.Equal(t, []string{"api.github.com=@GH_TOKEN"}, cf.InjectBearer) +} + +func TestLoadConfigFile_InjectBearerInvalid(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".curb.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +inject-bearer: + - missing-source +`), 0o644)) + + _, err := LoadConfigFile(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "inject-bearer") +} + +func TestLoadConfigFile_InjectHeader(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".curb.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +inject-header: + - api.anthropic.com=x-api-key=@ANTHROPIC_API_KEY +`), 0o644)) + + cf, err := LoadConfigFile(path) + require.NoError(t, err) + assert.Equal(t, []string{"api.anthropic.com=x-api-key=@ANTHROPIC_API_KEY"}, cf.InjectHeader) +} + +func TestLoadConfigFile_InjectHeaderInvalid(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".curb.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +inject-header: + - api.anthropic.com=x-api-key +`), 0o644)) + + _, err := LoadConfigFile(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "inject-header") +} + func TestFindConfigFile_InCWD(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, ".curb.yaml") @@ -199,22 +251,26 @@ func TestFindConfigFile_NotFound(t *testing.T) { } func TestMergeConfigFile_ListsPrepend(t *testing.T) { - cmd := newTestCmd([]string{"--domains", "cli.com"}) + cmd := newTestCmd([]string{"--domains", "cli.com", "--inject-bearer", "cli.com=@C", "--inject-header", "cli.com=x-tok=@H"}) cfg, err := FromFlags(cmd) require.NoError(t, err) cf := &ConfigFile{ - Domains: []string{"file.com"}, - IPs: []string{"10.0.0.1"}, - Read: []string{"/opt"}, - Write: []string{"/data"}, - Exec: []string{"python3"}, - Env: []string{"GOPATH", "FOO=bar"}, + Domains: []string{"file.com"}, + IPs: []string{"10.0.0.1"}, + InjectBearer: []string{"file.com=@F"}, + InjectHeader: []string{"file.com=x-api-key=@K"}, + Read: []string{"/opt"}, + Write: []string{"/data"}, + Exec: []string{"python3"}, + Env: []string{"GOPATH", "FOO=bar"}, } MergeConfigFile(cfg, cf, cmd.Flags()) assert.Equal(t, []string{"file.com", "cli.com"}, cfg.AllowedDomains) assert.Equal(t, []string{"10.0.0.1"}, cfg.AllowedIPs) + assert.Equal(t, []string{"file.com=@F", "cli.com=@C"}, cfg.InjectBearer) + assert.Equal(t, []string{"file.com=x-api-key=@K", "cli.com=x-tok=@H"}, cfg.InjectHeader) assert.Equal(t, []string{"/opt"}, cfg.ROPaths) assert.Equal(t, []string{"/data"}, cfg.RWPaths) assert.Equal(t, []string{"python3"}, cfg.ExecAllow) @@ -363,12 +419,12 @@ func TestMergeConfigFile_ExpandEnvDollarEscape(t *testing.T) { cf := &ConfigFile{ Read: []string{ - "/foo$$bar", // literal $: "/foo$bar" - "/foo$$$$bar", // literal $$: "/foo$$bar" - "$$/$CURB_TEST_DIR", // literal $ then expansion: "$/tmp/curb-expand-test" - "$CURB_TEST_DIR$$tail", // expansion then literal $: "/tmp/curb-expand-test$tail" - "$${CURB_TEST_DIR}", // escape disarms braces: "${CURB_TEST_DIR}" - `\!$$bang`, // bang-escape + literal $: "\!$bang" + "/foo$$bar", // literal $: "/foo$bar" + "/foo$$$$bar", // literal $$: "/foo$$bar" + "$$/$CURB_TEST_DIR", // literal $ then expansion: "$/tmp/curb-expand-test" + "$CURB_TEST_DIR$$tail", // expansion then literal $: "/tmp/curb-expand-test$tail" + "$${CURB_TEST_DIR}", // escape disarms braces: "${CURB_TEST_DIR}" + `\!$$bang`, // bang-escape + literal $: "\!$bang" }, } MergeConfigFile(cfg, cf, cmd.Flags()) diff --git a/config/profile_test.go b/config/profile_test.go index 261e404..c447f0c 100644 --- a/config/profile_test.go +++ b/config/profile_test.go @@ -123,6 +123,24 @@ func TestMergeProfiles(t *testing.T) { assert.Contains(t, cfg.EnvPassthrough, "SSH_AUTH_SOCK") } +func TestMergeProfiles_ClaudeSealsApiKey(t *testing.T) { + cmd := newTestCmd(nil) + cfg, err := FromFlags(cmd) + require.NoError(t, err) + + _, err = MergeProfiles(cfg, []string{"claude"}, cmd.Flags()) + require.NoError(t, err) + + assert.Contains(t, cfg.AllowedDomains, "api.anthropic.com") + + // The key is injected as x-api-key, optionally (@?) from the host env var, + // and the sandbox gets only a conditional ("?") placeholder. + assert.Contains(t, cfg.InjectHeader, "api.anthropic.com=x-api-key=@?ANTHROPIC_API_KEY") + assert.Contains(t, cfg.EnvSet, "ANTHROPIC_API_KEY=?curb-sealed-placeholder") + // The real key is not passed through (it would defeat the seal). + assert.NotContains(t, cfg.EnvPassthrough, "ANTHROPIC_API_KEY") +} + func TestMergeProfiles_Dedup(t *testing.T) { cmd := newTestCmd(nil) cfg, err := FromFlags(cmd) diff --git a/config/profiles/claude.yaml b/config/profiles/claude.yaml index c7360f0..a1798e7 100644 --- a/config/profiles/claude.yaml +++ b/config/profiles/claude.yaml @@ -5,6 +5,15 @@ # # Claude Code's own bwrap sandbox cannot nest inside curb's namespace. # Set sandbox.enabled: false in Claude Code settings — curb provides enforcement. +# +# Credential sealing (Linux): when ANTHROPIC_API_KEY is set on the host, it is +# kept out of the sandbox — the process sees only a placeholder, and curb's +# proxy injects the real key as the x-api-key header on requests to +# api.anthropic.com. This is conditional (? / @? markers): with no host key set, +# nothing is injected and OAuth/subscription auth works unchanged. On first run, +# Claude Code may prompt once to approve the placeholder as a custom API key. +# Caveat: a custom ANTHROPIC_BASE_URL is not covered (the seal targets +# api.anthropic.com). commands: [claude] # Node.js/Bun use AF_UNIX socketpairs internally for child_process.spawn() stdio. allow-unix-sockets: true @@ -44,11 +53,16 @@ exec: - /bin - /usr/local/bin - /opt/homebrew/bin +# Seal ANTHROPIC_API_KEY: inject the real key (from the host env) as x-api-key, +# and give the sandbox only a placeholder. Both are conditional on the host +# having the key set, so OAuth users are unaffected. +inject-header: + - api.anthropic.com=x-api-key=@?ANTHROPIC_API_KEY env: - HOME - SHELL - CLAUDE_CODE_TMPDIR=$TMPDIR - - ANTHROPIC_API_KEY + - ANTHROPIC_API_KEY=?curb-sealed-placeholder - ANTHROPIC_BASE_URL - CLAUDE_CODE_MAX_TOKENS - GIT_AUTHOR_NAME diff --git a/docs/comparison-zerobox.md b/docs/comparison-zerobox.md index 332a3e1..7e4a3e2 100644 --- a/docs/comparison-zerobox.md +++ b/docs/comparison-zerobox.md @@ -76,9 +76,11 @@ for HTTPS in standard mode. There is no SOCKS5 proxy -- non-HTTP TCP (SSH, git protocol, database connections) is blocked entirely when network filtering is active. -**curb** runs both an HTTP proxy and a SOCKS5 proxy. HTTPS always uses -CONNECT passthrough: the proxy checks the hostname from the CONNECT -request, then tunnels the encrypted stream without terminating TLS. +**curb** runs both an HTTP proxy and a SOCKS5 proxy. HTTPS uses CONNECT +passthrough by default: the proxy checks the hostname from the CONNECT +request, then tunnels the encrypted stream without terminating TLS. The +opt-in `--inject-bearer` (see below) terminates TLS for a +named host only, to inject a credential; every other host stays passthrough. The SOCKS5 proxy handles non-HTTP TCP (e.g. SSH via `ProxyCommand`). Both filter on the CONNECT hostname or SOCKS5 destination, not on TLS SNI, so neither is affected by Encrypted Client Hello (ECH). curb @@ -88,7 +90,9 @@ forwarding localhost traffic to the host via `--host-loopback`. The MITM requirement for zerobox's secret injection is a significant tradeoff: it breaks certificate pinning, requires injecting a custom CA into every TLS-using tool, and means the proxy can see all HTTPS -content. curb avoids TLS termination entirely. +content. curb avoids TLS termination by default, and confines it to the +specific hosts named in `--inject-bearer` when injection is used — every +other host still tunnels untouched. The lack of SOCKS5 in zerobox means non-HTTP TCP protocols (SSH, git://, database connections) cannot be domain-filtered -- they are either fully @@ -104,19 +108,37 @@ the sandbox. The sandboxed process sees a random placeholder (`ZEROBOX_SECRET_<64 hex chars>`) in the environment variable, not the real value. `--secret-host OPENAI_API_KEY=api.openai.com` restricts the secret to a specific domain. The MITM proxy intercepts HTTPS requests, -scans HTTP headers for placeholder tokens, and substitutes the real -secret value only for approved hosts. The placeholder is 32 random bytes, -not derived from the secret, preventing brute-force recovery. - -**curb** has no secret injection. Secrets are either passed via `--env` -(the process sees the real value) or not passed at all. Network domain -filtering (`--domains`) controls where the process can connect, but does -not inspect or modify request content. - -zerobox's approach prevents a compromised or malicious process from -reading the actual secret from its environment and exfiltrating it to an -unauthorized domain. The cost is mandatory TLS termination and CA -injection for all HTTPS traffic. +scans them for placeholder tokens, and substitutes the real secret value +only for approved hosts. The placeholder is 32 random bytes so it is long +and unique enough not to collide with legitimate request content during +that scan. + +**curb** has opt-in credential injection via +`--inject-bearer HOST=SOURCE` (`Authorization: Bearer`) and +`--inject-header HOST=HEADER=SOURCE` (any request header, e.g. `x-api-key` for +the Anthropic API) — Linux only. The sandboxed +process need not hold the real credential at all. The proxy terminates TLS for +`HOST` (presenting a per-run CA the sandbox trusts) and sets the header, bound +to that host. The token is read from an env var (`@ENV_VAR`, kept out of argv) +or a literal. Without the flag, curb performs no injection and no TLS +termination; secrets otherwise reach the process only via `--env`. + +Two differences from zerobox: curb terminates TLS only for the hosts named in +the flags, not for all HTTPS while secrets are active; and it *sets* a named +request header rather than scanning the request for a placeholder and +substituting it wherever it appears. zerobox's model is more general — it can +substitute in the request body, not just headers. curb's is header-only but +leaves untouched traffic untouched. zerobox's unguessable placeholder is a +requirement of that scanning (the marker must not collide with real content), +not a separate security property: curb sets the header to the real value and +overwrites whatever the sandbox sent, so its placeholder is never matched +against traffic and its value carries no security weight. Generalizing curb to +body substitution would also widen the proxy's attacker-facing surface (it +would parse and rewrite bodies), which cuts against keeping injection narrow. + +Both approaches prevent a compromised or malicious process from reading the +real secret and exfiltrating it. The shared cost is TLS termination and CA +trust — for curb, only on the injected hosts. ## Process isolation @@ -220,8 +242,8 @@ Linux. curb has no external runtime dependencies. | Default FS write | Blocked | Blocked | | Default network | Blocked | Blocked | | FS enforcement | bwrap (mount NS) + Landlock fallback | pivot_root (mount NS) + Landlock layered | -| Network filtering | HTTP proxy (MITM when secrets active) | HTTP + SOCKS5 proxy (CONNECT passthrough) | -| Secret injection | Yes (MITM proxy, placeholder substitution) | No | +| Network filtering | HTTP proxy (MITM when secrets active) | HTTP + SOCKS5 proxy (CONNECT passthrough; per-host TLS termination only with `--inject-bearer`) | +| Secret injection | Yes (MITM proxy, placeholder substitution) | Opt-in per host, header injection (`--inject-bearer`/`--inject-header`, Linux) | | Snapshot/restore | Yes (BLAKE3 + Merkle tree) | No (stateless) | | IP/CIDR filtering | No | Yes (`--ips`) | | SOCKS5 (non-HTTP TCP) | No | Yes | @@ -247,21 +269,21 @@ process from ever seeing real credentials. This is valuable for AI agent use cases where the agent needs to call authenticated APIs but should not be able to read or exfiltrate the actual tokens. -Implementing this in curb would require TLS termination (MITM) in the -proxy, which conflicts with curb's current CONNECT passthrough design. -An alternative approach: inject secrets only into specific HTTP headers -at the proxy level without full TLS termination, using CONNECT to -establish the tunnel and then a protocol-aware layer for header -injection. This is not straightforward and may not be feasible without -MITM. The security tradeoffs (breaking certificate pinning, CA -injection, proxy sees all HTTPS content) should be weighed against the -credential isolation benefit. - -A lighter-weight option: support secret-aware environment variables that -are populated only at exec time and excluded from `/proc//environ` -(e.g. via a helper that reads from a pipe). This would prevent -environment enumeration but not prevent the process from reading its own -memory. +curb now has an opt-in version of this: `--inject-bearer` and +`--inject-header` terminate TLS for a named host and set a credential header +(`Authorization: Bearer` or any header, several per host), keeping the token +out of the sandbox. Unlike zerobox, TLS termination is confined to the named +hosts rather than applied to all HTTPS while secrets are active. Remaining +work to approach zerobox's generality: substituting a secret in the request +body, not just headers (weigh against the added body-parsing surface), and +reading the token from an inherited fd so it touches neither argv nor the +environment. + +A lighter-weight, complementary option: support secret-aware environment +variables that are populated only at exec time and excluded from +`/proc//environ` (e.g. via a helper that reads from a pipe). This +would prevent environment enumeration but not prevent the process from +reading its own memory. ### Filesystem snapshots diff --git a/docs/configuration.md b/docs/configuration.md index 59a721c..0a1e322 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,7 +38,7 @@ Unknown keys are rejected. All fields are optional. ### List fields -`domains`, `ips`, `read`, `write`, `exec`, `env` — merged additively. Config file values are prepended to CLI values, so CLI exclusions (`!`) can override config file entries. +`domains`, `ips`, `inject-bearer`, `read`, `write`, `exec`, `env` — merged additively. Config file values are prepended to CLI values, so CLI exclusions (`!`) can override config file entries. ### Scalar fields @@ -185,6 +185,73 @@ By default, curb sets: And passes through: `PATH`, `TERM`, `COLORTERM`, `NO_COLOR`, `LANG`, `LC_ALL`, `LC_CTYPE`, `TZ`, `USER`, `LOGNAME`, `SHELL`. +## Credential injection + +> Linux only. + +`--inject-bearer HOST=SOURCE` keeps a bearer token out of the sandbox entirely. +curb's proxy terminates TLS for `HOST`, presenting a per-run CA that the sandbox +trusts, and adds `Authorization: Bearer ` to requests on their way to the +real upstream. The sandboxed process holds no token — only the proxy does — and +the credential is bound to `HOST`, so it is never attached to any other host the +program connects to. + +`SOURCE` is one of: + +- `@ENV_VAR` — read the token from an environment variable. Preferred: the value + is not visible in `/proc//cmdline`. +- `@?ENV_VAR` — optional: inject only if the variable is set; otherwise skip the + binding silently (no error). Use when the credential may be absent. +- a literal token — accepted, but visible in the process arguments (curb warns). + +``` +# Token read from $GH_TOKEN, never placed in the sandbox: +GH_TOKEN=ghp_xxx curb --inject-bearer 'api.github.com=@GH_TOKEN' -- gh api user +``` + +For schemes that do not use a bearer token, `--inject-header HOST=HEADER=SOURCE` +injects an arbitrary request header. `--inject-bearer HOST=SOURCE` is exactly +sugar for `--inject-header HOST=Authorization=Bearer …`. For example, the +Anthropic API authenticates with the `x-api-key` header, not `Authorization`: + +``` +# api.anthropic.com gets x-api-key from $ANTHROPIC_API_KEY; the sandbox never has it: +ANTHROPIC_API_KEY=sk-… curb --inject-header 'api.anthropic.com=x-api-key=@ANTHROPIC_API_KEY' -- ... +``` + +The built-in **`claude`** profile does exactly this for Claude Code: *when +`ANTHROPIC_API_KEY` is set on the host* it puts only a placeholder in the +sandbox and injects the real key as `x-api-key`. It uses the conditional markers +(`?` on the env value, `@?` on the injection source), so with no host key set it +is a no-op and OAuth/subscription auth works unchanged. Linux only; on first run +Claude Code may prompt once to approve the placeholder as a custom key. A custom +`ANTHROPIC_BASE_URL` is not covered (the seal targets `api.anthropic.com`). + +A sandbox env var can be set *conditionally* with a `?` prefix on its value: +`VAR=?placeholder` applies only when `VAR` is set in the host environment. This +is how a credential is replaced with a placeholder without introducing one when +it is absent. + +The flag is repeatable (one binding per host) and implies network filtering: the +host is added to the allowlist and routed through the proxy. curb generates a +combined CA bundle (system roots plus the per-run CA) and points the standard CA +environment variables (`SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, +`REQUESTS_CA_BUNDLE`, `NODE_EXTRA_CA_CERTS`) at it, so common tools trust the +terminated connection while still reaching other hosts normally. + +Both are also settable via environment variables (`CURB_INJECT_BEARER`, +`CURB_INJECT_HEADER`, comma-separated) and the `inject-bearer:` / +`inject-header:` config-file/profile keys, merged additively like `domains`. The +`@ENV_VAR` form is resolved at run time wherever the binding comes from, so +prefer it in config files and profiles — only the variable name is stored, never +the token: + +```yaml +# .curb.yaml — the token is read from $GH_TOKEN at run time, not committed. +inject-bearer: + - api.github.com=@GH_TOKEN +``` + ## HOME and tilde expansion ### How HOME is determined diff --git a/proxy/handler.go b/proxy/handler.go index bdd6cb4..63a7098 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -9,13 +9,15 @@ import ( "sync" ) - // Handler is an HTTP proxy handler that filters CONNECT (HTTPS) and plain // HTTP requests by domain and IP allowlists. HTTPS uses passthrough // tunneling: the proxy checks the CONNECT hostname, then relays the // encrypted stream without terminating TLS. type Handler struct { FilterBase + // Injector, when set, terminates TLS and injects a bound credential for + // hosts it has a binding for. Hosts without a binding stay passthrough. + Injector *Injector } // ServeHTTP implements http.Handler. @@ -43,6 +45,15 @@ func (h *Handler) handleCONNECT(w http.ResponseWriter, r *http.Request) { return } + // A bound host is TLS-terminated so the credential can be injected; + // everything else keeps the passthrough relay below. + if h.Injector != nil { + if injs, ok := h.Injector.binding(host); ok { + h.injectCONNECT(w, host, port, injs) + return + } + } + // Hijack the client connection. hijacker, ok := w.(http.Hijacker) if !ok { diff --git a/proxy/inject.go b/proxy/inject.go new file mode 100644 index 0000000..5b789c9 --- /dev/null +++ b/proxy/inject.go @@ -0,0 +1,221 @@ +package proxy + +import ( + "bufio" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "net/http" + "sync" + "time" +) + +// CA is a per-run certificate authority used to mint leaf certificates for the +// hosts whose TLS the injecting proxy terminates. It is trusted only inside one +// sandbox: even if the action reads the key, all it can do is MITM itself. +type CA struct { + cert *x509.Certificate + key *ecdsa.PrivateKey + certPEM []byte + + mu sync.Mutex + leaves map[string]*tls.Certificate +} + +// NewCA generates an in-memory EC certificate authority. Generating the CA and +// signing leaves on demand is sub-millisecond. +func NewCA() (*CA, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("ca key: %w", err) + } + now := time.Now() + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "curb per-run CA"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLenZero: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return nil, fmt.Errorf("ca cert: %w", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, fmt.Errorf("parse ca: %w", err) + } + return &CA{ + cert: cert, + key: key, + certPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + leaves: map[string]*tls.Certificate{}, + }, nil +} + +// CertPEM returns the CA certificate in PEM form, for injection into the +// action's trust store (SSL_CERT_FILE / GIT_SSL_CAINFO / ...). +func (ca *CA) CertPEM() []byte { return ca.certPEM } + +// leafFor returns a leaf certificate for host, signed by the CA. Leaves are +// cached per host for the life of the run. +func (ca *CA) leafFor(host string) (*tls.Certificate, error) { + ca.mu.Lock() + defer ca.mu.Unlock() + if c, ok := ca.leaves[host]; ok { + return c, nil + } + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + now := time.Now() + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(now.UnixNano()), + Subject: pkix.Name{CommonName: host}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + if ip := net.ParseIP(host); ip != nil { + tmpl.IPAddresses = []net.IP{ip} + } else { + tmpl.DNSNames = []string{host} + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, &key.PublicKey, ca.key) + if err != nil { + return nil, err + } + leaf := &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: ca.cert} + ca.leaves[host] = leaf + return leaf, nil +} + +// Injection is a credential bound to a destination host: the proxy sets Header +// to Value on requests it forwards to that host, and to no other host. +type Injection struct { + Header string + Value string +} + +// Injector terminates TLS for bound hosts and injects their credentials. A host +// without a binding is not terminated — the Handler relays it as passthrough, +// so a credential is never stapled to a destination it was not provisioned for +// (the central correctness property). +type Injector struct { + CA *CA + // Upstream round-trips the decrypted, credential-injected request to the + // real origin over a fresh TLS connection. Defaults to system-roots TLS; + // tests override it to reach local servers. + Upstream http.RoundTripper + + byHost map[string][]Injection +} + +// NewInjector creates an injector backed by the per-run CA. +func NewInjector(ca *CA) *Injector { + return &Injector{ + CA: ca, + Upstream: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}}, + byHost: map[string][]Injection{}, + } +} + +// Bind adds a header injection for a destination host. Multiple headers may be +// bound to the same host. +func (in *Injector) Bind(host string, inj Injection) { + in.byHost[host] = append(in.byHost[host], inj) +} + +func (in *Injector) binding(host string) ([]Injection, bool) { + injs, ok := in.byHost[host] + return injs, ok +} + +// injectCONNECT terminates the client's TLS with a per-run leaf for host, +// then forwards each decrypted request to the real upstream with the bound +// credential injected. +func (h *Handler) injectCONNECT(w http.ResponseWriter, host, port string, injs []Injection) { + hj, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "curb: hijack unsupported", http.StatusInternalServerError) + return + } + clientConn, _, err := hj.Hijack() + if err != nil { + h.logEvent("proxy_inject", host, "error", err.Error()) + return + } + defer func() { _ = clientConn.Close() }() + + if _, err := clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil { + return + } + + leaf, err := h.Injector.CA.leafFor(host) + if err != nil { + h.logEvent("proxy_inject", host, "error", "leaf: "+err.Error()) + return + } + tlsConn := tls.Server(clientConn, &tls.Config{ + Certificates: []tls.Certificate{*leaf}, + MinVersion: tls.VersionTLS12, + }) + if err := tlsConn.Handshake(); err != nil { + h.logEvent("proxy_inject", host, "error", "tls handshake: "+err.Error()) + return + } + defer func() { _ = tlsConn.Close() }() + + h.logEvent("proxy_inject", host, "allowed", "") + h.serveInjected(tlsConn, host, port, injs) +} + +// serveInjected reads requests off the decrypted client stream and forwards +// each to the upstream with the bound credentials set, relaying the response. +func (h *Handler) serveInjected(client net.Conn, host, port string, injs []Injection) { + rt := h.Injector.Upstream + br := bufio.NewReader(client) + for { + req, err := http.ReadRequest(br) + if err != nil { + return + } + // Overwrite any client-supplied values: the sandbox holds only a + // placeholder and must not pre-empt the real credential. + for _, inj := range injs { + req.Header.Set(inj.Header, inj.Value) + } + req.Header.Del("Proxy-Connection") + req.URL.Scheme = "https" + req.URL.Host = net.JoinHostPort(host, port) + req.RequestURI = "" + + resp, err := rt.RoundTrip(req.WithContext(context.Background())) + if err != nil { + writeGatewayError(client) + return + } + werr := resp.Write(client) + _ = resp.Body.Close() + if werr != nil { + return + } + } +} + +func writeGatewayError(c net.Conn) { + _, _ = c.Write([]byte("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")) +} diff --git a/proxy/inject_test.go b/proxy/inject_test.go new file mode 100644 index 0000000..9b3111b --- /dev/null +++ b/proxy/inject_test.go @@ -0,0 +1,238 @@ +package proxy + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// authRecorder is an upstream TLS server that records the headers of each +// request it receives. +type authRecorder struct { + srv *httptest.Server + headers []http.Header +} + +func newAuthRecorder(t *testing.T) *authRecorder { + t.Helper() + rec := &authRecorder{} + rec.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec.headers = append(rec.headers, r.Header.Clone()) + _, _ = io.WriteString(w, "ok") + })) + t.Cleanup(rec.srv.Close) + return rec +} + +// count returns how many requests the upstream received. +func (r *authRecorder) count() int { return len(r.headers) } + +// got returns the named header's value from each received request, in order. +func (r *authRecorder) got(name string) []string { + out := make([]string, 0, len(r.headers)) + for _, h := range r.headers { + out = append(out, h.Get(name)) + } + return out +} + +// injectTestProxy starts a curb proxy that allows the given hosts and injects +// the configured bearer tokens, routing upstream to the local recorders. +func injectTestProxy(t *testing.T, ca *CA, bindings map[string]string, upstreams map[string]*authRecorder) *url.URL { + t.Helper() + binds := make(map[string][]Injection, len(bindings)) + for host, token := range bindings { + binds[host] = []Injection{{Header: "Authorization", Value: "Bearer " + token}} + } + return injectTestProxyWith(t, ca, binds, upstreams) +} + +// injectTestProxyWith starts a curb proxy with arbitrary header injections. +func injectTestProxyWith(t *testing.T, ca *CA, bindings map[string][]Injection, upstreams map[string]*authRecorder) *url.URL { + t.Helper() + + allowed := map[string]bool{} + dialMap := map[string]*authRecorder{} + for host, rec := range upstreams { + allowed[host] = true + dialMap[net.JoinHostPort(host, "443")] = rec + } + + injector := NewInjector(ca) + for host, injs := range bindings { + for _, inj := range injs { + injector.Bind(host, inj) + } + } + // Reach the local recorders instead of the real hosts, trusting each + // server's own cert (httptest's built-in cert is issued for example.com). + injector.Upstream = &http.Transport{ + DialTLSContext: func(ctx context.Context, _, addr string) (net.Conn, error) { + rec, ok := dialMap[addr] + if !ok { + return nil, fmt.Errorf("unexpected upstream %s", addr) + } + raw, err := (&net.Dialer{}).DialContext(ctx, "tcp", rec.srv.Listener.Addr().String()) + if err != nil { + return nil, err + } + pool := x509.NewCertPool() + pool.AddCert(rec.srv.Certificate()) + tc := tls.Client(raw, &tls.Config{RootCAs: pool, ServerName: "example.com", MinVersion: tls.VersionTLS12}) + if err := tc.HandshakeContext(ctx); err != nil { + _ = raw.Close() + return nil, err + } + return tc, nil + }, + } + + handler := &Handler{ + FilterBase: FilterBase{DomainCheck: func(h string) bool { return allowed[h] }}, + Injector: injector, + } + proxySrv := httptest.NewServer(handler) + t.Cleanup(proxySrv.Close) + + u, err := url.Parse(proxySrv.URL) + require.NoError(t, err) + return u +} + +// clientThroughProxy builds an HTTP client that routes through the curb proxy +// and trusts the per-run CA for the terminated TLS. +func clientThroughProxy(proxyURL *url.URL, ca *CA) *http.Client { + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(ca.CertPEM()) + return &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + }, + } +} + +func get(t *testing.T, client *http.Client, rawURL string) string { + t.Helper() + resp, err := client.Get(rawURL) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return string(body) +} + +// TestInjector_InjectsBoundCredential verifies the placeholder request leaves +// the sandbox with the real credential attached. +func TestInjector_InjectsBoundCredential(t *testing.T) { + ca, err := NewCA() + require.NoError(t, err) + + gh := newAuthRecorder(t) + proxyURL := injectTestProxy(t, ca, + map[string]string{"api.github.com": "ghs_realtoken"}, + map[string]*authRecorder{"api.github.com": gh}, + ) + client := clientThroughProxy(proxyURL, ca) + + body := get(t, client, "https://api.github.com/user") + assert.Equal(t, "ok", body) + require.Equal(t, 1, gh.count()) + assert.Equal(t, "Bearer ghs_realtoken", gh.got("Authorization")[0]) +} + +// TestInjector_BindsCredentialToDestination is the central correctness +// property: a credential is attached only to the host it was provisioned for, +// never stapled to another host the agent names. +func TestInjector_BindsCredentialToDestination(t *testing.T) { + ca, err := NewCA() + require.NoError(t, err) + + gh := newAuthRecorder(t) + other := newAuthRecorder(t) + proxyURL := injectTestProxy(t, ca, + map[string]string{ + "api.github.com": "ghs_realtoken", + "api.example.com": "example_token", + }, + map[string]*authRecorder{ + "api.github.com": gh, + "api.example.com": other, + }, + ) + client := clientThroughProxy(proxyURL, ca) + + get(t, client, "https://api.github.com/user") + get(t, client, "https://api.example.com/data") + + require.Equal(t, 1, gh.count()) + require.Equal(t, 1, other.count()) + // Each host sees only its own credential. + assert.Equal(t, "Bearer ghs_realtoken", gh.got("Authorization")[0]) + assert.Equal(t, "Bearer example_token", other.got("Authorization")[0]) + assert.NotContains(t, gh.got("Authorization")[0], "example_token") + assert.NotContains(t, other.got("Authorization")[0], "ghs_realtoken") +} + +// TestInjector_PlaceholderIsOverwritten confirms a placeholder the sandbox +// might send is replaced, not honored. +func TestInjector_PlaceholderIsOverwritten(t *testing.T) { + ca, err := NewCA() + require.NoError(t, err) + + gh := newAuthRecorder(t) + proxyURL := injectTestProxy(t, ca, + map[string]string{"api.github.com": "ghs_realtoken"}, + map[string]*authRecorder{"api.github.com": gh}, + ) + client := clientThroughProxy(proxyURL, ca) + + req, err := http.NewRequest(http.MethodGet, "https://api.github.com/user", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer gh_placeholder") + resp, err := client.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, 1, gh.count()) + assert.Equal(t, "Bearer ghs_realtoken", gh.got("Authorization")[0]) +} + +// TestInjector_InjectsArbitraryHeader covers --inject-header: a non-bearer +// header (e.g. x-api-key, as Anthropic uses) is set on the outbound request. +func TestInjector_InjectsArbitraryHeader(t *testing.T) { + ca, err := NewCA() + require.NoError(t, err) + + up := newAuthRecorder(t) + proxyURL := injectTestProxyWith(t, ca, + map[string][]Injection{ + "api.anthropic.com": {{Header: "x-api-key", Value: "sk-ant-real"}}, + }, + map[string]*authRecorder{"api.anthropic.com": up}, + ) + client := clientThroughProxy(proxyURL, ca) + + req, err := http.NewRequest(http.MethodGet, "https://api.anthropic.com/v1/models", nil) + require.NoError(t, err) + req.Header.Set("x-api-key", "sk-ant-placeholder") // overwritten, not honored + resp, err := client.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, 1, up.count()) + assert.Equal(t, "sk-ant-real", up.got("x-api-key")[0]) + // The bearer header is not added for a header-only binding. + assert.Empty(t, up.got("Authorization")[0]) +} diff --git a/sandbox/capabilities_linux_test.go b/sandbox/capabilities_linux_test.go index edcfe7d..633645a 100644 --- a/sandbox/capabilities_linux_test.go +++ b/sandbox/capabilities_linux_test.go @@ -219,6 +219,34 @@ func TestPrintDryRun_ContainsExpectedSections(t *testing.T) { assert.Contains(t, output, "status: full") } +func TestPrintDryRun_Injection(t *testing.T) { + caps := &Capabilities{ + UserNS: nil, + MountNS: nil, + NetNS: nil, + LandlockABI: 4, + Seccomp: true, + KernelInfo: "6.8.0-test", + } + t.Setenv("CURB_DRYRUN_TOKEN", "sk-secret-must-not-leak") + cfg := &config.Config{ + InjectHeader: []string{"api.example.com=x-api-key=@CURB_DRYRUN_TOKEN"}, + } + + plan, err := BuildPlan(cfg, caps, nil) + require.NoError(t, err) + defer plan.Cleanup() + + var buf bytes.Buffer + plan.PrintDryRun(&buf) + output := buf.String() + + assert.Contains(t, output, "inject:") + assert.Contains(t, output, "api.example.com: x-api-key") + assert.Contains(t, output, "ca-trust:") + assert.NotContains(t, output, "sk-secret-must-not-leak", "dry-run must never print the injected token") +} + func TestPrintDryRun_DegradedEnforcement(t *testing.T) { caps := &Capabilities{ UserNS: nil, diff --git a/sandbox/inject_integration_test.go b/sandbox/inject_integration_test.go new file mode 100644 index 0000000..fa0172c --- /dev/null +++ b/sandbox/inject_integration_test.go @@ -0,0 +1,209 @@ +//go:build linux + +package sandbox + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// upstreamTLS generates a CA and a leaf certificate valid for dnsNames and +// 127.0.0.1, returning the server cert and the CA in PEM (to trust the server). +func upstreamTLS(t *testing.T, dnsNames []string) (tls.Certificate, []byte) { + t.Helper() + + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + caTmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test upstream CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey) + require.NoError(t, err) + caCert, err := x509.ParseCertificate(caDER) + require.NoError(t, err) + + leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + leafTmpl := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: dnsNames[0]}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: dnsNames, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, + } + leafDER, err := x509.CreateCertificate(rand.Reader, leafTmpl, caCert, &leafKey.PublicKey, caKey) + require.NoError(t, err) + + cert := tls.Certificate{Certificate: [][]byte{leafDER}, PrivateKey: leafKey} + caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}) + return cert, caPEM +} + +// headerRecorder is an HTTPS upstream that records the headers of each request. +type headerRecorder struct { + mu sync.Mutex + headers []http.Header +} + +func (r *headerRecorder) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + r.mu.Lock() + r.headers = append(r.headers, req.Header.Clone()) + r.mu.Unlock() + _, _ = fmt.Fprint(w, "ok") + }) +} + +// got returns the named header's value from each received request, in order. +func (r *headerRecorder) got(name string) []string { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]string, 0, len(r.headers)) + for _, h := range r.headers { + out = append(out, h.Get(name)) + } + return out +} + +// startTLSServer serves h over TLS on a loopback port and returns the port. +func startTLSServer(t *testing.T, cert tls.Certificate, h http.Handler) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + srv := &http.Server{ + Handler: h, + TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, + } + go func() { _ = srv.ServeTLS(ln, "", "") }() + t.Cleanup(func() { _ = srv.Close() }) + _, port, err := net.SplitHostPort(ln.Addr().String()) + require.NoError(t, err) + return port +} + +// envWithout returns os.Environ() with the named keys removed, so the test can +// set them unambiguously (Go reads the first match for a key). +func envWithout(keys ...string) []string { + var out []string + for _, e := range os.Environ() { + drop := false + for _, k := range keys { + if strings.HasPrefix(e, k+"=") { + drop = true + break + } + } + if !drop { + out = append(out, e) + } + } + return out +} + +// TestCurb_Inject_BearerEndToEnd runs the real curb binary with --inject-bearer +// and a sandboxed curl against a local HTTPS server, asserting the proxy injects +// the bound credential — and overwrites a client-supplied placeholder. +func TestCurb_Inject_BearerEndToEnd(t *testing.T) { + requireProxyNS(t) + + rec := &headerRecorder{} + cert, caPEM := upstreamTLS(t, []string{"localhost"}) + port := startTLSServer(t, cert, rec.handler()) + + // The curb proxy verifies the upstream against system roots; point its + // process at a trust file containing only this test server's CA. + caFile := filepath.Join(t.TempDir(), "upstream-ca.pem") + require.NoError(t, os.WriteFile(caFile, caPEM, 0o644)) + + url := fmt.Sprintf("https://localhost:%s/", port) + // First request: no client credential. Second: a placeholder that must be + // overwritten, not honored. + script := fmt.Sprintf( + "curl -sf --connect-timeout 10 %s; echo \" a=$?\"; "+ + "curl -sf --connect-timeout 10 -H 'Authorization: Bearer client-placeholder' %s; echo \" b=$?\"", + url, url) + + cmd := exec.Command(curbBin, "--write", "*", "--exec", "*", + "--host-loopback", + "--inject-bearer", "localhost=@DEMO_TOKEN", + "--", "sh", "-c", script) + cmd.Env = append(envWithout("DEMO_TOKEN", "SSL_CERT_FILE"), + "DEMO_TOKEN=integration-secret", + "SSL_CERT_FILE="+caFile, + ) + + out, err := cmd.CombinedOutput() + outStr := filterCurbOutput(string(out)) + require.NoError(t, err, "sandboxed curl failed: %s", outStr) + assert.Contains(t, outStr, "a=0", "first request should succeed (sandbox trusts the per-run CA)") + assert.Contains(t, outStr, "b=0", "second request should succeed") + + seen := rec.got("Authorization") + require.Len(t, seen, 2, "upstream should have received two requests") + assert.Equal(t, "Bearer integration-secret", seen[0], "credential injected on the wire") + assert.Equal(t, "Bearer integration-secret", seen[1], "client placeholder overwritten, not honored") +} + +// TestCurb_Inject_HeaderEndToEnd runs the real curb binary with --inject-header +// and a sandboxed curl, asserting an arbitrary header (x-api-key, as Anthropic +// uses) is injected and overwrites a client-supplied placeholder. +func TestCurb_Inject_HeaderEndToEnd(t *testing.T) { + requireProxyNS(t) + + rec := &headerRecorder{} + cert, caPEM := upstreamTLS(t, []string{"localhost"}) + port := startTLSServer(t, cert, rec.handler()) + + caFile := filepath.Join(t.TempDir(), "upstream-ca.pem") + require.NoError(t, os.WriteFile(caFile, caPEM, 0o644)) + + url := fmt.Sprintf("https://localhost:%s/", port) + script := fmt.Sprintf( + "curl -sf --connect-timeout 10 -H 'x-api-key: sk-placeholder' %s; echo \" a=$?\"", url) + + cmd := exec.Command(curbBin, "--write", "*", "--exec", "*", + "--host-loopback", + "--inject-header", "localhost=x-api-key=@DEMO_KEY", + "--", "sh", "-c", script) + cmd.Env = append(envWithout("DEMO_KEY", "SSL_CERT_FILE"), + "DEMO_KEY=sk-ant-real-integration", + "SSL_CERT_FILE="+caFile, + ) + + out, err := cmd.CombinedOutput() + outStr := filterCurbOutput(string(out)) + require.NoError(t, err, "sandboxed curl failed: %s", outStr) + assert.Contains(t, outStr, "a=0") + + seen := rec.got("x-api-key") + require.Len(t, seen, 1) + assert.Equal(t, "sk-ant-real-integration", seen[0], "x-api-key injected, placeholder overwritten") +} diff --git a/sandbox/inject_test.go b/sandbox/inject_test.go new file mode 100644 index 0000000..7e90dec --- /dev/null +++ b/sandbox/inject_test.go @@ -0,0 +1,110 @@ +package sandbox + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/upsun/curb/clog" +) + +func testLogger(t *testing.T) *clog.Logger { + t.Helper() + log, err := clog.New("", false, false, true) // quiet + require.NoError(t, err) + return log +} + +func TestParseInjectBearer(t *testing.T) { + specs, err := parseInjectBearer([]string{"api.github.com=@TOKEN", "example.com=literal"}) + require.NoError(t, err) + require.Len(t, specs, 2) + assert.Equal(t, "api.github.com", specs[0].host) + assert.Equal(t, "@TOKEN", specs[0].source) + + for _, bad := range []string{"", "noequals", "=missinghost", "host="} { + _, err := parseInjectBearer([]string{bad}) + assert.Error(t, err, "expected error for %q", bad) + } +} + +func TestParseInjectBearer_FillsAuthorization(t *testing.T) { + specs, err := parseInjectBearer([]string{"api.github.com=@TOK"}) + require.NoError(t, err) + require.Len(t, specs, 1) + assert.Equal(t, "Authorization", specs[0].header) + assert.Equal(t, "Bearer ", specs[0].prefix) +} + +func TestParseInjectHeader(t *testing.T) { + specs, err := parseInjectHeader([]string{"api.anthropic.com=x-api-key=@ANTHROPIC_API_KEY"}) + require.NoError(t, err) + require.Len(t, specs, 1) + assert.Equal(t, "api.anthropic.com", specs[0].host) + assert.Equal(t, "x-api-key", specs[0].header) + assert.Equal(t, "", specs[0].prefix) + assert.Equal(t, "@ANTHROPIC_API_KEY", specs[0].source) + + // A literal value containing '=' is preserved (split is HOST=HEADER=rest). + specs, err = parseInjectHeader([]string{"h.com=X-Tok=ab=cd"}) + require.NoError(t, err) + assert.Equal(t, "ab=cd", specs[0].source) + + for _, bad := range []string{"", "h.com", "h.com=x-api-key", "=x=y", "h.com==y", "h.com=bad header=y"} { + _, err := parseInjectHeader([]string{bad}) + assert.Error(t, err, "expected error for %q", bad) + } +} + +func TestResolveSecretSource(t *testing.T) { + log := testLogger(t) + + t.Setenv("CURB_TEST_TOKEN", "s3cret") + + // Required @VAR: value when set; error when unset. + val, ok, err := resolveSecretSource("@CURB_TEST_TOKEN", log) + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, "s3cret", val) + + _, _, err = resolveSecretSource("@CURB_TEST_UNSET", log) + assert.Error(t, err) + + // Optional @?VAR: value when set; skip (ok=false, no error) when unset. + val, ok, err = resolveSecretSource("@?CURB_TEST_TOKEN", log) + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, "s3cret", val) + + val, ok, err = resolveSecretSource("@?CURB_TEST_UNSET", log) + require.NoError(t, err) + assert.False(t, ok, "optional source skips when unset") + assert.Empty(t, val) + + // Literal value is returned (with a warning, not asserted here). + val, ok, err = resolveSecretSource("literal-token", log) + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, "literal-token", val) +} + +func TestWriteCABundle(t *testing.T) { + dir := t.TempDir() + caPEM := []byte("-----BEGIN CERTIFICATE-----\nPERRUNCA\n-----END CERTIFICATE-----\n") + + path, err := writeCABundle(dir, caPEM) + require.NoError(t, err) + assert.Equal(t, filepath.Join(dir, "ca-bundle.pem"), path) + + data, err := os.ReadFile(path) + require.NoError(t, err) + // The per-run CA is always present; system roots are appended before it + // when a system bundle exists on the host. + assert.Contains(t, string(data), "PERRUNCA") + if sys := systemCABundle(); sys != "" { + assert.Greater(t, len(data), len(caPEM), "combined bundle should include system roots") + } +} diff --git a/sandbox/plan.go b/sandbox/plan.go index 9054034..f684e3a 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -16,6 +16,7 @@ import ( "github.com/upsun/curb/clog" "github.com/upsun/curb/config" "github.com/upsun/curb/policy" + "github.com/upsun/curb/proxy" ) const ( @@ -106,6 +107,12 @@ type SandboxPlan struct { Command []string Caps *Capabilities Logger *clog.Logger + + // Credential injection (parent-only; never serialized to the child). + // CA mints leaf certs for injected hosts; InjectBindings maps a host to + // the credential headers the proxy attaches to it. + CA *proxy.CA + InjectBindings map[string][]proxy.Injection } // LandlockPaths returns the path sets for Landlock rule construction. @@ -572,6 +579,162 @@ func appendUniq(s []string, v string) []string { return s } +// injectSpec is a parsed injection binding: for requests to host, set header to +// prefix + the resolved token. The token comes from source (an @ENV_VAR +// reference or a literal). --inject-bearer fills prefix with "Bearer ". +type injectSpec struct { + host string + header string + prefix string + source string +} + +// parseInjectBearer parses --inject-bearer "HOST=SOURCE" into an Authorization: +// Bearer binding (sugar over parseInjectHeader). +func parseInjectBearer(entries []string) ([]injectSpec, error) { + var specs []injectSpec + for _, e := range entries { + host, source, ok := strings.Cut(e, "=") + if !ok || host == "" || source == "" { + return nil, fmt.Errorf("--inject-bearer must be HOST=SOURCE, got %q", e) + } + if err := policy.ValidateDomains([]string{host}); err != nil { + return nil, fmt.Errorf("--inject-bearer host %q: %w", host, err) + } + specs = append(specs, injectSpec{host: host, header: "Authorization", prefix: "Bearer ", source: source}) + } + return specs, nil +} + +// parseInjectHeader parses --inject-header "HOST=HEADER=SOURCE" into a binding +// that sets an arbitrary request header (e.g. x-api-key) to the resolved token. +func parseInjectHeader(entries []string) ([]injectSpec, error) { + var specs []injectSpec + for _, e := range entries { + parts := strings.SplitN(e, "=", 3) + if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + return nil, fmt.Errorf("--inject-header must be HOST=HEADER=SOURCE, got %q", e) + } + host, header, source := parts[0], parts[1], parts[2] + if err := policy.ValidateDomains([]string{host}); err != nil { + return nil, fmt.Errorf("--inject-header host %q: %w", host, err) + } + if strings.ContainsAny(header, " \t:") { + return nil, fmt.Errorf("--inject-header header name %q is not a valid token", header) + } + specs = append(specs, injectSpec{host: host, header: header, source: source}) + } + return specs, nil +} + +// resolveInject generates the per-run CA, resolves each bound token, and +// delivers the CA to the sandbox trust store. It runs after proxy and env +// resolution. The CA key and tokens stay in the parent; only the public CA +// (in a combined bundle) and the placeholder-free env reach the child. +func resolveInject(plan *SandboxPlan, specs []injectSpec) error { + if len(specs) == 0 { + return nil + } + bindings := make(map[string][]proxy.Injection, len(specs)) + for _, s := range specs { + token, ok, err := resolveSecretSource(s.source, plan.Logger) + if err != nil { + return err + } + if !ok { + continue // optional source (@?VAR) absent — skip this binding + } + bindings[s.host] = append(bindings[s.host], proxy.Injection{Header: s.header, Value: s.prefix + token}) + } + if len(bindings) == 0 { + return nil // nothing to inject (all sources were optional and absent) + } + if !plan.ProxyEnabled { + return fmt.Errorf("credential injection requires the network proxy (needs user and network namespaces)") + } + ca, err := proxy.NewCA() + if err != nil { + return fmt.Errorf("generating per-run CA: %w", err) + } + plan.CA = ca + plan.InjectBindings = bindings + + // Trust-store delivery: a combined bundle (system roots + per-run CA) the + // action trusts for the proxy's leaf certs. The CA validates only inside + // this run, so it is not sensitive to the action. + bundle, err := writeCABundle(plan.TempDir, ca.CertPEM()) + if err != nil { + return err + } + plan.ROFiles = appendUniq(plan.ROFiles, bundle) + for _, k := range []string{"SSL_CERT_FILE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS"} { + plan.EnvSet[k] = bundle + } + return nil +} + +// resolveSecretSource reads a token from a credential-injection source. It +// returns ok=false (no error) when an optional @?ENV_VAR source is unset, so +// the caller can skip that binding. +// +// - @?VAR — optional: the var's value if set and non-empty, else skip. +// - @VAR — required: the var's value, or an error if unset or empty. +// - literal — returned as-is (warning that argv is world-readable). +func resolveSecretSource(source string, log *clog.Logger) (string, bool, error) { + if name, ok := strings.CutPrefix(source, "@?"); ok { + val, present := os.LookupEnv(name) + if !present || val == "" { + return "", false, nil + } + return val, true, nil + } + if name, ok := strings.CutPrefix(source, "@"); ok { + val, present := os.LookupEnv(name) + if !present || val == "" { + return "", false, fmt.Errorf("credential injection source $%s is unset or empty", name) + } + return val, true, nil + } + log.Warn("credential injection literal token is visible in process arguments; prefer @ENV_VAR") + return source, true, nil +} + +// writeCABundle writes a PEM bundle of the system roots plus the per-run CA to +// the temp dir and returns its path. +func writeCABundle(tmpDir string, caPEM []byte) (string, error) { + var buf []byte + if sys := systemCABundle(); sys != "" { + if data, err := os.ReadFile(sys); err == nil { + buf = append(buf, data...) + if len(buf) > 0 && buf[len(buf)-1] != '\n' { + buf = append(buf, '\n') + } + } + } + buf = append(buf, caPEM...) + path := filepath.Join(tmpDir, "ca-bundle.pem") + if err := os.WriteFile(path, buf, 0o644); err != nil { + return "", fmt.Errorf("writing CA bundle: %w", err) + } + return path, nil +} + +// systemCABundle returns the first system CA bundle found, or "". +func systemCABundle() string { + for _, p := range []string{ + "/etc/ssl/certs/ca-certificates.crt", // Debian, Ubuntu, Alpine + "/etc/pki/tls/certs/ca-bundle.crt", // Fedora, RHEL + "/etc/ssl/ca-bundle.pem", // openSUSE + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", // CentOS/RHEL 7 + "/etc/ssl/cert.pem", // macOS, some BSDs + } { + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + // resolveEnv applies environment policy, sets up shell init files, and writes // an SSH config for ProxyCommand routing through the SOCKS5 proxy. func resolveEnv(plan *SandboxPlan, cfg *config.Config) error { @@ -774,6 +937,23 @@ func (p *SandboxPlan) PrintDryRun(w io.Writer) { ln(" localhost: sandbox-internal (NO_PROXY)") } ln(" blocked: everything else") + if len(p.InjectBindings) > 0 { + hosts := make([]string, 0, len(p.InjectBindings)) + for h := range p.InjectBindings { + hosts = append(hosts, h) + } + sort.Strings(hosts) + ln(" inject: TLS terminated; credential headers added by the proxy (token never enters the sandbox)") + for _, h := range hosts { + var headers []string + for _, inj := range p.InjectBindings[h] { + headers = append(headers, inj.Header) + } + sort.Strings(headers) + pr(" %s: %s\n", h, strings.Join(headers, ", ")) + } + ln(" ca-trust: per-run CA bundle in env (SSL_CERT_FILE, CURL_CA_BUNDLE, GIT_SSL_CAINFO, REQUESTS_CA_BUNDLE, NODE_EXTRA_CA_CERTS)") + } } // Environment. @@ -868,6 +1048,16 @@ func applyEnvPolicy(plan *SandboxPlan, cfg *config.Config, tmpDir string) { } for _, pair := range cfg.EnvSet { k, v, _ := strings.Cut(pair, "=") + // A "?" prefix on the value makes the assignment conditional: apply it + // only if k is set in the host environment. Used to placeholder a + // credential the sandbox would otherwise receive, without introducing + // one when it is absent (e.g. ANTHROPIC_API_KEY for OAuth users). + if rest, ok := strings.CutPrefix(v, "?"); ok { + if _, present := os.LookupEnv(k); !present { + continue + } + v = rest + } // Expand $VAR references against the sandbox env built so far. v = os.Expand(v, func(name string) string { if val, ok := plan.EnvSet[name]; ok { @@ -1216,6 +1406,10 @@ func resolveSymlinks(paths []string) []string { func buildDegradedPlan(cfg *config.Config, caps *Capabilities) (*SandboxPlan, error) { plan := &SandboxPlan{Caps: caps} + if len(cfg.InjectBearer) > 0 || len(cfg.InjectHeader) > 0 { + return nil, fmt.Errorf("credential injection (--inject-bearer/--inject-header) is only supported on Linux") + } + if len(cfg.AllowedDomains) > 0 { plan.DegradedLayers = append(plan.DegradedLayers, DegradedLayer{ Layer: "network filtering", diff --git a/sandbox/plan_darwin.go b/sandbox/plan_darwin.go index 5a87782..830d18a 100644 --- a/sandbox/plan_darwin.go +++ b/sandbox/plan_darwin.go @@ -24,6 +24,9 @@ func (darwinPlanBuilder) BuildPlan(cfg *config.Config, caps *Capabilities, logge if caps.Seatbelt != nil { return nil, fmt.Errorf("fatal: sandbox-exec is required on macOS: %w", caps.Seatbelt) } + if len(cfg.InjectBearer) > 0 || len(cfg.InjectHeader) > 0 { + return nil, fmt.Errorf("credential injection (--inject-bearer/--inject-header) is only supported on Linux") + } plan := &SandboxPlan{Caps: caps, Quiet: cfg.Quiet, Logger: logger} var removals planRemovals diff --git a/sandbox/plan_linux.go b/sandbox/plan_linux.go index 90c588f..16dd9a4 100644 --- a/sandbox/plan_linux.go +++ b/sandbox/plan_linux.go @@ -17,6 +17,21 @@ func (linuxPlanBuilder) BuildPlan(cfg *config.Config, caps *Capabilities, logger plan := &SandboxPlan{Caps: caps, Quiet: cfg.Quiet, Logger: logger} var removals planRemovals + // Parse credential-injection bindings first and ensure their hosts are + // allowed, so capability/proxy resolution routes them through the proxy. + injects, err := parseInjectBearer(cfg.InjectBearer) + if err != nil { + return nil, err + } + headerInjects, err := parseInjectHeader(cfg.InjectHeader) + if err != nil { + return nil, err + } + injects = append(injects, headerInjects...) + for _, s := range injects { + cfg.AllowedDomains = appendUniq(cfg.AllowedDomains, s.host) + } + // Create tmpDir first — needed for sandbox HOME fallback. tmpDir, err := createTempDir() if err != nil { @@ -51,6 +66,9 @@ func (linuxPlanBuilder) BuildPlan(cfg *config.Config, caps *Capabilities, logger if err := resolveEnv(plan, cfg); err != nil { return nil, err } + if err := resolveInject(plan, injects); err != nil { + return nil, err + } resolveDenials(plan, &removals) if err := writeSkill(plan, plan.TempDir); err != nil { return nil, err diff --git a/sandbox/plan_test.go b/sandbox/plan_test.go index 191aacb..9d17243 100644 --- a/sandbox/plan_test.go +++ b/sandbox/plan_test.go @@ -189,6 +189,22 @@ func TestBuildPlan_NoLandlock_NoFSRestrict(t *testing.T) { defer plan.Cleanup() } +func TestBuildPlan_ConditionalEnvSet(t *testing.T) { + // "VAR=?value" applies only when VAR is set in the host environment. + t.Setenv("CURB_COND_PRESENT", "real") + cfg := &config.Config{EnvSet: []string{ + "CURB_COND_PRESENT=?placeholder", + "CURB_COND_ABSENT=?placeholder", + }} + plan, err := BuildPlan(cfg, minCaps(), nil) + require.NoError(t, err) + defer plan.Cleanup() + + assert.Equal(t, "placeholder", plan.EnvSet["CURB_COND_PRESENT"], "set when host var is present") + _, ok := plan.EnvSet["CURB_COND_ABSENT"] + assert.False(t, ok, "skipped when host var is absent") +} + func TestBuildPlan_NoExecRestrict(t *testing.T) { cfg := &config.Config{NoExecRestrict: true} plan, err := BuildPlan(cfg, minCaps(), nil) @@ -996,7 +1012,7 @@ func TestFormatSkill_NoFSSection(t *testing.T) { func TestFormatSkill_ExcludesSelfEnvVar(t *testing.T) { plan := &SandboxPlan{ EnvSet: map[string]string{ - "TMPDIR": "/tmp/curb-abc", + "TMPDIR": "/tmp/curb-abc", SkillDirEnvKey: "/tmp/curb-abc/.agents/skills/curb", }, } diff --git a/sandbox/proxy_handler.go b/sandbox/proxy_handler.go index 971d879..86f518a 100644 --- a/sandbox/proxy_handler.go +++ b/sandbox/proxy_handler.go @@ -21,9 +21,17 @@ func buildFilterBase(plan *SandboxPlan) proxy.FilterBase { // buildProxyHandler creates the HTTP proxy handler from the sandbox plan. func buildProxyHandler(plan *SandboxPlan) *proxy.Handler { - return &proxy.Handler{ - FilterBase: buildFilterBase(plan), + h := &proxy.Handler{FilterBase: buildFilterBase(plan)} + if plan.CA != nil && len(plan.InjectBindings) > 0 { + inj := proxy.NewInjector(plan.CA) + for host, injections := range plan.InjectBindings { + for _, injection := range injections { + inj.Bind(host, injection) + } + } + h.Injector = inj } + return h } // buildSOCKS5Server creates the SOCKS5 proxy server from the sandbox plan. diff --git a/sandbox/skill.go b/sandbox/skill.go index 3aed539..a920064 100644 --- a/sandbox/skill.go +++ b/sandbox/skill.go @@ -68,6 +68,14 @@ description: Active sandbox constraints for this environment. Check when encount } else if plan.ProxyEnabled { b.WriteString("- Localhost: sandbox-internal\n") } + if len(plan.InjectBindings) > 0 { + hosts := make([]string, 0, len(plan.InjectBindings)) + for h := range plan.InjectBindings { + hosts = append(hosts, h) + } + sort.Strings(hosts) + fmt.Fprintf(&b, "- Credential injection: auth headers are added automatically to requests for %s; no token is present in this sandbox, so do not expect or look for one.\n", strings.Join(hosts, ", ")) + } if !plan.ProxyEnabled && len(plan.AllowedDomains) == 0 && len(plan.AllowedIPs) == 0 { b.WriteString("- Allowed: none\n") } From f2aeb45f6cfc96d33566bd88731aa0a23ef7857a Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 15 Jun 2026 09:38:14 +0100 Subject: [PATCH 02/27] feat: respect a user-provided ALL_PROXY 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 --- CLAUDE.md | 1 + sandbox/plan.go | 13 +++++++++++-- sandbox/plan_test.go | 28 ++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index dcea204..c19dae9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,7 @@ go test ./sandbox/ -run TestCurb_FS_ -v # run a subset - `/etc` is not a blanket default. Only specific files/dirs are exposed (ssl, ca-certificates, ld.so, resolv.conf, hosts, passwd, etc.). Use `--read /etc` to restore full access. - `/proc` is safe as default RO: user namespace blocks ptrace-guarded access across namespace boundaries. - When the proxy is enabled, `NO_PROXY=localhost,127.0.0.1,::1` is set by default so sandbox-internal servers are reachable. `--host-loopback` suppresses this and forwards localhost traffic through the proxy to the host. User-provided `--env NO_PROXY=...` takes precedence. +- The SOCKS proxy is advertised via `ALL_PROXY`/`all_proxy` (HTTP/HTTPS still prefer `HTTP_PROXY`/`HTTPS_PROXY`). A user-provided value takes precedence, so `--env ALL_PROXY=` suppresses it — useful for HTTP-only workloads with clients that eagerly construct a SOCKS transport (e.g. httpx, which then needs the `httpx[socks]` extra). ssh routing does not use it (it has its own config). - `--ips` allows connections to specific IP addresses or CIDR ranges. Works alongside or independently of `--domains`. IP-matched connections bypass domain filtering (forwarded directly on any port via SOCKS5 or CONNECT). In IPs-only mode (no `--domains`), DNS queries are not intercepted. - `--unrestricted-net` skips network namespace creation entirely, allowing unrestricted network access while keeping FS sandboxing. Cannot be combined with `--domains` or `--ips`. - `--inject-bearer HOST=SOURCE` and `--inject-header HOST=HEADER=SOURCE` (Linux) terminate TLS for `HOST` only and set a credential header (`Authorization: Bearer `, or any header e.g. `x-api-key`), so the token never enters the sandbox. `--inject-bearer` is sugar for `--inject-header HOST=Authorization=Bearer …`. The proxy holds the credential and a per-run CA; curb delivers a combined CA bundle (system roots + per-run CA) to the child via the standard CA env vars. `SOURCE` is `@ENV_VAR` (kept out of argv) or a literal. Settable via the flags, `CURB_INJECT_BEARER`/`CURB_INJECT_HEADER`, or the `inject-bearer:`/`inject-header:` config-file/profile keys (all merged additively); the `@ENV_VAR` form resolves at run time regardless of source. The CA key and token are parent-only — never serialized to the child. `@?VAR` is an optional source (inject only if set), and `VAR=?value` sets an env var only if it is set on the host; the `claude` profile uses both to seal `ANTHROPIC_API_KEY` out of Claude Code when present while staying a no-op for OAuth users. diff --git a/sandbox/plan.go b/sandbox/plan.go index f684e3a..5378f27 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -1086,11 +1086,20 @@ func applyEnvPolicy(plan *SandboxPlan, cfg *config.Config, tmpDir string) { // SOCKS5 proxy env vars. Most HTTP clients prefer HTTP_PROXY/HTTPS_PROXY // over ALL_PROXY, so HTTP/HTTPS still goes through the HTTP proxy. // ALL_PROXY covers non-HTTP tools (curl --socks5, cargo, etc.). + // + // Respect a user-provided value (as NO_PROXY does below): set ALL_PROXY + // empty to suppress the SOCKS env for HTTP-only workloads. httpx eagerly + // builds a transport for ALL_PROXY at client construction and needs the + // socks extra otherwise; ssh routing does not use it (setupSSHConfig). if plan.SOCKSPort > 0 { socksAddr := fmt.Sprintf("127.0.0.1:%d", plan.SOCKSPort) socksURL := fmt.Sprintf("socks5h://%s", socksAddr) - plan.EnvSet["ALL_PROXY"] = socksURL - plan.EnvSet["all_proxy"] = socksURL + if _, ok := plan.EnvSet["ALL_PROXY"]; !ok { + plan.EnvSet["ALL_PROXY"] = socksURL + } + if _, ok := plan.EnvSet["all_proxy"]; !ok { + plan.EnvSet["all_proxy"] = socksURL + } plan.EnvSet[SOCKSAddrEnvKey] = socksAddr } } diff --git a/sandbox/plan_test.go b/sandbox/plan_test.go index 9d17243..1cbfee1 100644 --- a/sandbox/plan_test.go +++ b/sandbox/plan_test.go @@ -205,6 +205,34 @@ func TestBuildPlan_ConditionalEnvSet(t *testing.T) { assert.False(t, ok, "skipped when host var is absent") } +func TestBuildPlan_AllProxyDefaultAndOverride(t *testing.T) { + // By default the SOCKS env is advertised via ALL_PROXY. + cfg := &config.Config{AllowedDomains: []string{"example.com"}} + plan, err := BuildPlan(cfg, minCaps(), nil) + require.NoError(t, err) + defer plan.Cleanup() + require.True(t, plan.ProxyEnabled) + require.Positive(t, plan.SOCKSPort) + assert.True(t, strings.HasPrefix(plan.EnvSet["ALL_PROXY"], "socks5h://127.0.0.1:"), + "ALL_PROXY defaults to the SOCKS proxy, got %q", plan.EnvSet["ALL_PROXY"]) + + // A user-provided value wins (mirrors NO_PROXY): empty suppresses the SOCKS + // env for HTTP-only workloads (e.g. httpx without the socks extra), without + // disturbing curb's own SOCKS address var. + cfg = &config.Config{ + AllowedDomains: []string{"example.com"}, + EnvSet: []string{"ALL_PROXY=", "all_proxy="}, + } + plan, err = BuildPlan(cfg, minCaps(), nil) + require.NoError(t, err) + defer plan.Cleanup() + allProxy, ok := plan.EnvSet["ALL_PROXY"] + require.True(t, ok, "ALL_PROXY must remain set (empty), not be dropped") + assert.Empty(t, allProxy, "user-provided empty ALL_PROXY must be preserved") + assert.Empty(t, plan.EnvSet["all_proxy"]) + assert.NotEmpty(t, plan.EnvSet[SOCKSAddrEnvKey], "curb's SOCKS address var is unaffected") +} + func TestBuildPlan_NoExecRestrict(t *testing.T) { cfg := &config.Config{NoExecRestrict: true} plan, err := BuildPlan(cfg, minCaps(), nil) From 56255e321f0592e8381683857a728e0c4586e887 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 15 Jun 2026 10:37:03 +0100 Subject: [PATCH 03/27] fix: harden credential-injection host handling 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) --- docs/configuration.md | 4 +++- proxy/handler.go | 33 +++++++++++++++++++-------------- proxy/inject.go | 21 ++++++++++++++++++--- proxy/inject_test.go | 16 ++++++++++++++++ sandbox/inject_test.go | 19 ++++++++++++++++++- sandbox/plan.go | 25 +++++++++++++++++++++---- 6 files changed, 95 insertions(+), 23 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 0a1e322..be6af14 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -194,7 +194,9 @@ curb's proxy terminates TLS for `HOST`, presenting a per-run CA that the sandbox trusts, and adds `Authorization: Bearer ` to requests on their way to the real upstream. The sandboxed process holds no token — only the proxy does — and the credential is bound to `HOST`, so it is never attached to any other host the -program connects to. +program connects to. `HOST` must be an exact hostname — wildcards are rejected, +since they cannot identify the single destination a credential belongs to. The +host is matched case-insensitively and a trailing dot is ignored. `SOURCE` is one of: diff --git a/proxy/handler.go b/proxy/handler.go index 63a7098..26c2c63 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -111,20 +111,7 @@ func (h *Handler) handleHTTP(w http.ResponseWriter, r *http.Request) { } defer func() { _ = upstream.Close() }() - // Strip hop-by-hop headers before forwarding (RFC 7230 §6.1). - // Parse Connection header for additional hop-by-hop headers to remove. - for _, connHdr := range r.Header.Values("Connection") { - for part := range strings.SplitSeq(connHdr, ",") { - r.Header.Del(strings.TrimSpace(part)) - } - } - r.Header.Del("Connection") - r.Header.Del("Keep-Alive") - r.Header.Del("Proxy-Connection") - r.Header.Del("Proxy-Authorization") - r.Header.Del("TE") - r.Header.Del("Trailer") - r.Header.Del("Upgrade") + stripHopByHop(r.Header) r.Header.Set("Connection", "close") // Write the request to the upstream. @@ -154,6 +141,24 @@ func (h *Handler) handleHTTP(w http.ResponseWriter, r *http.Request) { relay(upstream, clientConn) } +// stripHopByHop removes hop-by-hop headers (RFC 7230 §6.1) before forwarding a +// request upstream, including any named in the Connection header. This keeps +// proxy-specific headers such as Proxy-Authorization from leaking to the origin. +func stripHopByHop(h http.Header) { + for _, connHdr := range h.Values("Connection") { + for part := range strings.SplitSeq(connHdr, ",") { + h.Del(strings.TrimSpace(part)) + } + } + h.Del("Connection") + h.Del("Keep-Alive") + h.Del("Proxy-Connection") + h.Del("Proxy-Authorization") + h.Del("TE") + h.Del("Trailer") + h.Del("Upgrade") +} + // splitHostPort splits host:port, using defaultPort if no port is present. func splitHostPort(hostport, defaultPort string) (string, string, error) { host, port, err := net.SplitHostPort(hostport) diff --git a/proxy/inject.go b/proxy/inject.go index 5b789c9..f264e9c 100644 --- a/proxy/inject.go +++ b/proxy/inject.go @@ -14,6 +14,7 @@ import ( "math/big" "net" "net/http" + "strings" "sync" "time" ) @@ -98,7 +99,11 @@ func (ca *CA) leafFor(host string) (*tls.Certificate, error) { if err != nil { return nil, err } - leaf := &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: ca.cert} + leafCert, err := x509.ParseCertificate(der) + if err != nil { + return nil, err + } + leaf := &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leafCert} ca.leaves[host] = leaf return leaf, nil } @@ -136,14 +141,22 @@ func NewInjector(ca *CA) *Injector { // Bind adds a header injection for a destination host. Multiple headers may be // bound to the same host. func (in *Injector) Bind(host string, inj Injection) { + host = normalizeHost(host) in.byHost[host] = append(in.byHost[host], inj) } func (in *Injector) binding(host string) ([]Injection, bool) { - injs, ok := in.byHost[host] + injs, ok := in.byHost[normalizeHost(host)] return injs, ok } +// normalizeHost lowercases and trims a trailing dot so binding keys match +// CONNECT targets regardless of case or root-label dot (api.example.com, +// API.EXAMPLE.COM, and api.example.com. are the same host). +func normalizeHost(host string) string { + return strings.TrimSuffix(strings.ToLower(host), ".") +} + // injectCONNECT terminates the client's TLS with a per-run leaf for host, // then forwards each decrypted request to the real upstream with the bound // credential injected. @@ -193,12 +206,14 @@ func (h *Handler) serveInjected(client net.Conn, host, port string, injs []Injec if err != nil { return } + // Strip hop-by-hop headers first so the client cannot drop an injected + // header by naming it in Connection. + stripHopByHop(req.Header) // Overwrite any client-supplied values: the sandbox holds only a // placeholder and must not pre-empt the real credential. for _, inj := range injs { req.Header.Set(inj.Header, inj.Value) } - req.Header.Del("Proxy-Connection") req.URL.Scheme = "https" req.URL.Host = net.JoinHostPort(host, port) req.RequestURI = "" diff --git a/proxy/inject_test.go b/proxy/inject_test.go index 9b3111b..a88f380 100644 --- a/proxy/inject_test.go +++ b/proxy/inject_test.go @@ -133,6 +133,22 @@ func get(t *testing.T, client *http.Client, rawURL string) string { return string(body) } +// TestInjector_BindingNormalizesHost confirms bindings match CONNECT targets +// regardless of case or a trailing dot. +func TestInjector_BindingNormalizesHost(t *testing.T) { + in := &Injector{byHost: map[string][]Injection{}} + in.Bind("API.GitHub.COM.", Injection{Header: "Authorization", Value: "Bearer t"}) + + for _, host := range []string{"api.github.com", "API.GITHUB.COM", "api.github.com.", "Api.GitHub.Com."} { + injs, ok := in.binding(host) + require.True(t, ok, "expected binding to match %q", host) + assert.Equal(t, "Bearer t", injs[0].Value) + } + + _, ok := in.binding("other.com") + assert.False(t, ok) +} + // TestInjector_InjectsBoundCredential verifies the placeholder request leaves // the sandbox with the real credential attached. func TestInjector_InjectsBoundCredential(t *testing.T) { diff --git a/sandbox/inject_test.go b/sandbox/inject_test.go index 7e90dec..04e104c 100644 --- a/sandbox/inject_test.go +++ b/sandbox/inject_test.go @@ -29,6 +29,18 @@ func TestParseInjectBearer(t *testing.T) { _, err := parseInjectBearer([]string{bad}) assert.Error(t, err, "expected error for %q", bad) } + + // Wildcards must be rejected: "*" would broaden the allowlist to every + // domain while never matching a binding. + for _, bad := range []string{"*=@T", "*.example.com=@T"} { + _, err := parseInjectBearer([]string{bad}) + assert.Error(t, err, "expected wildcard %q to be rejected", bad) + } + + // Hosts are normalized to lowercase with no trailing dot. + specs, err = parseInjectBearer([]string{"API.GitHub.COM.=@T"}) + require.NoError(t, err) + assert.Equal(t, "api.github.com", specs[0].host) } func TestParseInjectBearer_FillsAuthorization(t *testing.T) { @@ -53,10 +65,15 @@ func TestParseInjectHeader(t *testing.T) { require.NoError(t, err) assert.Equal(t, "ab=cd", specs[0].source) - for _, bad := range []string{"", "h.com", "h.com=x-api-key", "=x=y", "h.com==y", "h.com=bad header=y"} { + for _, bad := range []string{"", "h.com", "h.com=x-api-key", "=x=y", "h.com==y", "h.com=bad header=y", "*=x=@T", "*.h.com=x=@T"} { _, err := parseInjectHeader([]string{bad}) assert.Error(t, err, "expected error for %q", bad) } + + // Hosts are normalized to lowercase with no trailing dot. + specs, err = parseInjectHeader([]string{"API.Anthropic.COM.=x-api-key=@T"}) + require.NoError(t, err) + assert.Equal(t, "api.anthropic.com", specs[0].host) } func TestResolveSecretSource(t *testing.T) { diff --git a/sandbox/plan.go b/sandbox/plan.go index 5378f27..23840e8 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -598,14 +598,30 @@ func parseInjectBearer(entries []string) ([]injectSpec, error) { if !ok || host == "" || source == "" { return nil, fmt.Errorf("--inject-bearer must be HOST=SOURCE, got %q", e) } - if err := policy.ValidateDomains([]string{host}); err != nil { - return nil, fmt.Errorf("--inject-bearer host %q: %w", host, err) + host, err := normalizeInjectHost(host) + if err != nil { + return nil, fmt.Errorf("--inject-bearer host: %w", err) } specs = append(specs, injectSpec{host: host, header: "Authorization", prefix: "Bearer ", source: source}) } return specs, nil } +// normalizeInjectHost validates a credential-injection host and returns it +// normalized (lowercase, no trailing dot). Unlike --domains, an injection host +// must be an exact hostname: a wildcard would broaden the allowlist (in the +// case of "*", to every domain) while never matching a binding at runtime, so +// the credential would never be injected. +func normalizeInjectHost(host string) (string, error) { + if err := policy.ValidateDomains([]string{host}); err != nil { + return "", err + } + if strings.Contains(host, "*") { + return "", fmt.Errorf("%q must be an exact hostname (no wildcards)", host) + } + return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), "."), nil +} + // parseInjectHeader parses --inject-header "HOST=HEADER=SOURCE" into a binding // that sets an arbitrary request header (e.g. x-api-key) to the resolved token. func parseInjectHeader(entries []string) ([]injectSpec, error) { @@ -616,8 +632,9 @@ func parseInjectHeader(entries []string) ([]injectSpec, error) { return nil, fmt.Errorf("--inject-header must be HOST=HEADER=SOURCE, got %q", e) } host, header, source := parts[0], parts[1], parts[2] - if err := policy.ValidateDomains([]string{host}); err != nil { - return nil, fmt.Errorf("--inject-header host %q: %w", host, err) + host, err := normalizeInjectHost(host) + if err != nil { + return nil, fmt.Errorf("--inject-header host: %w", err) } if strings.ContainsAny(header, " \t:") { return nil, fmt.Errorf("--inject-header header name %q is not a valid token", header) From 09598d547f7568ed683b102e93d16c8e147fa897 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 15 Jun 2026 12:00:51 +0100 Subject: [PATCH 04/27] fix: address second-round credential-injection review 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) --- config/configfile.go | 8 ++--- config/configfile_test.go | 14 ++++++++ docs/configuration.md | 6 ++++ policy/validate.go | 15 +++++++++ policy/validate_test.go | 31 +++++++++++++++++ proxy/inject.go | 14 +++++--- sandbox/inject_test.go | 21 ++++++++---- sandbox/plan.go | 71 ++++++++++++++++++++++----------------- sandbox/skill.go | 2 +- 9 files changed, 137 insertions(+), 45 deletions(-) diff --git a/config/configfile.go b/config/configfile.go index 8dd64b1..7db5dd7 100644 --- a/config/configfile.go +++ b/config/configfile.go @@ -116,8 +116,8 @@ func (cf *ConfigFile) validate() error { if !ok || host == "" || source == "" { return fmt.Errorf("inject-bearer[%d] must be HOST=SOURCE, got %q", i, e) } - if err := policy.ValidateDomains([]string{host}); err != nil { - return fmt.Errorf("inject-bearer[%d] host %q: %w", i, host, err) + if _, err := policy.ValidateInjectHost(host); err != nil { + return fmt.Errorf("inject-bearer[%d] %w", i, err) } } for i, e := range cf.InjectHeader { @@ -125,8 +125,8 @@ func (cf *ConfigFile) validate() error { if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { return fmt.Errorf("inject-header[%d] must be HOST=HEADER=SOURCE, got %q", i, e) } - if err := policy.ValidateDomains([]string{parts[0]}); err != nil { - return fmt.Errorf("inject-header[%d] host %q: %w", i, parts[0], err) + if _, err := policy.ValidateInjectHost(parts[0]); err != nil { + return fmt.Errorf("inject-header[%d] %w", i, err) } } for _, pair := range [...]struct { diff --git a/config/configfile_test.go b/config/configfile_test.go index 1af8a30..614180f 100644 --- a/config/configfile_test.go +++ b/config/configfile_test.go @@ -178,6 +178,20 @@ inject-bearer: assert.Contains(t, err.Error(), "inject-bearer") } +func TestLoadConfigFile_InjectBearerWildcard(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".curb.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +inject-bearer: + - "*.github.com=@GH_TOKEN" +`), 0o644)) + + _, err := LoadConfigFile(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "inject-bearer") + assert.Contains(t, err.Error(), "exact hostname") +} + func TestLoadConfigFile_InjectHeader(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, ".curb.yaml") diff --git a/docs/configuration.md b/docs/configuration.md index be6af14..3d3bc6d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -241,6 +241,12 @@ environment variables (`SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, `REQUESTS_CA_BUNDLE`, `NODE_EXTRA_CA_CERTS`) at it, so common tools trust the terminated connection while still reaching other hosts normally. +After terminating TLS, curb parses the decrypted stream as HTTP/1.1 and does not +negotiate HTTP/2 (no `h2` ALPN). An HTTP/2-only client or protocol — some gRPC +setups, for example — may fail against a host with injection enabled. Hosts +without an injection binding keep the untouched passthrough relay and are +unaffected. + Both are also settable via environment variables (`CURB_INJECT_BEARER`, `CURB_INJECT_HEADER`, comma-separated) and the `inject-bearer:` / `inject-header:` config-file/profile keys, merged additively like `domains`. The diff --git a/policy/validate.go b/policy/validate.go index a826cb7..aef7f1e 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -59,6 +59,21 @@ func validateDomain(d string) error { return nil } +// ValidateInjectHost validates a credential-injection host and returns it +// normalized (lowercase, no trailing dot). Unlike a --domains pattern, an +// injection host must be an exact hostname: a wildcard cannot identify the +// single destination a credential belongs to, and "*" would broaden the +// allowlist to every domain while never matching a binding at runtime. +func ValidateInjectHost(host string) (string, error) { + if err := validateDomain(host); err != nil { + return "", err + } + if strings.Contains(host, "*") { + return "", fmt.Errorf("host %q must be an exact hostname (no wildcards)", host) + } + return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), "."), nil +} + // stripScheme removes http:// or https:// and any trailing path from a URL-like string. func stripScheme(s string) string { s = strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://") diff --git a/policy/validate_test.go b/policy/validate_test.go index 67ec16f..46753ea 100644 --- a/policy/validate_test.go +++ b/policy/validate_test.go @@ -51,6 +51,37 @@ func TestValidateDomains(t *testing.T) { } } +func TestValidateInjectHost(t *testing.T) { + tests := []struct { + name string + host string + want string + wantErr string + }{ + {"exact host", "api.github.com", "api.github.com", ""}, + {"lowercased", "API.GitHub.COM", "api.github.com", ""}, + {"trailing dot trimmed", "api.github.com.", "api.github.com", ""}, + {"both", "API.GitHub.COM.", "api.github.com", ""}, + + {"reject match-all", "*", "", "exact hostname"}, + {"reject wildcard", "*.github.com", "", "exact hostname"}, + {"reject URL", "https://api.github.com", "", "looks like a URL"}, + {"reject IP", "10.0.0.1", "", "use --ips instead"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ValidateInjectHost(tt.host) + if tt.wantErr == "" { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } else { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } + }) + } +} + func TestValidateIPs(t *testing.T) { tests := []struct { name string diff --git a/proxy/inject.go b/proxy/inject.go index f264e9c..e7bdf5b 100644 --- a/proxy/inject.go +++ b/proxy/inject.go @@ -129,12 +129,18 @@ type Injector struct { byHost map[string][]Injection } -// NewInjector creates an injector backed by the per-run CA. +// NewInjector creates an injector backed by the per-run CA. Its upstream +// transport uses the same dial and TLS-handshake timeouts as passthrough +// traffic so an injected request cannot hang indefinitely tying up a goroutine. func NewInjector(ca *CA) *Injector { return &Injector{ - CA: ca, - Upstream: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}}, - byHost: map[string][]Injection{}, + CA: ca, + Upstream: &http.Transport{ + DialContext: (&net.Dialer{Timeout: dialTimeout}).DialContext, + TLSHandshakeTimeout: dialTimeout, + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + }, + byHost: map[string][]Injection{}, } } diff --git a/sandbox/inject_test.go b/sandbox/inject_test.go index 04e104c..2290582 100644 --- a/sandbox/inject_test.go +++ b/sandbox/inject_test.go @@ -65,7 +65,11 @@ func TestParseInjectHeader(t *testing.T) { require.NoError(t, err) assert.Equal(t, "ab=cd", specs[0].source) - for _, bad := range []string{"", "h.com", "h.com=x-api-key", "=x=y", "h.com==y", "h.com=bad header=y", "*=x=@T", "*.h.com=x=@T"} { + for _, bad := range []string{ + "", "h.com", "h.com=x-api-key", "=x=y", "h.com==y", + "h.com=bad header=y", "*=x=@T", "*.h.com=x=@T", + "h.com=x/y=@T", "h.com=x(y)=@T", "h.com=a:b=@T", // invalid header-name tokens + } { _, err := parseInjectHeader([]string{bad}) assert.Error(t, err, "expected error for %q", bad) } @@ -112,16 +116,21 @@ func TestWriteCABundle(t *testing.T) { dir := t.TempDir() caPEM := []byte("-----BEGIN CERTIFICATE-----\nPERRUNCA\n-----END CERTIFICATE-----\n") + if systemCABundle() == "" { + // Without system roots, the bundle would override TLS trust with an + // incomplete set, so injection fails rather than break other hosts. + _, err := writeCABundle(dir, caPEM) + require.Error(t, err) + return + } + path, err := writeCABundle(dir, caPEM) require.NoError(t, err) assert.Equal(t, filepath.Join(dir, "ca-bundle.pem"), path) data, err := os.ReadFile(path) require.NoError(t, err) - // The per-run CA is always present; system roots are appended before it - // when a system bundle exists on the host. + // The per-run CA is appended after the system roots. assert.Contains(t, string(data), "PERRUNCA") - if sys := systemCABundle(); sys != "" { - assert.Greater(t, len(data), len(caPEM), "combined bundle should include system roots") - } + assert.Greater(t, len(data), len(caPEM), "combined bundle should include system roots") } diff --git a/sandbox/plan.go b/sandbox/plan.go index 23840e8..d9e1ca4 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -598,30 +598,15 @@ func parseInjectBearer(entries []string) ([]injectSpec, error) { if !ok || host == "" || source == "" { return nil, fmt.Errorf("--inject-bearer must be HOST=SOURCE, got %q", e) } - host, err := normalizeInjectHost(host) + host, err := policy.ValidateInjectHost(host) if err != nil { - return nil, fmt.Errorf("--inject-bearer host: %w", err) + return nil, fmt.Errorf("--inject-bearer %w", err) } specs = append(specs, injectSpec{host: host, header: "Authorization", prefix: "Bearer ", source: source}) } return specs, nil } -// normalizeInjectHost validates a credential-injection host and returns it -// normalized (lowercase, no trailing dot). Unlike --domains, an injection host -// must be an exact hostname: a wildcard would broaden the allowlist (in the -// case of "*", to every domain) while never matching a binding at runtime, so -// the credential would never be injected. -func normalizeInjectHost(host string) (string, error) { - if err := policy.ValidateDomains([]string{host}); err != nil { - return "", err - } - if strings.Contains(host, "*") { - return "", fmt.Errorf("%q must be an exact hostname (no wildcards)", host) - } - return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), "."), nil -} - // parseInjectHeader parses --inject-header "HOST=HEADER=SOURCE" into a binding // that sets an arbitrary request header (e.g. x-api-key) to the resolved token. func parseInjectHeader(entries []string) ([]injectSpec, error) { @@ -632,18 +617,39 @@ func parseInjectHeader(entries []string) ([]injectSpec, error) { return nil, fmt.Errorf("--inject-header must be HOST=HEADER=SOURCE, got %q", e) } host, header, source := parts[0], parts[1], parts[2] - host, err := normalizeInjectHost(host) + host, err := policy.ValidateInjectHost(host) if err != nil { - return nil, fmt.Errorf("--inject-header host: %w", err) + return nil, fmt.Errorf("--inject-header %w", err) } - if strings.ContainsAny(header, " \t:") { - return nil, fmt.Errorf("--inject-header header name %q is not a valid token", header) + if !validHeaderName(header) { + return nil, fmt.Errorf("--inject-header header name %q is not a valid token (RFC 7230)", header) } specs = append(specs, injectSpec{host: host, header: header, source: source}) } return specs, nil } +// validHeaderName reports whether name is a valid HTTP header field name (an +// RFC 7230 token). Rejecting invalid names here gives an immediate, clear error +// instead of a runtime 502 when net/http rejects the upstream request. +func validHeaderName(name string) bool { + if name == "" { + return false + } + // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / + // "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA (RFC 7230 §3.2.6). + const special = "!#$%&'*+-.^_`|~" + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + case strings.ContainsRune(special, r): + default: + return false + } + } + return true +} + // resolveInject generates the per-run CA, resolves each bound token, and // delivers the CA to the sandbox trust store. It runs after proxy and env // resolution. The CA key and tokens stay in the parent; only the public CA @@ -717,16 +723,21 @@ func resolveSecretSource(source string, log *clog.Logger) (string, bool, error) } // writeCABundle writes a PEM bundle of the system roots plus the per-run CA to -// the temp dir and returns its path. +// the temp dir and returns its path. It fails if the system roots cannot be +// located or read: this bundle replaces the sandbox's TLS trust (SSL_CERT_FILE +// etc.), so a bundle holding only the per-run CA would break trust for every +// HTTPS destination other than the injected hosts. func writeCABundle(tmpDir string, caPEM []byte) (string, error) { - var buf []byte - if sys := systemCABundle(); sys != "" { - if data, err := os.ReadFile(sys); err == nil { - buf = append(buf, data...) - if len(buf) > 0 && buf[len(buf)-1] != '\n' { - buf = append(buf, '\n') - } - } + sys := systemCABundle() + if sys == "" { + return "", fmt.Errorf("credential injection: no system CA bundle found; cannot deliver TLS trust to the sandbox without overriding it") + } + buf, err := os.ReadFile(sys) + if err != nil { + return "", fmt.Errorf("credential injection: reading system CA bundle %s: %w", sys, err) + } + if len(buf) > 0 && buf[len(buf)-1] != '\n' { + buf = append(buf, '\n') } buf = append(buf, caPEM...) path := filepath.Join(tmpDir, "ca-bundle.pem") diff --git a/sandbox/skill.go b/sandbox/skill.go index a920064..9afc4b8 100644 --- a/sandbox/skill.go +++ b/sandbox/skill.go @@ -74,7 +74,7 @@ description: Active sandbox constraints for this environment. Check when encount hosts = append(hosts, h) } sort.Strings(hosts) - fmt.Fprintf(&b, "- Credential injection: auth headers are added automatically to requests for %s; no token is present in this sandbox, so do not expect or look for one.\n", strings.Join(hosts, ", ")) + fmt.Fprintf(&b, "- Credential injection: credential headers are added automatically to requests for %s; no token is present in this sandbox, so do not expect or look for one.\n", strings.Join(hosts, ", ")) } if !plan.ProxyEnabled && len(plan.AllowedDomains) == 0 && len(plan.AllowedIPs) == 0 { b.WriteString("- Allowed: none\n") From f2f7145e49da06e5e1d354e4a2f245a161603431 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 15 Jun 2026 21:08:41 +0100 Subject: [PATCH 05/27] fix: address third-round credential-injection review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 2 +- config/configfile.go | 3 +++ config/configfile_test.go | 14 ++++++++++++++ docs/configuration.md | 10 ++++++---- policy/validate.go | 21 +++++++++++++++++++++ proxy/inject.go | 11 ++++++++++- proxy/inject_test.go | 20 ++++++++++++++++++++ sandbox/plan.go | 23 +---------------------- 8 files changed, 76 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index e1640c8..ddd8318 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ GH_TOKEN=ghp_xxx curb --inject-bearer 'api.github.com=@GH_TOKEN' -- gh api user | `--domains` | `CURB_DOMAINS` | Allowed domain patterns (comma-separated). `*.example.com` for subdomains, `*` to allow all. | | `--ips` | `CURB_IPS` | Allowed IP addresses or CIDR ranges (e.g. `10.0.0.1`, `192.168.0.0/16`, `::1`). | | `--inject-bearer` | `CURB_INJECT_BEARER` | Terminate TLS for a host and inject `Authorization: Bearer ` on the way out, so the token never enters the sandbox. `HOST=@ENV_VAR` reads the token from an env var (kept out of argv); `HOST=literal` also works but exposes it in process arguments. Repeatable; also settable via the `inject-bearer:` config-file/profile key. Linux only. | -| `--inject-header` | `CURB_INJECT_HEADER` | Like `--inject-bearer`, but injects an arbitrary request header: `HOST=HEADER=SOURCE` (e.g. `api.example.com=x-api-key=@KEY`). `--inject-bearer HOST=SOURCE` is sugar for `HOST=Authorization=Bearer …`. Repeatable; also a config-file/profile key. Linux only. | +| `--inject-header` | `CURB_INJECT_HEADER` | Like `--inject-bearer`, but injects an arbitrary request header: `HOST=HEADER=SOURCE` (e.g. `api.example.com=x-api-key=@KEY`). The header value is the resolved token verbatim (no `Bearer ` prefix). Repeatable; also a config-file/profile key. Linux only. | | `--host-loopback` | `CURB_HOST_LOOPBACK` | Forward localhost/127.0.0.1 traffic to the host loopback. Cannot combine with `--unrestricted-net`. | | `--unrestricted-net` | `CURB_UNRESTRICTED_NET` | Skip network filtering entirely. Cannot combine with `--domains`, `--ips`, or `--host-loopback`. | diff --git a/config/configfile.go b/config/configfile.go index 7db5dd7..4dd9ee0 100644 --- a/config/configfile.go +++ b/config/configfile.go @@ -128,6 +128,9 @@ func (cf *ConfigFile) validate() error { if _, err := policy.ValidateInjectHost(parts[0]); err != nil { return fmt.Errorf("inject-header[%d] %w", i, err) } + if !policy.ValidHeaderName(parts[1]) { + return fmt.Errorf("inject-header[%d] header name %q is not a valid token (RFC 7230)", i, parts[1]) + } } for _, pair := range [...]struct { field string diff --git a/config/configfile_test.go b/config/configfile_test.go index 614180f..71a94bc 100644 --- a/config/configfile_test.go +++ b/config/configfile_test.go @@ -218,6 +218,20 @@ inject-header: assert.Contains(t, err.Error(), "inject-header") } +func TestLoadConfigFile_InjectHeaderInvalidName(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".curb.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +inject-header: + - "api.example.com=bad header=@KEY" +`), 0o644)) + + _, err := LoadConfigFile(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "inject-header") + assert.Contains(t, err.Error(), "valid token") +} + func TestFindConfigFile_InCWD(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, ".curb.yaml") diff --git a/docs/configuration.md b/docs/configuration.md index 3d3bc6d..0a714fc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,7 +38,7 @@ Unknown keys are rejected. All fields are optional. ### List fields -`domains`, `ips`, `inject-bearer`, `read`, `write`, `exec`, `env` — merged additively. Config file values are prepended to CLI values, so CLI exclusions (`!`) can override config file entries. +`domains`, `ips`, `inject-bearer`, `inject-header`, `read`, `write`, `exec`, `env` — merged additively. Config file values are prepended to CLI values, so CLI exclusions (`!`) can override config file entries. ### Scalar fields @@ -212,9 +212,11 @@ GH_TOKEN=ghp_xxx curb --inject-bearer 'api.github.com=@GH_TOKEN' -- gh api user ``` For schemes that do not use a bearer token, `--inject-header HOST=HEADER=SOURCE` -injects an arbitrary request header. `--inject-bearer HOST=SOURCE` is exactly -sugar for `--inject-header HOST=Authorization=Bearer …`. For example, the -Anthropic API authenticates with the `x-api-key` header, not `Authorization`: +injects an arbitrary request header. It is the general form: `--inject-bearer` +is the same mechanism with the header fixed to `Authorization` and the value +prefixed with `Bearer ` (`--inject-header` sets the header value to the resolved +token verbatim, with no prefix). For example, the Anthropic API authenticates +with the `x-api-key` header, not `Authorization`: ``` # api.anthropic.com gets x-api-key from $ANTHROPIC_API_KEY; the sandbox never has it: diff --git a/policy/validate.go b/policy/validate.go index aef7f1e..930b80d 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -74,6 +74,27 @@ func ValidateInjectHost(host string) (string, error) { return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), "."), nil } +// ValidHeaderName reports whether name is a valid HTTP header field name (an +// RFC 7230 token). Rejecting invalid names early gives a clear error instead of +// a runtime 502 when net/http rejects the upstream request. +func ValidHeaderName(name string) bool { + if name == "" { + return false + } + // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / + // "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA (RFC 7230 §3.2.6). + const special = "!#$%&'*+-.^_`|~" + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + case strings.ContainsRune(special, r): + default: + return false + } + } + return true +} + // stripScheme removes http:// or https:// and any trailing path from a URL-like string. func stripScheme(s string) string { s = strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://") diff --git a/proxy/inject.go b/proxy/inject.go index e7bdf5b..a6f99ac 100644 --- a/proxy/inject.go +++ b/proxy/inject.go @@ -206,6 +206,12 @@ func (h *Handler) injectCONNECT(w http.ResponseWriter, host, port string, injs [ // each to the upstream with the bound credentials set, relaying the response. func (h *Handler) serveInjected(client net.Conn, host, port string, injs []Injection) { rt := h.Injector.Upstream + // authority is the upstream the request is bound to. Drop the default + // https port so the Host header matches what a direct client would send. + authority := host + if port != "443" { + authority = net.JoinHostPort(host, port) + } br := bufio.NewReader(client) for { req, err := http.ReadRequest(br) @@ -221,7 +227,10 @@ func (h *Handler) serveInjected(client net.Conn, host, port string, injs []Injec req.Header.Set(inj.Header, inj.Value) } req.URL.Scheme = "https" - req.URL.Host = net.JoinHostPort(host, port) + req.URL.Host = authority + // Align the Host header with the upstream we dial; the client may have + // sent a placeholder or an incorrect port. + req.Host = authority req.RequestURI = "" resp, err := rt.RoundTrip(req.WithContext(context.Background())) diff --git a/proxy/inject_test.go b/proxy/inject_test.go index a88f380..5cdf573 100644 --- a/proxy/inject_test.go +++ b/proxy/inject_test.go @@ -21,6 +21,7 @@ import ( type authRecorder struct { srv *httptest.Server headers []http.Header + hosts []string } func newAuthRecorder(t *testing.T) *authRecorder { @@ -28,6 +29,7 @@ func newAuthRecorder(t *testing.T) *authRecorder { rec := &authRecorder{} rec.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rec.headers = append(rec.headers, r.Header.Clone()) + rec.hosts = append(rec.hosts, r.Host) _, _ = io.WriteString(w, "ok") })) t.Cleanup(rec.srv.Close) @@ -149,6 +151,24 @@ func TestInjector_BindingNormalizesHost(t *testing.T) { assert.False(t, ok) } +// TestInjector_SetsHostHeader confirms the upstream request's Host header is +// the bound host, not whatever the client put on the decrypted request. +func TestInjector_SetsHostHeader(t *testing.T) { + ca, err := NewCA() + require.NoError(t, err) + + gh := newAuthRecorder(t) + proxyURL := injectTestProxy(t, ca, + map[string]string{"api.github.com": "ghs_realtoken"}, + map[string]*authRecorder{"api.github.com": gh}, + ) + client := clientThroughProxy(proxyURL, ca) + + get(t, client, "https://api.github.com/user") + require.Equal(t, 1, len(gh.hosts)) + assert.Equal(t, "api.github.com", gh.hosts[0]) +} + // TestInjector_InjectsBoundCredential verifies the placeholder request leaves // the sandbox with the real credential attached. func TestInjector_InjectsBoundCredential(t *testing.T) { diff --git a/sandbox/plan.go b/sandbox/plan.go index d9e1ca4..236c8b9 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -621,7 +621,7 @@ func parseInjectHeader(entries []string) ([]injectSpec, error) { if err != nil { return nil, fmt.Errorf("--inject-header %w", err) } - if !validHeaderName(header) { + if !policy.ValidHeaderName(header) { return nil, fmt.Errorf("--inject-header header name %q is not a valid token (RFC 7230)", header) } specs = append(specs, injectSpec{host: host, header: header, source: source}) @@ -629,27 +629,6 @@ func parseInjectHeader(entries []string) ([]injectSpec, error) { return specs, nil } -// validHeaderName reports whether name is a valid HTTP header field name (an -// RFC 7230 token). Rejecting invalid names here gives an immediate, clear error -// instead of a runtime 502 when net/http rejects the upstream request. -func validHeaderName(name string) bool { - if name == "" { - return false - } - // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / - // "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA (RFC 7230 §3.2.6). - const special = "!#$%&'*+-.^_`|~" - for _, r := range name { - switch { - case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': - case strings.ContainsRune(special, r): - default: - return false - } - } - return true -} - // resolveInject generates the per-run CA, resolves each bound token, and // delivers the CA to the sandbox trust store. It runs after proxy and env // resolution. The CA key and tokens stay in the parent; only the public CA From 09612816eab42f57a1d0df2c32db79858eaa923b Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 15 Jun 2026 21:22:05 +0100 Subject: [PATCH 06/27] doc: correct --inject-bearer description in CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --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) --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index c19dae9..06d801a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ go test ./sandbox/ -run TestCurb_FS_ -v # run a subset - The SOCKS proxy is advertised via `ALL_PROXY`/`all_proxy` (HTTP/HTTPS still prefer `HTTP_PROXY`/`HTTPS_PROXY`). A user-provided value takes precedence, so `--env ALL_PROXY=` suppresses it — useful for HTTP-only workloads with clients that eagerly construct a SOCKS transport (e.g. httpx, which then needs the `httpx[socks]` extra). ssh routing does not use it (it has its own config). - `--ips` allows connections to specific IP addresses or CIDR ranges. Works alongside or independently of `--domains`. IP-matched connections bypass domain filtering (forwarded directly on any port via SOCKS5 or CONNECT). In IPs-only mode (no `--domains`), DNS queries are not intercepted. - `--unrestricted-net` skips network namespace creation entirely, allowing unrestricted network access while keeping FS sandboxing. Cannot be combined with `--domains` or `--ips`. -- `--inject-bearer HOST=SOURCE` and `--inject-header HOST=HEADER=SOURCE` (Linux) terminate TLS for `HOST` only and set a credential header (`Authorization: Bearer `, or any header e.g. `x-api-key`), so the token never enters the sandbox. `--inject-bearer` is sugar for `--inject-header HOST=Authorization=Bearer …`. The proxy holds the credential and a per-run CA; curb delivers a combined CA bundle (system roots + per-run CA) to the child via the standard CA env vars. `SOURCE` is `@ENV_VAR` (kept out of argv) or a literal. Settable via the flags, `CURB_INJECT_BEARER`/`CURB_INJECT_HEADER`, or the `inject-bearer:`/`inject-header:` config-file/profile keys (all merged additively); the `@ENV_VAR` form resolves at run time regardless of source. The CA key and token are parent-only — never serialized to the child. `@?VAR` is an optional source (inject only if set), and `VAR=?value` sets an env var only if it is set on the host; the `claude` profile uses both to seal `ANTHROPIC_API_KEY` out of Claude Code when present while staying a no-op for OAuth users. +- `--inject-bearer HOST=SOURCE` and `--inject-header HOST=HEADER=SOURCE` (Linux) terminate TLS for `HOST` only and set a credential header (`Authorization: Bearer `, or any header e.g. `x-api-key`), so the token never enters the sandbox. `--inject-bearer` is the same mechanism as `--inject-header` with the header fixed to `Authorization` and the value prefixed with `Bearer ` (`--inject-header` sets the header value to the resolved token verbatim, no prefix). The proxy holds the credential and a per-run CA; curb delivers a combined CA bundle (system roots + per-run CA) to the child via the standard CA env vars. `SOURCE` is `@ENV_VAR` (kept out of argv) or a literal. Settable via the flags, `CURB_INJECT_BEARER`/`CURB_INJECT_HEADER`, or the `inject-bearer:`/`inject-header:` config-file/profile keys (all merged additively); the `@ENV_VAR` form resolves at run time regardless of source. The CA key and token are parent-only — never serialized to the child. `@?VAR` is an optional source (inject only if set), and `VAR=?value` sets an env var only if it is set on the host; the `claude` profile uses both to seal `ANTHROPIC_API_KEY` out of Claude Code when present while staying a no-op for OAuth users. - `--domains` validates input: rejects URLs (suggests bare domain), IP addresses (suggests `--ips`), invalid characters, and malformed wildcards. `--ips` validates that values parse as IP addresses or CIDR prefixes. - `!` prefix in list flags removes from defaults AND actively denies via overmount when the path is under an allowed parent. `--read '!/path'` hides (tmpfs/dev-null), `--write '!/path'` makes read-only, `--exec '!/path'` makes noexec. `!*` clears all defaults (not deny-all). `\!` escapes literal `!`. Sub-path denials require mount NS; Landlock-only mode warns. - HOME defaults to a private tmpDir. `--env HOME` passes through the host HOME; `--env HOME=/path` sets it explicitly. `~` and `$HOME` in config/profile paths both resolve to the **host** home (the shell's view), not the sandbox HOME — they are host-side shorthands. When sandbox HOME differs from host HOME and a path uses `~`, curb warns that the sandboxed program cannot reach those paths via its own `$HOME`. Built-in profiles include `HOME` in their env passthrough so sandbox HOME = host HOME and `~` paths align. See `docs/configuration.md` for full details. From ffb59cf5c71b3ce8fcd2e09cc5310ec21ca9771c Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 15 Jun 2026 22:49:51 +0100 Subject: [PATCH 07/27] refactor: header-agnostic credential injection via ENV_VAR=HOST 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 --- CLAUDE.md | 2 +- README.md | 5 +- cmd/help.go | 2 +- cmd/root.go | 5 +- config/config.go | 7 -- config/config_test.go | 18 +--- config/configfile.go | 25 ++--- config/configfile_test.go | 60 ++++-------- config/profile_test.go | 8 +- config/profiles/claude.yaml | 20 ++-- docs/comparison-zerobox.md | 74 ++++++++------- docs/configuration.md | 110 +++++++++++----------- policy/validate.go | 18 ++-- proxy/inject.go | 41 ++++++--- proxy/inject_test.go | 142 ++++++++++++++--------------- sandbox/capabilities_linux_test.go | 4 +- sandbox/inject_integration_test.go | 46 +++++----- sandbox/inject_test.go | 98 +++----------------- sandbox/plan.go | 128 +++++++++++--------------- sandbox/plan_darwin.go | 4 +- sandbox/plan_linux.go | 7 +- 21 files changed, 343 insertions(+), 481 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 06d801a..35af94e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ go test ./sandbox/ -run TestCurb_FS_ -v # run a subset - The SOCKS proxy is advertised via `ALL_PROXY`/`all_proxy` (HTTP/HTTPS still prefer `HTTP_PROXY`/`HTTPS_PROXY`). A user-provided value takes precedence, so `--env ALL_PROXY=` suppresses it — useful for HTTP-only workloads with clients that eagerly construct a SOCKS transport (e.g. httpx, which then needs the `httpx[socks]` extra). ssh routing does not use it (it has its own config). - `--ips` allows connections to specific IP addresses or CIDR ranges. Works alongside or independently of `--domains`. IP-matched connections bypass domain filtering (forwarded directly on any port via SOCKS5 or CONNECT). In IPs-only mode (no `--domains`), DNS queries are not intercepted. - `--unrestricted-net` skips network namespace creation entirely, allowing unrestricted network access while keeping FS sandboxing. Cannot be combined with `--domains` or `--ips`. -- `--inject-bearer HOST=SOURCE` and `--inject-header HOST=HEADER=SOURCE` (Linux) terminate TLS for `HOST` only and set a credential header (`Authorization: Bearer `, or any header e.g. `x-api-key`), so the token never enters the sandbox. `--inject-bearer` is the same mechanism as `--inject-header` with the header fixed to `Authorization` and the value prefixed with `Bearer ` (`--inject-header` sets the header value to the resolved token verbatim, no prefix). The proxy holds the credential and a per-run CA; curb delivers a combined CA bundle (system roots + per-run CA) to the child via the standard CA env vars. `SOURCE` is `@ENV_VAR` (kept out of argv) or a literal. Settable via the flags, `CURB_INJECT_BEARER`/`CURB_INJECT_HEADER`, or the `inject-bearer:`/`inject-header:` config-file/profile keys (all merged additively); the `@ENV_VAR` form resolves at run time regardless of source. The CA key and token are parent-only — never serialized to the child. `@?VAR` is an optional source (inject only if set), and `VAR=?value` sets an env var only if it is set on the host; the `claude` profile uses both to seal `ANTHROPIC_API_KEY` out of Claude Code when present while staying a no-op for OAuth users. +- `--inject-header ENV_VAR=HOST` (Linux) terminates TLS for `HOST` only and replaces a placeholder with a real credential in request headers, so the token never enters the sandbox. curb generates a stable per-var placeholder, sets the child's `ENV_VAR` to it (sealing — the sandbox sees the placeholder, never the real value), reads the real value from the host's `ENV_VAR`, and the proxy substitutes the placeholder wherever the client put it among the request headers (`Authorization: Bearer `, `x-api-key: `, any header). It is header-agnostic by design: curb needs no knowledge of the host's auth scheme, so a fast-changing API surface and end users writing config are not coupled to a wire-header name. The binding is var-first (`ENV_VAR=HOST`) because a credential belongs to its variable and may be valid for several hosts — a comma-separated host list is a natural future extension. The proxy holds the real credential and a per-run CA; curb delivers a combined CA bundle (system roots + per-run CA) to the child via the standard CA env vars. The left side is always an env var name (no literals — there would be no var to carry the placeholder). Injection is opt-in per credential: if `ENV_VAR` is unset or empty on the host, the binding is skipped silently (so the `claude` profile is a no-op for OAuth users). Settable via the flag, `CURB_INJECT_HEADER`, or the `inject-header:` config-file/profile key (all merged additively); the value resolves at run time. The CA key and real token are parent-only — never serialized to the child. - `--domains` validates input: rejects URLs (suggests bare domain), IP addresses (suggests `--ips`), invalid characters, and malformed wildcards. `--ips` validates that values parse as IP addresses or CIDR prefixes. - `!` prefix in list flags removes from defaults AND actively denies via overmount when the path is under an allowed parent. `--read '!/path'` hides (tmpfs/dev-null), `--write '!/path'` makes read-only, `--exec '!/path'` makes noexec. `!*` clears all defaults (not deny-all). `\!` escapes literal `!`. Sub-path denials require mount NS; Landlock-only mode warns. - HOME defaults to a private tmpDir. `--env HOME` passes through the host HOME; `--env HOME=/path` sets it explicitly. `~` and `$HOME` in config/profile paths both resolve to the **host** home (the shell's view), not the sandbox HOME — they are host-side shorthands. When sandbox HOME differs from host HOME and a path uses `~`, curb warns that the sandboxed program cannot reach those paths via its own `$HOME`. Built-in profiles include `HOME` in their env passthrough so sandbox HOME = host HOME and `~` paths align. See `docs/configuration.md` for full details. diff --git a/README.md b/README.md index ddd8318..4a72a55 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ Inject a credential the sandboxed process never sees — the token stays in curb's proxy, which adds it to requests bound for the named host: ``` -GH_TOKEN=ghp_xxx curb --inject-bearer 'api.github.com=@GH_TOKEN' -- gh api user +GH_TOKEN=ghp_xxx curb --inject-header 'GH_TOKEN=api.github.com' -- gh api user ``` ## CLI reference @@ -169,8 +169,7 @@ GH_TOKEN=ghp_xxx curb --inject-bearer 'api.github.com=@GH_TOKEN' -- gh api user |------|---------|-------------| | `--domains` | `CURB_DOMAINS` | Allowed domain patterns (comma-separated). `*.example.com` for subdomains, `*` to allow all. | | `--ips` | `CURB_IPS` | Allowed IP addresses or CIDR ranges (e.g. `10.0.0.1`, `192.168.0.0/16`, `::1`). | -| `--inject-bearer` | `CURB_INJECT_BEARER` | Terminate TLS for a host and inject `Authorization: Bearer ` on the way out, so the token never enters the sandbox. `HOST=@ENV_VAR` reads the token from an env var (kept out of argv); `HOST=literal` also works but exposes it in process arguments. Repeatable; also settable via the `inject-bearer:` config-file/profile key. Linux only. | -| `--inject-header` | `CURB_INJECT_HEADER` | Like `--inject-bearer`, but injects an arbitrary request header: `HOST=HEADER=SOURCE` (e.g. `api.example.com=x-api-key=@KEY`). The header value is the resolved token verbatim (no `Bearer ` prefix). Repeatable; also a config-file/profile key. Linux only. | +| `--inject-header` | `CURB_INJECT_HEADER` | Terminate TLS for a host and replace a placeholder with a real credential in request headers, so the token never enters the sandbox. `ENV_VAR=HOST` reads the real value from the host's env var and seals the sandbox's copy to a placeholder; the proxy substitutes it wherever the client sent it (any header — `Authorization: Bearer`, `x-api-key`, etc.), so no auth-scheme knowledge is needed. Skipped if `ENV_VAR` is unset. Repeatable; also settable via the `inject-header:` config-file/profile key. Linux only. | | `--host-loopback` | `CURB_HOST_LOOPBACK` | Forward localhost/127.0.0.1 traffic to the host loopback. Cannot combine with `--unrestricted-net`. | | `--unrestricted-net` | `CURB_UNRESTRICTED_NET` | Skip network filtering entirely. Cannot combine with `--domains`, `--ips`, or `--host-loopback`. | diff --git a/cmd/help.go b/cmd/help.go index 2557e19..437c49f 100644 --- a/cmd/help.go +++ b/cmd/help.go @@ -20,7 +20,7 @@ type flagGroup struct { // flagGroups defines the order and grouping of flags in help output. var flagGroups = []flagGroup{ {"Filesystem", []string{"read", "write"}}, - {"Network", []string{"domains", "ips", "inject-bearer", "inject-header", "unrestricted-net", "host-loopback", "allow-unix-sockets"}}, + {"Network", []string{"domains", "ips", "inject-header", "unrestricted-net", "host-loopback", "allow-unix-sockets"}}, {"Executables", []string{"exec"}}, {"Environment", []string{"env"}}, {"Profiles & Config", []string{"profiles", "auto", "config-file"}}, diff --git a/cmd/root.go b/cmd/root.go index 4662089..37ff700 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,7 +28,7 @@ Use -- before the command when it has its own flags.`, curb -p node -w . -- npm install # profile + writable cwd curb -a cargo build # auto-select profile curb --dry-run -p node -- npm install # preview sandbox plan - curb --inject-bearer api.github.com=@TOK -- gh pr list # inject a token, kept out of the sandbox`, + curb --inject-header GH_TOKEN=api.github.com -- gh pr list # inject a token, kept out of the sandbox`, Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { @@ -330,8 +330,7 @@ func registerFlags(cmd *cobra.Command) { f.StringSlice("ips", nil, "allowed IP/CIDR ranges (e.g. 10.0.0.1, 192.168.0.0/16)") f.Bool("unrestricted-net", false, "skip network restrictions entirely") f.Bool("host-loopback", false, "forward localhost traffic to host loopback") - f.StringSlice("inject-bearer", nil, "terminate TLS for HOST and inject 'Authorization: Bearer ' (HOST=@ENV_VAR reads the token from an env var, keeping it out of argv)") - f.StringSlice("inject-header", nil, "terminate TLS for HOST and inject an arbitrary request header (HOST=HEADER=@ENV_VAR, e.g. api.example.com=x-api-key=@KEY)") + f.StringSlice("inject-header", nil, "inject ENV_VAR's value as a credential to HOST, kept out of the sandbox (ENV_VAR=HOST; TLS terminated for HOST, the sandbox sees only a placeholder; skipped if ENV_VAR is unset)") // Executables. f.StringSlice("exec", nil, "allowed executables (default: invoked command only)") diff --git a/config/config.go b/config/config.go index be218e4..00bf0af 100644 --- a/config/config.go +++ b/config/config.go @@ -21,7 +21,6 @@ type Config struct { EnvSet []string EnvPassthroughAll bool AllowedIPs []string - InjectBearer []string InjectHeader []string UnrestrictedNet bool NoFSRestrict bool @@ -70,10 +69,6 @@ func FromFlags(cmd *cobra.Command) (*Config, error) { if err != nil { return nil, err } - injectBearer, err := flags.GetStringSlice("inject-bearer") - if err != nil { - return nil, err - } injectHeader, err := flags.GetStringSlice("inject-header") if err != nil { return nil, err @@ -133,7 +128,6 @@ func FromFlags(cmd *cobra.Command) (*Config, error) { cfg := &Config{ AllowedDomains: allow, AllowedIPs: ips, - InjectBearer: injectBearer, InjectHeader: injectHeader, UnrestrictedNet: unrestrictedNet, ROPaths: ro, @@ -181,7 +175,6 @@ func MergeEnv(cfg *Config, cmd *cobra.Command) { // List values: always additive, with wildcard detection. cfg.AllowedDomains = appendEnvList(cfg.AllowedDomains, "CURB_DOMAINS") cfg.AllowedIPs = appendEnvList(cfg.AllowedIPs, "CURB_IPS") - cfg.InjectBearer = appendEnvList(cfg.InjectBearer, "CURB_INJECT_BEARER") cfg.InjectHeader = appendEnvList(cfg.InjectHeader, "CURB_INJECT_HEADER") roEnv := appendEnvList(nil, "CURB_READ") diff --git a/config/config_test.go b/config/config_test.go index 2b62efa..6adca33 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -22,7 +22,6 @@ func newTestCmd(args []string) *cobra.Command { f.StringSlice("exec", nil, "") f.StringSlice("env", nil, "") f.StringSlice("ips", nil, "") - f.StringSlice("inject-bearer", nil, "") f.StringSlice("inject-header", nil, "") f.Bool("unrestricted-net", false, "") f.Bool("allow-unix-sockets", false, "") @@ -126,26 +125,15 @@ func TestMergeEnv_ListsAdditive(t *testing.T) { assert.Equal(t, []string{"b.com", "a.com"}, cfg.AllowedDomains) } -func TestMergeEnv_InjectBearerAdditive(t *testing.T) { - cmd := newTestCmd([]string{"--inject-bearer", "b.com=@B"}) - cfg, err := FromFlags(cmd) - require.NoError(t, err) - - t.Setenv("CURB_INJECT_BEARER", "a.com=@A") - MergeEnv(cfg, cmd) - - assert.Equal(t, []string{"b.com=@B", "a.com=@A"}, cfg.InjectBearer) -} - func TestMergeEnv_InjectHeaderAdditive(t *testing.T) { - cmd := newTestCmd([]string{"--inject-header", "b.com=x-tok=@B"}) + cmd := newTestCmd([]string{"--inject-header", "B=b.com"}) cfg, err := FromFlags(cmd) require.NoError(t, err) - t.Setenv("CURB_INJECT_HEADER", "a.com=x-api-key=@A") + t.Setenv("CURB_INJECT_HEADER", "A=a.com") MergeEnv(cfg, cmd) - assert.Equal(t, []string{"b.com=x-tok=@B", "a.com=x-api-key=@A"}, cfg.InjectHeader) + assert.Equal(t, []string{"B=b.com", "A=a.com"}, cfg.InjectHeader) } func TestMergeEnv_CommaSeparatedList(t *testing.T) { diff --git a/config/configfile.go b/config/configfile.go index 4dd9ee0..166cc79 100644 --- a/config/configfile.go +++ b/config/configfile.go @@ -19,7 +19,6 @@ type ConfigFile struct { Profiles []string `yaml:"profiles"` Domains []string `yaml:"domains"` IPs []string `yaml:"ips"` - InjectBearer []string `yaml:"inject-bearer"` InjectHeader []string `yaml:"inject-header"` Read pathList `yaml:"read"` Write pathList `yaml:"write"` @@ -111,25 +110,16 @@ func (cf *ConfigFile) validate() error { return err } } - for i, e := range cf.InjectBearer { - host, source, ok := strings.Cut(e, "=") - if !ok || host == "" || source == "" { - return fmt.Errorf("inject-bearer[%d] must be HOST=SOURCE, got %q", i, e) - } - if _, err := policy.ValidateInjectHost(host); err != nil { - return fmt.Errorf("inject-bearer[%d] %w", i, err) - } - } for i, e := range cf.InjectHeader { - parts := strings.SplitN(e, "=", 3) - if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { - return fmt.Errorf("inject-header[%d] must be HOST=HEADER=SOURCE, got %q", i, e) + envVar, host, ok := strings.Cut(e, "=") + if !ok || envVar == "" || host == "" { + return fmt.Errorf("inject-header[%d] must be ENV_VAR=HOST, got %q", i, e) } - if _, err := policy.ValidateInjectHost(parts[0]); err != nil { - return fmt.Errorf("inject-header[%d] %w", i, err) + if !policy.ValidEnvName(envVar) { + return fmt.Errorf("inject-header[%d] env var name %q is not valid", i, envVar) } - if !policy.ValidHeaderName(parts[1]) { - return fmt.Errorf("inject-header[%d] header name %q is not a valid token (RFC 7230)", i, parts[1]) + if _, err := policy.ValidateInjectHost(host); err != nil { + return fmt.Errorf("inject-header[%d] %w", i, err) } } for _, pair := range [...]struct { @@ -175,7 +165,6 @@ func FindConfigFile() string { func mergeConfigLists(cfg *Config, cf *ConfigFile) { cfg.AllowedDomains = append(cf.Domains, cfg.AllowedDomains...) cfg.AllowedIPs = append(cf.IPs, cfg.AllowedIPs...) - cfg.InjectBearer = append(cf.InjectBearer, cfg.InjectBearer...) cfg.InjectHeader = append(cf.InjectHeader, cfg.InjectHeader...) cfg.ROPaths = append(expandEnvPaths([]string(cf.Read), "read"), cfg.ROPaths...) cfg.RWPaths = append(expandEnvPaths([]string(cf.Write), "write"), cfg.RWPaths...) diff --git a/config/configfile_test.go b/config/configfile_test.go index 71a94bc..bcdb215 100644 --- a/config/configfile_test.go +++ b/config/configfile_test.go @@ -152,70 +152,44 @@ func TestLoadConfigFile_NotFound(t *testing.T) { require.Error(t, err) } -func TestLoadConfigFile_InjectBearer(t *testing.T) { +func TestLoadConfigFile_InjectHeader(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, ".curb.yaml") require.NoError(t, os.WriteFile(path, []byte(` -inject-bearer: - - api.github.com=@GH_TOKEN +inject-header: + - ANTHROPIC_API_KEY=api.anthropic.com `), 0o644)) cf, err := LoadConfigFile(path) require.NoError(t, err) - assert.Equal(t, []string{"api.github.com=@GH_TOKEN"}, cf.InjectBearer) + assert.Equal(t, []string{"ANTHROPIC_API_KEY=api.anthropic.com"}, cf.InjectHeader) } -func TestLoadConfigFile_InjectBearerInvalid(t *testing.T) { +func TestLoadConfigFile_InjectHeaderMissingHost(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, ".curb.yaml") require.NoError(t, os.WriteFile(path, []byte(` -inject-bearer: - - missing-source -`), 0o644)) - - _, err := LoadConfigFile(path) - require.Error(t, err) - assert.Contains(t, err.Error(), "inject-bearer") -} - -func TestLoadConfigFile_InjectBearerWildcard(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, ".curb.yaml") - require.NoError(t, os.WriteFile(path, []byte(` -inject-bearer: - - "*.github.com=@GH_TOKEN" +inject-header: + - ANTHROPIC_API_KEY `), 0o644)) _, err := LoadConfigFile(path) require.Error(t, err) - assert.Contains(t, err.Error(), "inject-bearer") - assert.Contains(t, err.Error(), "exact hostname") -} - -func TestLoadConfigFile_InjectHeader(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, ".curb.yaml") - require.NoError(t, os.WriteFile(path, []byte(` -inject-header: - - api.anthropic.com=x-api-key=@ANTHROPIC_API_KEY -`), 0o644)) - - cf, err := LoadConfigFile(path) - require.NoError(t, err) - assert.Equal(t, []string{"api.anthropic.com=x-api-key=@ANTHROPIC_API_KEY"}, cf.InjectHeader) + assert.Contains(t, err.Error(), "inject-header") } -func TestLoadConfigFile_InjectHeaderInvalid(t *testing.T) { +func TestLoadConfigFile_InjectHeaderWildcard(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, ".curb.yaml") require.NoError(t, os.WriteFile(path, []byte(` inject-header: - - api.anthropic.com=x-api-key + - "GH_TOKEN=*.github.com" `), 0o644)) _, err := LoadConfigFile(path) require.Error(t, err) assert.Contains(t, err.Error(), "inject-header") + assert.Contains(t, err.Error(), "exact hostname") } func TestLoadConfigFile_InjectHeaderInvalidName(t *testing.T) { @@ -223,13 +197,13 @@ func TestLoadConfigFile_InjectHeaderInvalidName(t *testing.T) { path := filepath.Join(dir, ".curb.yaml") require.NoError(t, os.WriteFile(path, []byte(` inject-header: - - "api.example.com=bad header=@KEY" + - "bad-var=api.example.com" `), 0o644)) _, err := LoadConfigFile(path) require.Error(t, err) assert.Contains(t, err.Error(), "inject-header") - assert.Contains(t, err.Error(), "valid token") + assert.Contains(t, err.Error(), "not valid") } func TestFindConfigFile_InCWD(t *testing.T) { @@ -279,15 +253,14 @@ func TestFindConfigFile_NotFound(t *testing.T) { } func TestMergeConfigFile_ListsPrepend(t *testing.T) { - cmd := newTestCmd([]string{"--domains", "cli.com", "--inject-bearer", "cli.com=@C", "--inject-header", "cli.com=x-tok=@H"}) + cmd := newTestCmd([]string{"--domains", "cli.com", "--inject-header", "H=cli.com"}) cfg, err := FromFlags(cmd) require.NoError(t, err) cf := &ConfigFile{ Domains: []string{"file.com"}, IPs: []string{"10.0.0.1"}, - InjectBearer: []string{"file.com=@F"}, - InjectHeader: []string{"file.com=x-api-key=@K"}, + InjectHeader: []string{"K=file.com"}, Read: []string{"/opt"}, Write: []string{"/data"}, Exec: []string{"python3"}, @@ -297,8 +270,7 @@ func TestMergeConfigFile_ListsPrepend(t *testing.T) { assert.Equal(t, []string{"file.com", "cli.com"}, cfg.AllowedDomains) assert.Equal(t, []string{"10.0.0.1"}, cfg.AllowedIPs) - assert.Equal(t, []string{"file.com=@F", "cli.com=@C"}, cfg.InjectBearer) - assert.Equal(t, []string{"file.com=x-api-key=@K", "cli.com=x-tok=@H"}, cfg.InjectHeader) + assert.Equal(t, []string{"K=file.com", "H=cli.com"}, cfg.InjectHeader) assert.Equal(t, []string{"/opt"}, cfg.ROPaths) assert.Equal(t, []string{"/data"}, cfg.RWPaths) assert.Equal(t, []string{"python3"}, cfg.ExecAllow) diff --git a/config/profile_test.go b/config/profile_test.go index c447f0c..7034459 100644 --- a/config/profile_test.go +++ b/config/profile_test.go @@ -133,10 +133,10 @@ func TestMergeProfiles_ClaudeSealsApiKey(t *testing.T) { assert.Contains(t, cfg.AllowedDomains, "api.anthropic.com") - // The key is injected as x-api-key, optionally (@?) from the host env var, - // and the sandbox gets only a conditional ("?") placeholder. - assert.Contains(t, cfg.InjectHeader, "api.anthropic.com=x-api-key=@?ANTHROPIC_API_KEY") - assert.Contains(t, cfg.EnvSet, "ANTHROPIC_API_KEY=?curb-sealed-placeholder") + // The key is injected to api.anthropic.com from the host env var; curb seals + // the sandbox's ANTHROPIC_API_KEY to a placeholder itself, and skips + // injection when the host var is unset (OAuth). + assert.Contains(t, cfg.InjectHeader, "ANTHROPIC_API_KEY=api.anthropic.com") // The real key is not passed through (it would defeat the seal). assert.NotContains(t, cfg.EnvPassthrough, "ANTHROPIC_API_KEY") } diff --git a/config/profiles/claude.yaml b/config/profiles/claude.yaml index a1798e7..1469ea9 100644 --- a/config/profiles/claude.yaml +++ b/config/profiles/claude.yaml @@ -8,11 +8,11 @@ # # Credential sealing (Linux): when ANTHROPIC_API_KEY is set on the host, it is # kept out of the sandbox — the process sees only a placeholder, and curb's -# proxy injects the real key as the x-api-key header on requests to -# api.anthropic.com. This is conditional (? / @? markers): with no host key set, -# nothing is injected and OAuth/subscription auth works unchanged. On first run, -# Claude Code may prompt once to approve the placeholder as a custom API key. -# Caveat: a custom ANTHROPIC_BASE_URL is not covered (the seal targets +# proxy replaces that placeholder with the real key in requests to +# api.anthropic.com (in whatever header Claude Code sends it). With no host key +# set, nothing is injected and OAuth/subscription auth works unchanged. On first +# run, Claude Code may prompt once to approve the placeholder as a custom API +# key. Caveat: a custom ANTHROPIC_BASE_URL is not covered (the seal targets # api.anthropic.com). commands: [claude] # Node.js/Bun use AF_UNIX socketpairs internally for child_process.spawn() stdio. @@ -53,16 +53,16 @@ exec: - /bin - /usr/local/bin - /opt/homebrew/bin -# Seal ANTHROPIC_API_KEY: inject the real key (from the host env) as x-api-key, -# and give the sandbox only a placeholder. Both are conditional on the host -# having the key set, so OAuth users are unaffected. +# Seal ANTHROPIC_API_KEY: the sandbox sees only a placeholder, and curb's proxy +# replaces it with the real key (read from the host env) in requests to +# api.anthropic.com — whichever header Claude Code sends it in. Skipped when the +# host key is unset, so OAuth users are unaffected. inject-header: - - api.anthropic.com=x-api-key=@?ANTHROPIC_API_KEY + - ANTHROPIC_API_KEY=api.anthropic.com env: - HOME - SHELL - CLAUDE_CODE_TMPDIR=$TMPDIR - - ANTHROPIC_API_KEY=?curb-sealed-placeholder - ANTHROPIC_BASE_URL - CLAUDE_CODE_MAX_TOKENS - GIT_AUTHOR_NAME diff --git a/docs/comparison-zerobox.md b/docs/comparison-zerobox.md index 7e4a3e2..f515bd4 100644 --- a/docs/comparison-zerobox.md +++ b/docs/comparison-zerobox.md @@ -79,7 +79,7 @@ network filtering is active. **curb** runs both an HTTP proxy and a SOCKS5 proxy. HTTPS uses CONNECT passthrough by default: the proxy checks the hostname from the CONNECT request, then tunnels the encrypted stream without terminating TLS. The -opt-in `--inject-bearer` (see below) terminates TLS for a +opt-in `--inject-header` (see below) terminates TLS for a named host only, to inject a credential; every other host stays passthrough. The SOCKS5 proxy handles non-HTTP TCP (e.g. SSH via `ProxyCommand`). Both filter on the CONNECT hostname or SOCKS5 destination, not on TLS @@ -91,7 +91,7 @@ The MITM requirement for zerobox's secret injection is a significant tradeoff: it breaks certificate pinning, requires injecting a custom CA into every TLS-using tool, and means the proxy can see all HTTPS content. curb avoids TLS termination by default, and confines it to the -specific hosts named in `--inject-bearer` when injection is used — every +specific hosts named in `--inject-header` when injection is used — every other host still tunnels untouched. The lack of SOCKS5 in zerobox means non-HTTP TCP protocols (SSH, git://, @@ -113,28 +113,35 @@ only for approved hosts. The placeholder is 32 random bytes so it is long and unique enough not to collide with legitimate request content during that scan. -**curb** has opt-in credential injection via -`--inject-bearer HOST=SOURCE` (`Authorization: Bearer`) and -`--inject-header HOST=HEADER=SOURCE` (any request header, e.g. `x-api-key` for -the Anthropic API) — Linux only. The sandboxed -process need not hold the real credential at all. The proxy terminates TLS for -`HOST` (presenting a per-run CA the sandbox trusts) and sets the header, bound -to that host. The token is read from an env var (`@ENV_VAR`, kept out of argv) -or a literal. Without the flag, curb performs no injection and no TLS -termination; secrets otherwise reach the process only via `--env`. - -Two differences from zerobox: curb terminates TLS only for the hosts named in -the flags, not for all HTTPS while secrets are active; and it *sets* a named -request header rather than scanning the request for a placeholder and -substituting it wherever it appears. zerobox's model is more general — it can -substitute in the request body, not just headers. curb's is header-only but -leaves untouched traffic untouched. zerobox's unguessable placeholder is a -requirement of that scanning (the marker must not collide with real content), -not a separate security property: curb sets the header to the real value and -overwrites whatever the sandbox sent, so its placeholder is never matched -against traffic and its value carries no security weight. Generalizing curb to -body substitution would also widen the proxy's attacker-facing surface (it -would parse and rewrite bodies), which cuts against keeping injection narrow. +**curb** has opt-in credential injection via `--inject-header ENV_VAR=HOST` +(Linux only). The two tools' mechanisms have converged: the sandbox sees only a +placeholder in `ENV_VAR`, never the real value; the proxy terminates TLS for +`HOST` (presenting a per-run CA the sandbox trusts) and substitutes the +placeholder with the real value wherever the client put it among the request +headers. Like zerobox, it is header-agnostic — `Authorization: Bearer`, +`x-api-key`, or any other header all work, with no auth-scheme knowledge +configured. Without the flag, curb performs no injection and no TLS termination; +secrets otherwise reach the process only via `--env`. + +The substantive difference is blast radius. zerobox flips its proxy into MITM +mode for *all* allowed HTTPS once any secret is configured (`config.network.mitm` +is global); curb terminates TLS only for the hosts named in `--inject-header` and +leaves every other host on untouched passthrough. The two designs are otherwise +close: both are header-only (zerobox's `substitute_headers` and curb's +`replaceInHeaders` both scan header values, not request bodies), both bind a +secret to approved hosts, and both let the program choose which header carries +the marker. + +Two smaller differences. First, zerobox's placeholder is 32 random bytes (unique +per run) to avoid colliding with real content during the scan; curb uses a +stable, distinctive per-variable sentinel, which keeps it collision-safe while +letting a tool that approves a custom credential (e.g. Claude Code) approve it +once rather than every run. Both placeholders carry no secret weight — neither is +the credential. Second, zerobox couples the secret to an env var name +(`--secret KEY=VALUE`) and the host separately (`--secret-host KEY=hosts`); curb's +single `ENV_VAR=HOST` does both — the env var both reads the real value on the +host and carries the placeholder in the sandbox, and curb seals it automatically. +Both put the variable first, leaving room for a credential to name several hosts. Both approaches prevent a compromised or malicious process from reading the real secret and exfiltrating it. The shared cost is TLS termination and CA @@ -242,8 +249,8 @@ Linux. curb has no external runtime dependencies. | Default FS write | Blocked | Blocked | | Default network | Blocked | Blocked | | FS enforcement | bwrap (mount NS) + Landlock fallback | pivot_root (mount NS) + Landlock layered | -| Network filtering | HTTP proxy (MITM when secrets active) | HTTP + SOCKS5 proxy (CONNECT passthrough; per-host TLS termination only with `--inject-bearer`) | -| Secret injection | Yes (MITM proxy, placeholder substitution) | Opt-in per host, header injection (`--inject-bearer`/`--inject-header`, Linux) | +| Network filtering | HTTP proxy (MITM when secrets active) | HTTP + SOCKS5 proxy (CONNECT passthrough; per-host TLS termination only with `--inject-header`) | +| Secret injection | Yes (MITM all HTTPS, placeholder substitution) | Yes (`--inject-header`, placeholder substitution, TLS terminated per host, Linux) | | Snapshot/restore | Yes (BLAKE3 + Merkle tree) | No (stateless) | | IP/CIDR filtering | No | Yes (`--ips`) | | SOCKS5 (non-HTTP TCP) | No | Yes | @@ -269,15 +276,12 @@ process from ever seeing real credentials. This is valuable for AI agent use cases where the agent needs to call authenticated APIs but should not be able to read or exfiltrate the actual tokens. -curb now has an opt-in version of this: `--inject-bearer` and -`--inject-header` terminate TLS for a named host and set a credential header -(`Authorization: Bearer` or any header, several per host), keeping the token -out of the sandbox. Unlike zerobox, TLS termination is confined to the named -hosts rather than applied to all HTTPS while secrets are active. Remaining -work to approach zerobox's generality: substituting a secret in the request -body, not just headers (weigh against the added body-parsing surface), and -reading the token from an inherited fd so it touches neither argv nor the -environment. +curb now implements the same model: `--inject-header ENV_VAR=HOST` seals the +sandbox's copy of the variable to a placeholder and has the proxy substitute the +real value in request headers, header-agnostically. Unlike zerobox, TLS +termination is confined to the named hosts rather than applied to all allowed +HTTPS while secrets are active. Possible future work: reading the token from an +inherited fd so it touches neither argv nor the environment. A lighter-weight, complementary option: support secret-aware environment variables that are populated only at exec time and excluded from diff --git a/docs/configuration.md b/docs/configuration.md index 0a714fc..a52a6a4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,7 +38,7 @@ Unknown keys are rejected. All fields are optional. ### List fields -`domains`, `ips`, `inject-bearer`, `inject-header`, `read`, `write`, `exec`, `env` — merged additively. Config file values are prepended to CLI values, so CLI exclusions (`!`) can override config file entries. +`domains`, `ips`, `inject-header`, `read`, `write`, `exec`, `env` — merged additively. Config file values are prepended to CLI values, so CLI exclusions (`!`) can override config file entries. ### Scalar fields @@ -189,54 +189,57 @@ And passes through: `PATH`, `TERM`, `COLORTERM`, `NO_COLOR`, `LANG`, `LC_ALL`, ` > Linux only. -`--inject-bearer HOST=SOURCE` keeps a bearer token out of the sandbox entirely. -curb's proxy terminates TLS for `HOST`, presenting a per-run CA that the sandbox -trusts, and adds `Authorization: Bearer ` to requests on their way to the -real upstream. The sandboxed process holds no token — only the proxy does — and -the credential is bound to `HOST`, so it is never attached to any other host the -program connects to. `HOST` must be an exact hostname — wildcards are rejected, -since they cannot identify the single destination a credential belongs to. The -host is matched case-insensitively and a trailing dot is ignored. - -`SOURCE` is one of: - -- `@ENV_VAR` — read the token from an environment variable. Preferred: the value - is not visible in `/proc//cmdline`. -- `@?ENV_VAR` — optional: inject only if the variable is set; otherwise skip the - binding silently (no error). Use when the credential may be absent. -- a literal token — accepted, but visible in the process arguments (curb warns). - -``` -# Token read from $GH_TOKEN, never placed in the sandbox: -GH_TOKEN=ghp_xxx curb --inject-bearer 'api.github.com=@GH_TOKEN' -- gh api user -``` - -For schemes that do not use a bearer token, `--inject-header HOST=HEADER=SOURCE` -injects an arbitrary request header. It is the general form: `--inject-bearer` -is the same mechanism with the header fixed to `Authorization` and the value -prefixed with `Bearer ` (`--inject-header` sets the header value to the resolved -token verbatim, with no prefix). For example, the Anthropic API authenticates -with the `x-api-key` header, not `Authorization`: +`--inject-header ENV_VAR=HOST` keeps a credential out of the sandbox entirely. +curb generates a stable placeholder for `ENV_VAR`, sets the sandbox's copy of +`ENV_VAR` to that placeholder (so the process never sees the real value), and +reads the real value from the *host's* `ENV_VAR`. Its proxy terminates TLS for +`HOST`, presenting a per-run CA that the sandbox trusts, and replaces the +placeholder with the real value wherever the client placed it among the request +headers on the way to the real upstream. The credential is bound to `HOST`, so +it is never attached to any other host the program connects to. `HOST` must be +an exact hostname — wildcards are rejected, since they cannot identify the single +destination a credential belongs to. The host is matched case-insensitively and a +trailing dot is ignored. + +The binding is written variable-first because a credential belongs to its +variable and may be valid for more than one host; a comma-separated host list is +a natural future extension. The left side is always an environment variable name +— there is no literal form, because a literal would have no variable through +which to deliver the placeholder into the sandbox. + +Injection is **header-agnostic**: curb substitutes the placeholder in whatever +header the client emits, so it needs no knowledge of the host's auth scheme. The +same binding works whether the client sends `Authorization: Bearer `, +`x-api-key: `, or any other header — useful when the wire detail varies +across tools or changes over time, and so that a binding can be written by +someone who knows the credential's env var but not the API's header. + +Injection is opt-in per credential: if `ENV_VAR` is unset or empty on the host, +the binding is skipped silently (no error). This is what lets a profile carry an +injection that simply does nothing when the credential is absent. ``` -# api.anthropic.com gets x-api-key from $ANTHROPIC_API_KEY; the sandbox never has it: -ANTHROPIC_API_KEY=sk-… curb --inject-header 'api.anthropic.com=x-api-key=@ANTHROPIC_API_KEY' -- ... +# Real value read from the host's $GH_TOKEN; the sandbox sees only a placeholder, +# and gh sends it in whatever header it uses: +GH_TOKEN=ghp_xxx curb --inject-header 'GH_TOKEN=api.github.com' -- gh api user ``` -The built-in **`claude`** profile does exactly this for Claude Code: *when -`ANTHROPIC_API_KEY` is set on the host* it puts only a placeholder in the -sandbox and injects the real key as `x-api-key`. It uses the conditional markers -(`?` on the env value, `@?` on the injection source), so with no host key set it -is a no-op and OAuth/subscription auth works unchanged. Linux only; on first run -Claude Code may prompt once to approve the placeholder as a custom key. A custom -`ANTHROPIC_BASE_URL` is not covered (the seal targets `api.anthropic.com`). - -A sandbox env var can be set *conditionally* with a `?` prefix on its value: -`VAR=?placeholder` applies only when `VAR` is set in the host environment. This -is how a credential is replaced with a placeholder without introducing one when -it is absent. - -The flag is repeatable (one binding per host) and implies network filtering: the +The placeholder is constant per variable (e.g. `curb-sealed-placeholder-GH_TOKEN`) +and carries no secret weight — the proxy overwrites it before the request leaves +the host. Keeping it stable means a tool that approves a custom credential (such +as Claude Code prompting to approve a custom API key) approves it once rather +than on every run. + +The built-in **`claude`** profile does exactly this for Claude Code: it injects +`ANTHROPIC_API_KEY=api.anthropic.com`, so *when `ANTHROPIC_API_KEY` is set on the +host* the sandbox sees only the placeholder and the proxy substitutes the real +key in whichever header Claude Code sends (`x-api-key` or, for OAuth, +`Authorization`). With no host key set it is a no-op and OAuth/subscription auth +works unchanged. Linux only; on first run Claude Code may prompt once to approve +the placeholder as a custom key. A custom `ANTHROPIC_BASE_URL` is not covered +(the seal targets `api.anthropic.com`). + +The flag is repeatable (bindings accumulate) and implies network filtering: the host is added to the allowlist and routed through the proxy. curb generates a combined CA bundle (system roots plus the per-run CA) and points the standard CA environment variables (`SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, @@ -249,17 +252,16 @@ setups, for example — may fail against a host with injection enabled. Hosts without an injection binding keep the untouched passthrough relay and are unaffected. -Both are also settable via environment variables (`CURB_INJECT_BEARER`, -`CURB_INJECT_HEADER`, comma-separated) and the `inject-bearer:` / -`inject-header:` config-file/profile keys, merged additively like `domains`. The -`@ENV_VAR` form is resolved at run time wherever the binding comes from, so -prefer it in config files and profiles — only the variable name is stored, never -the token: +It is also settable via the `CURB_INJECT_HEADER` environment variable +(comma-separated) and the `inject-header:` config-file/profile key, merged +additively like `domains`. The value is resolved at run time wherever the +binding comes from, so prefer config files and profiles — only the variable name +is stored, never the token: ```yaml -# .curb.yaml — the token is read from $GH_TOKEN at run time, not committed. -inject-bearer: - - api.github.com=@GH_TOKEN +# .curb.yaml — the value is read from the host's $GH_TOKEN at run time, not committed. +inject-header: + - GH_TOKEN=api.github.com ``` ## HOME and tilde expansion diff --git a/policy/validate.go b/policy/validate.go index 930b80d..fae0839 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -74,20 +74,18 @@ func ValidateInjectHost(host string) (string, error) { return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), "."), nil } -// ValidHeaderName reports whether name is a valid HTTP header field name (an -// RFC 7230 token). Rejecting invalid names early gives a clear error instead of -// a runtime 502 when net/http rejects the upstream request. -func ValidHeaderName(name string) bool { +// ValidEnvName reports whether name is a valid environment variable name (a C +// identifier: a letter or underscore, then letters, digits, or underscores). +// Credential-injection sources name the carrier var this way, so a typo is +// caught at parse time instead of silently matching nothing. +func ValidEnvName(name string) bool { if name == "" { return false } - // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / - // "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA (RFC 7230 §3.2.6). - const special = "!#$%&'*+-.^_`|~" - for _, r := range name { + for i, r := range name { switch { - case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': - case strings.ContainsRune(special, r): + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '_': + case i > 0 && r >= '0' && r <= '9': default: return false } diff --git a/proxy/inject.go b/proxy/inject.go index a6f99ac..dd2dcb6 100644 --- a/proxy/inject.go +++ b/proxy/inject.go @@ -108,11 +108,13 @@ func (ca *CA) leafFor(host string) (*tls.Certificate, error) { return leaf, nil } -// Injection is a credential bound to a destination host: the proxy sets Header -// to Value on requests it forwards to that host, and to no other host. +// Injection is a credential bound to a destination host: on requests the proxy +// forwards to that host, it replaces every occurrence of Placeholder in the +// request header values with Value, and does so for no other host. The sandbox +// holds only Placeholder; Value (the real credential) is parent-only. type Injection struct { - Header string - Value string + Placeholder string + Value string } // Injector terminates TLS for bound hosts and injects their credentials. A host @@ -218,14 +220,14 @@ func (h *Handler) serveInjected(client net.Conn, host, port string, injs []Injec if err != nil { return } - // Strip hop-by-hop headers first so the client cannot drop an injected - // header by naming it in Connection. + // Strip hop-by-hop headers first so the client cannot smuggle a + // placeholder past substitution by naming a header in Connection. stripHopByHop(req.Header) - // Overwrite any client-supplied values: the sandbox holds only a - // placeholder and must not pre-empt the real credential. - for _, inj := range injs { - req.Header.Set(inj.Header, inj.Value) - } + // Replace the placeholder with the real credential wherever the client + // placed it among the request headers. The sandbox never holds the real + // value, so this is where the credential is first introduced. Header + // values only: bodies and the request URI are left untouched. + replaceInHeaders(req.Header, injs) req.URL.Scheme = "https" req.URL.Host = authority // Align the Host header with the upstream we dial; the client may have @@ -246,6 +248,23 @@ func (h *Handler) serveInjected(client net.Conn, host, port string, injs []Injec } } +// replaceInHeaders substitutes each binding's placeholder with its real value in +// every request header value. Placement is the client's choice — Authorization: +// Bearer , x-api-key: , or any other header all work, so the proxy needs +// no knowledge of the host's auth scheme. +func replaceInHeaders(hdr http.Header, injs []Injection) { + for _, values := range hdr { + for i, v := range values { + for _, inj := range injs { + if inj.Placeholder != "" { + v = strings.ReplaceAll(v, inj.Placeholder, inj.Value) + } + } + values[i] = v + } + } +} + func writeGatewayError(c net.Conn) { _, _ = c.Write([]byte("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")) } diff --git a/proxy/inject_test.go b/proxy/inject_test.go index 5cdf573..1638c41 100644 --- a/proxy/inject_test.go +++ b/proxy/inject_test.go @@ -48,19 +48,10 @@ func (r *authRecorder) got(name string) []string { return out } -// injectTestProxy starts a curb proxy that allows the given hosts and injects -// the configured bearer tokens, routing upstream to the local recorders. -func injectTestProxy(t *testing.T, ca *CA, bindings map[string]string, upstreams map[string]*authRecorder) *url.URL { - t.Helper() - binds := make(map[string][]Injection, len(bindings)) - for host, token := range bindings { - binds[host] = []Injection{{Header: "Authorization", Value: "Bearer " + token}} - } - return injectTestProxyWith(t, ca, binds, upstreams) -} - -// injectTestProxyWith starts a curb proxy with arbitrary header injections. -func injectTestProxyWith(t *testing.T, ca *CA, bindings map[string][]Injection, upstreams map[string]*authRecorder) *url.URL { +// injectTestProxy starts a curb proxy that allows the given hosts and applies +// the configured placeholder substitutions, routing upstream to the local +// recorders. +func injectTestProxy(t *testing.T, ca *CA, bindings map[string][]Injection, upstreams map[string]*authRecorder) *url.URL { t.Helper() allowed := map[string]bool{} @@ -135,16 +126,32 @@ func get(t *testing.T, client *http.Client, rawURL string) string { return string(body) } +// getWithHeader issues a GET through the proxy with one header set, returning +// the upstream's response body. +func getWithHeader(t *testing.T, client *http.Client, rawURL, name, value string) string { + t.Helper() + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + require.NoError(t, err) + req.Header.Set(name, value) + resp, err := client.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return string(body) +} + // TestInjector_BindingNormalizesHost confirms bindings match CONNECT targets // regardless of case or a trailing dot. func TestInjector_BindingNormalizesHost(t *testing.T) { in := &Injector{byHost: map[string][]Injection{}} - in.Bind("API.GitHub.COM.", Injection{Header: "Authorization", Value: "Bearer t"}) + in.Bind("API.GitHub.COM.", Injection{Placeholder: "PH", Value: "real"}) for _, host := range []string{"api.github.com", "API.GITHUB.COM", "api.github.com.", "Api.GitHub.Com."} { injs, ok := in.binding(host) require.True(t, ok, "expected binding to match %q", host) - assert.Equal(t, "Bearer t", injs[0].Value) + assert.Equal(t, "real", injs[0].Value) } _, ok := in.binding("other.com") @@ -159,7 +166,7 @@ func TestInjector_SetsHostHeader(t *testing.T) { gh := newAuthRecorder(t) proxyURL := injectTestProxy(t, ca, - map[string]string{"api.github.com": "ghs_realtoken"}, + map[string][]Injection{"api.github.com": {{Placeholder: "GH_PH", Value: "ghs_realtoken"}}}, map[string]*authRecorder{"api.github.com": gh}, ) client := clientThroughProxy(proxyURL, ca) @@ -169,28 +176,52 @@ func TestInjector_SetsHostHeader(t *testing.T) { assert.Equal(t, "api.github.com", gh.hosts[0]) } -// TestInjector_InjectsBoundCredential verifies the placeholder request leaves -// the sandbox with the real credential attached. -func TestInjector_InjectsBoundCredential(t *testing.T) { +// TestInjector_ReplacesPlaceholder verifies the placeholder the sandbox sends is +// replaced with the real credential, wherever the client placed it. +func TestInjector_ReplacesPlaceholder(t *testing.T) { ca, err := NewCA() require.NoError(t, err) gh := newAuthRecorder(t) proxyURL := injectTestProxy(t, ca, - map[string]string{"api.github.com": "ghs_realtoken"}, + map[string][]Injection{"api.github.com": {{Placeholder: "GH_PH", Value: "ghs_realtoken"}}}, map[string]*authRecorder{"api.github.com": gh}, ) client := clientThroughProxy(proxyURL, ca) - body := get(t, client, "https://api.github.com/user") + body := getWithHeader(t, client, "https://api.github.com/user", "Authorization", "Bearer GH_PH") assert.Equal(t, "ok", body) require.Equal(t, 1, gh.count()) assert.Equal(t, "Bearer ghs_realtoken", gh.got("Authorization")[0]) } +// TestInjector_HeaderAgnostic is the headline property: one binding works +// whatever header the client carries the placeholder in — no auth-scheme +// knowledge is configured. +func TestInjector_HeaderAgnostic(t *testing.T) { + ca, err := NewCA() + require.NoError(t, err) + + for _, tc := range []struct{ header, value, want string }{ + {"Authorization", "Bearer ANT_PH", "Bearer sk-ant-real"}, + {"x-api-key", "ANT_PH", "sk-ant-real"}, + } { + up := newAuthRecorder(t) + proxyURL := injectTestProxy(t, ca, + map[string][]Injection{"api.anthropic.com": {{Placeholder: "ANT_PH", Value: "sk-ant-real"}}}, + map[string]*authRecorder{"api.anthropic.com": up}, + ) + client := clientThroughProxy(proxyURL, ca) + + getWithHeader(t, client, "https://api.anthropic.com/v1/models", tc.header, tc.value) + require.Equal(t, 1, up.count()) + assert.Equal(t, tc.want, up.got(tc.header)[0], "header %s", tc.header) + } +} + // TestInjector_BindsCredentialToDestination is the central correctness -// property: a credential is attached only to the host it was provisioned for, -// never stapled to another host the agent names. +// property: a placeholder is substituted only at the host it was provisioned +// for. A placeholder for one host is not substituted when sent to another. func TestInjector_BindsCredentialToDestination(t *testing.T) { ca, err := NewCA() require.NoError(t, err) @@ -198,9 +229,9 @@ func TestInjector_BindsCredentialToDestination(t *testing.T) { gh := newAuthRecorder(t) other := newAuthRecorder(t) proxyURL := injectTestProxy(t, ca, - map[string]string{ - "api.github.com": "ghs_realtoken", - "api.example.com": "example_token", + map[string][]Injection{ + "api.github.com": {{Placeholder: "GH_PH", Value: "ghs_realtoken"}}, + "api.example.com": {{Placeholder: "EX_PH", Value: "example_token"}}, }, map[string]*authRecorder{ "api.github.com": gh, @@ -209,66 +240,35 @@ func TestInjector_BindsCredentialToDestination(t *testing.T) { ) client := clientThroughProxy(proxyURL, ca) - get(t, client, "https://api.github.com/user") - get(t, client, "https://api.example.com/data") + // Send github's placeholder to both hosts: only github substitutes it. + getWithHeader(t, client, "https://api.github.com/user", "Authorization", "Bearer GH_PH") + getWithHeader(t, client, "https://api.example.com/data", "Authorization", "Bearer GH_PH") require.Equal(t, 1, gh.count()) require.Equal(t, 1, other.count()) - // Each host sees only its own credential. assert.Equal(t, "Bearer ghs_realtoken", gh.got("Authorization")[0]) - assert.Equal(t, "Bearer example_token", other.got("Authorization")[0]) - assert.NotContains(t, gh.got("Authorization")[0], "example_token") + // example.com has no binding for github's placeholder, so it passes through + // unsubstituted — github's real token never reaches another host. + assert.Equal(t, "Bearer GH_PH", other.got("Authorization")[0]) assert.NotContains(t, other.got("Authorization")[0], "ghs_realtoken") } -// TestInjector_PlaceholderIsOverwritten confirms a placeholder the sandbox -// might send is replaced, not honored. -func TestInjector_PlaceholderIsOverwritten(t *testing.T) { +// TestInjector_LeavesOtherHeadersUntouched confirms only the placeholder is +// replaced; unrelated header content passes through verbatim. +func TestInjector_LeavesOtherHeadersUntouched(t *testing.T) { ca, err := NewCA() require.NoError(t, err) gh := newAuthRecorder(t) proxyURL := injectTestProxy(t, ca, - map[string]string{"api.github.com": "ghs_realtoken"}, + map[string][]Injection{"api.github.com": {{Placeholder: "GH_PH", Value: "ghs_realtoken"}}}, map[string]*authRecorder{"api.github.com": gh}, ) client := clientThroughProxy(proxyURL, ca) - req, err := http.NewRequest(http.MethodGet, "https://api.github.com/user", nil) - require.NoError(t, err) - req.Header.Set("Authorization", "Bearer gh_placeholder") - resp, err := client.Do(req) - require.NoError(t, err) - defer func() { _ = resp.Body.Close() }() - + // No placeholder present: nothing is substituted, and the value is intact. + getWithHeader(t, client, "https://api.github.com/user", "X-Custom", "untouched-value") require.Equal(t, 1, gh.count()) - assert.Equal(t, "Bearer ghs_realtoken", gh.got("Authorization")[0]) -} - -// TestInjector_InjectsArbitraryHeader covers --inject-header: a non-bearer -// header (e.g. x-api-key, as Anthropic uses) is set on the outbound request. -func TestInjector_InjectsArbitraryHeader(t *testing.T) { - ca, err := NewCA() - require.NoError(t, err) - - up := newAuthRecorder(t) - proxyURL := injectTestProxyWith(t, ca, - map[string][]Injection{ - "api.anthropic.com": {{Header: "x-api-key", Value: "sk-ant-real"}}, - }, - map[string]*authRecorder{"api.anthropic.com": up}, - ) - client := clientThroughProxy(proxyURL, ca) - - req, err := http.NewRequest(http.MethodGet, "https://api.anthropic.com/v1/models", nil) - require.NoError(t, err) - req.Header.Set("x-api-key", "sk-ant-placeholder") // overwritten, not honored - resp, err := client.Do(req) - require.NoError(t, err) - defer func() { _ = resp.Body.Close() }() - - require.Equal(t, 1, up.count()) - assert.Equal(t, "sk-ant-real", up.got("x-api-key")[0]) - // The bearer header is not added for a header-only binding. - assert.Empty(t, up.got("Authorization")[0]) + assert.Equal(t, "untouched-value", gh.got("X-Custom")[0]) + assert.Empty(t, gh.got("Authorization")[0]) } diff --git a/sandbox/capabilities_linux_test.go b/sandbox/capabilities_linux_test.go index 633645a..902f95b 100644 --- a/sandbox/capabilities_linux_test.go +++ b/sandbox/capabilities_linux_test.go @@ -230,7 +230,7 @@ func TestPrintDryRun_Injection(t *testing.T) { } t.Setenv("CURB_DRYRUN_TOKEN", "sk-secret-must-not-leak") cfg := &config.Config{ - InjectHeader: []string{"api.example.com=x-api-key=@CURB_DRYRUN_TOKEN"}, + InjectHeader: []string{"CURB_DRYRUN_TOKEN=api.example.com"}, } plan, err := BuildPlan(cfg, caps, nil) @@ -242,7 +242,7 @@ func TestPrintDryRun_Injection(t *testing.T) { output := buf.String() assert.Contains(t, output, "inject:") - assert.Contains(t, output, "api.example.com: x-api-key") + assert.Contains(t, output, "api.example.com") assert.Contains(t, output, "ca-trust:") assert.NotContains(t, output, "sk-secret-must-not-leak", "dry-run must never print the injected token") } diff --git a/sandbox/inject_integration_test.go b/sandbox/inject_integration_test.go index fa0172c..ace86d3 100644 --- a/sandbox/inject_integration_test.go +++ b/sandbox/inject_integration_test.go @@ -128,10 +128,12 @@ func envWithout(keys ...string) []string { return out } -// TestCurb_Inject_BearerEndToEnd runs the real curb binary with --inject-bearer -// and a sandboxed curl against a local HTTPS server, asserting the proxy injects -// the bound credential — and overwrites a client-supplied placeholder. -func TestCurb_Inject_BearerEndToEnd(t *testing.T) { +// TestCurb_Inject_EndToEnd runs the real curb binary with --inject-header and a +// sandboxed curl against a local HTTPS server. The sandbox sees only a +// placeholder in the carrier env var; the proxy replaces it with the real +// credential on the wire. It also confirms the real secret never enters the +// sandbox. +func TestCurb_Inject_EndToEnd(t *testing.T) { requireProxyNS(t) rec := &headerRecorder{} @@ -144,16 +146,17 @@ func TestCurb_Inject_BearerEndToEnd(t *testing.T) { require.NoError(t, os.WriteFile(caFile, caPEM, 0o644)) url := fmt.Sprintf("https://localhost:%s/", port) - // First request: no client credential. Second: a placeholder that must be - // overwritten, not honored. + // $DEMO_TOKEN inside the sandbox is the placeholder. The script sends it in + // the Authorization header; the proxy substitutes the real value. Echoing it + // lets us confirm the sandbox never holds the real secret. script := fmt.Sprintf( - "curl -sf --connect-timeout 10 %s; echo \" a=$?\"; "+ - "curl -sf --connect-timeout 10 -H 'Authorization: Bearer client-placeholder' %s; echo \" b=$?\"", - url, url) + "echo \" seal=$DEMO_TOKEN\"; "+ + "curl -sf --connect-timeout 10 -H \"Authorization: Bearer $DEMO_TOKEN\" %s; echo \" a=$?\"", + url) cmd := exec.Command(curbBin, "--write", "*", "--exec", "*", "--host-loopback", - "--inject-bearer", "localhost=@DEMO_TOKEN", + "--inject-header", "DEMO_TOKEN=localhost", "--", "sh", "-c", script) cmd.Env = append(envWithout("DEMO_TOKEN", "SSL_CERT_FILE"), "DEMO_TOKEN=integration-secret", @@ -163,19 +166,18 @@ func TestCurb_Inject_BearerEndToEnd(t *testing.T) { out, err := cmd.CombinedOutput() outStr := filterCurbOutput(string(out)) require.NoError(t, err, "sandboxed curl failed: %s", outStr) - assert.Contains(t, outStr, "a=0", "first request should succeed (sandbox trusts the per-run CA)") - assert.Contains(t, outStr, "b=0", "second request should succeed") + assert.Contains(t, outStr, "a=0", "request should succeed (sandbox trusts the per-run CA)") + assert.Contains(t, outStr, "seal=curb-sealed-placeholder-DEMO_TOKEN", "sandbox sees only the placeholder") + assert.NotContains(t, outStr, "integration-secret", "real secret must not enter the sandbox") seen := rec.got("Authorization") - require.Len(t, seen, 2, "upstream should have received two requests") - assert.Equal(t, "Bearer integration-secret", seen[0], "credential injected on the wire") - assert.Equal(t, "Bearer integration-secret", seen[1], "client placeholder overwritten, not honored") + require.Len(t, seen, 1) + assert.Equal(t, "Bearer integration-secret", seen[0], "real credential injected on the wire") } -// TestCurb_Inject_HeaderEndToEnd runs the real curb binary with --inject-header -// and a sandboxed curl, asserting an arbitrary header (x-api-key, as Anthropic -// uses) is injected and overwrites a client-supplied placeholder. -func TestCurb_Inject_HeaderEndToEnd(t *testing.T) { +// TestCurb_Inject_HeaderAgnostic confirms the same binding works for a different +// auth header (x-api-key, as Anthropic uses) with no header configured. +func TestCurb_Inject_HeaderAgnostic(t *testing.T) { requireProxyNS(t) rec := &headerRecorder{} @@ -187,11 +189,11 @@ func TestCurb_Inject_HeaderEndToEnd(t *testing.T) { url := fmt.Sprintf("https://localhost:%s/", port) script := fmt.Sprintf( - "curl -sf --connect-timeout 10 -H 'x-api-key: sk-placeholder' %s; echo \" a=$?\"", url) + "curl -sf --connect-timeout 10 -H \"x-api-key: $DEMO_KEY\" %s; echo \" a=$?\"", url) cmd := exec.Command(curbBin, "--write", "*", "--exec", "*", "--host-loopback", - "--inject-header", "localhost=x-api-key=@DEMO_KEY", + "--inject-header", "DEMO_KEY=localhost", "--", "sh", "-c", script) cmd.Env = append(envWithout("DEMO_KEY", "SSL_CERT_FILE"), "DEMO_KEY=sk-ant-real-integration", @@ -205,5 +207,5 @@ func TestCurb_Inject_HeaderEndToEnd(t *testing.T) { seen := rec.got("x-api-key") require.Len(t, seen, 1) - assert.Equal(t, "sk-ant-real-integration", seen[0], "x-api-key injected, placeholder overwritten") + assert.Equal(t, "sk-ant-real-integration", seen[0], "x-api-key placeholder replaced with real value") } diff --git a/sandbox/inject_test.go b/sandbox/inject_test.go index 2290582..44db4b4 100644 --- a/sandbox/inject_test.go +++ b/sandbox/inject_test.go @@ -7,109 +7,37 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/upsun/curb/clog" ) -func testLogger(t *testing.T) *clog.Logger { - t.Helper() - log, err := clog.New("", false, false, true) // quiet - require.NoError(t, err) - return log -} - -func TestParseInjectBearer(t *testing.T) { - specs, err := parseInjectBearer([]string{"api.github.com=@TOKEN", "example.com=literal"}) - require.NoError(t, err) - require.Len(t, specs, 2) - assert.Equal(t, "api.github.com", specs[0].host) - assert.Equal(t, "@TOKEN", specs[0].source) - - for _, bad := range []string{"", "noequals", "=missinghost", "host="} { - _, err := parseInjectBearer([]string{bad}) - assert.Error(t, err, "expected error for %q", bad) - } - - // Wildcards must be rejected: "*" would broaden the allowlist to every - // domain while never matching a binding. - for _, bad := range []string{"*=@T", "*.example.com=@T"} { - _, err := parseInjectBearer([]string{bad}) - assert.Error(t, err, "expected wildcard %q to be rejected", bad) - } - - // Hosts are normalized to lowercase with no trailing dot. - specs, err = parseInjectBearer([]string{"API.GitHub.COM.=@T"}) - require.NoError(t, err) - assert.Equal(t, "api.github.com", specs[0].host) -} - -func TestParseInjectBearer_FillsAuthorization(t *testing.T) { - specs, err := parseInjectBearer([]string{"api.github.com=@TOK"}) - require.NoError(t, err) - require.Len(t, specs, 1) - assert.Equal(t, "Authorization", specs[0].header) - assert.Equal(t, "Bearer ", specs[0].prefix) -} - func TestParseInjectHeader(t *testing.T) { - specs, err := parseInjectHeader([]string{"api.anthropic.com=x-api-key=@ANTHROPIC_API_KEY"}) + specs, err := parseInjectHeader([]string{"ANTHROPIC_API_KEY=api.anthropic.com"}) require.NoError(t, err) require.Len(t, specs, 1) + assert.Equal(t, "ANTHROPIC_API_KEY", specs[0].envVar) assert.Equal(t, "api.anthropic.com", specs[0].host) - assert.Equal(t, "x-api-key", specs[0].header) - assert.Equal(t, "", specs[0].prefix) - assert.Equal(t, "@ANTHROPIC_API_KEY", specs[0].source) - - // A literal value containing '=' is preserved (split is HOST=HEADER=rest). - specs, err = parseInjectHeader([]string{"h.com=X-Tok=ab=cd"}) - require.NoError(t, err) - assert.Equal(t, "ab=cd", specs[0].source) for _, bad := range []string{ - "", "h.com", "h.com=x-api-key", "=x=y", "h.com==y", - "h.com=bad header=y", "*=x=@T", "*.h.com=x=@T", - "h.com=x/y=@T", "h.com=x(y)=@T", "h.com=a:b=@T", // invalid header-name tokens + "", "ANTHROPIC_API_KEY", "=h.com", "TOK=", // missing var or host + "1BAD=h.com", // invalid env var name (leading digit) + "bad-var=h.com", // invalid env var name (dash) + "T=*", "T=*.h.com", // wildcard hosts rejected + "TOK=not a host", // invalid host } { _, err := parseInjectHeader([]string{bad}) assert.Error(t, err, "expected error for %q", bad) } // Hosts are normalized to lowercase with no trailing dot. - specs, err = parseInjectHeader([]string{"API.Anthropic.COM.=x-api-key=@T"}) + specs, err = parseInjectHeader([]string{"T=API.Anthropic.COM."}) require.NoError(t, err) assert.Equal(t, "api.anthropic.com", specs[0].host) } -func TestResolveSecretSource(t *testing.T) { - log := testLogger(t) - - t.Setenv("CURB_TEST_TOKEN", "s3cret") - - // Required @VAR: value when set; error when unset. - val, ok, err := resolveSecretSource("@CURB_TEST_TOKEN", log) - require.NoError(t, err) - assert.True(t, ok) - assert.Equal(t, "s3cret", val) - - _, _, err = resolveSecretSource("@CURB_TEST_UNSET", log) - assert.Error(t, err) - - // Optional @?VAR: value when set; skip (ok=false, no error) when unset. - val, ok, err = resolveSecretSource("@?CURB_TEST_TOKEN", log) - require.NoError(t, err) - assert.True(t, ok) - assert.Equal(t, "s3cret", val) - - val, ok, err = resolveSecretSource("@?CURB_TEST_UNSET", log) - require.NoError(t, err) - assert.False(t, ok, "optional source skips when unset") - assert.Empty(t, val) - - // Literal value is returned (with a warning, not asserted here). - val, ok, err = resolveSecretSource("literal-token", log) - require.NoError(t, err) - assert.True(t, ok) - assert.Equal(t, "literal-token", val) +func TestInjectPlaceholder(t *testing.T) { + // Stable per env var, and distinct vars get distinct placeholders. + assert.Equal(t, injectPlaceholder("ANTHROPIC_API_KEY"), injectPlaceholder("ANTHROPIC_API_KEY")) + assert.NotEqual(t, injectPlaceholder("A"), injectPlaceholder("B")) + assert.Contains(t, injectPlaceholder("GH_TOKEN"), "GH_TOKEN") } func TestWriteCABundle(t *testing.T) { diff --git a/sandbox/plan.go b/sandbox/plan.go index 236c8b9..024cd97 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -579,77 +579,77 @@ func appendUniq(s []string, v string) []string { return s } -// injectSpec is a parsed injection binding: for requests to host, set header to -// prefix + the resolved token. The token comes from source (an @ENV_VAR -// reference or a literal). --inject-bearer fills prefix with "Bearer ". +// injectSpec is a parsed injection binding: the credential in env var envVar may +// be sent to host. The sandbox sees envVar set to a placeholder; the proxy +// replaces that placeholder with envVar's real (host) value in requests to host, +// wherever the client placed it among the request headers. type injectSpec struct { + envVar string host string - header string - prefix string - source string } -// parseInjectBearer parses --inject-bearer "HOST=SOURCE" into an Authorization: -// Bearer binding (sugar over parseInjectHeader). -func parseInjectBearer(entries []string) ([]injectSpec, error) { - var specs []injectSpec - for _, e := range entries { - host, source, ok := strings.Cut(e, "=") - if !ok || host == "" || source == "" { - return nil, fmt.Errorf("--inject-bearer must be HOST=SOURCE, got %q", e) - } - host, err := policy.ValidateInjectHost(host) - if err != nil { - return nil, fmt.Errorf("--inject-bearer %w", err) - } - specs = append(specs, injectSpec{host: host, header: "Authorization", prefix: "Bearer ", source: source}) - } - return specs, nil -} - -// parseInjectHeader parses --inject-header "HOST=HEADER=SOURCE" into a binding -// that sets an arbitrary request header (e.g. x-api-key) to the resolved token. +// parseInjectHeader parses --inject-header "ENV_VAR=HOST". The env var both reads +// the real value on the host and carries the placeholder in the sandbox. The +// binding is var-first because a credential conceptually belongs to its variable +// and may be valid for more than one host (a comma-separated host list is a +// natural future extension). No header name is needed: the client emits the +// placeholder in whatever header its auth scheme uses, and the proxy substitutes +// it in place. func parseInjectHeader(entries []string) ([]injectSpec, error) { var specs []injectSpec for _, e := range entries { - parts := strings.SplitN(e, "=", 3) - if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { - return nil, fmt.Errorf("--inject-header must be HOST=HEADER=SOURCE, got %q", e) + envVar, host, ok := strings.Cut(e, "=") + if !ok || envVar == "" || host == "" { + return nil, fmt.Errorf("--inject-header must be ENV_VAR=HOST, got %q", e) + } + if !policy.ValidEnvName(envVar) { + return nil, fmt.Errorf("--inject-header env var name %q is not a valid environment variable name", envVar) } - host, header, source := parts[0], parts[1], parts[2] host, err := policy.ValidateInjectHost(host) if err != nil { return nil, fmt.Errorf("--inject-header %w", err) } - if !policy.ValidHeaderName(header) { - return nil, fmt.Errorf("--inject-header header name %q is not a valid token (RFC 7230)", header) - } - specs = append(specs, injectSpec{host: host, header: header, source: source}) + specs = append(specs, injectSpec{envVar: envVar, host: host}) } return specs, nil } +// injectPlaceholder returns the stable, distinctive sentinel the sandbox sees in +// place of a real credential. It is constant per env var: the same secret keeps +// the same placeholder across runs, so a tool that approves a custom key (e.g. +// Claude Code) approves it once. Its value carries no secret weight — the proxy +// replaces it with the real token before the request leaves the host — so the +// string being predictable is harmless: an in-sandbox process already holds it +// (curb set it in the env), and substitution only happens for the bound host. +func injectPlaceholder(envVar string) string { + return "curb-sealed-placeholder-" + envVar +} + // resolveInject generates the per-run CA, resolves each bound token, and // delivers the CA to the sandbox trust store. It runs after proxy and env // resolution. The CA key and tokens stay in the parent; only the public CA // (in a combined bundle) and the placeholder-free env reach the child. +// +// A source var that is unset or empty is skipped silently: injection is opt-in +// per credential, and the common case (e.g. the claude profile for an OAuth +// user) is that the key is simply not present. func resolveInject(plan *SandboxPlan, specs []injectSpec) error { if len(specs) == 0 { return nil } bindings := make(map[string][]proxy.Injection, len(specs)) + seals := make(map[string]string) for _, s := range specs { - token, ok, err := resolveSecretSource(s.source, plan.Logger) - if err != nil { - return err - } - if !ok { - continue // optional source (@?VAR) absent — skip this binding + token, present := os.LookupEnv(s.envVar) + if !present || token == "" { + continue // source var absent — nothing to inject for this binding } - bindings[s.host] = append(bindings[s.host], proxy.Injection{Header: s.header, Value: s.prefix + token}) + placeholder := injectPlaceholder(s.envVar) + bindings[s.host] = append(bindings[s.host], proxy.Injection{Placeholder: placeholder, Value: token}) + seals[s.envVar] = placeholder } if len(bindings) == 0 { - return nil // nothing to inject (all sources were optional and absent) + return nil // nothing to inject (all source vars were absent) } if !plan.ProxyEnabled { return fmt.Errorf("credential injection requires the network proxy (needs user and network namespaces)") @@ -661,6 +661,11 @@ func resolveInject(plan *SandboxPlan, specs []injectSpec) error { plan.CA = ca plan.InjectBindings = bindings + // Seal the source vars: the sandbox sees only the placeholder. EnvSet wins + // over passthrough in ResolveEnv, so the real value cannot leak in even under + // --env '*'. + maps.Copy(plan.EnvSet, seals) + // Trust-store delivery: a combined bundle (system roots + per-run CA) the // action trusts for the proxy's leaf certs. The CA validates only inside // this run, so it is not sensitive to the action. @@ -675,32 +680,6 @@ func resolveInject(plan *SandboxPlan, specs []injectSpec) error { return nil } -// resolveSecretSource reads a token from a credential-injection source. It -// returns ok=false (no error) when an optional @?ENV_VAR source is unset, so -// the caller can skip that binding. -// -// - @?VAR — optional: the var's value if set and non-empty, else skip. -// - @VAR — required: the var's value, or an error if unset or empty. -// - literal — returned as-is (warning that argv is world-readable). -func resolveSecretSource(source string, log *clog.Logger) (string, bool, error) { - if name, ok := strings.CutPrefix(source, "@?"); ok { - val, present := os.LookupEnv(name) - if !present || val == "" { - return "", false, nil - } - return val, true, nil - } - if name, ok := strings.CutPrefix(source, "@"); ok { - val, present := os.LookupEnv(name) - if !present || val == "" { - return "", false, fmt.Errorf("credential injection source $%s is unset or empty", name) - } - return val, true, nil - } - log.Warn("credential injection literal token is visible in process arguments; prefer @ENV_VAR") - return source, true, nil -} - // writeCABundle writes a PEM bundle of the system roots plus the per-run CA to // the temp dir and returns its path. It fails if the system roots cannot be // located or read: this bundle replaces the sandbox's TLS trust (SSL_CERT_FILE @@ -950,14 +929,9 @@ func (p *SandboxPlan) PrintDryRun(w io.Writer) { hosts = append(hosts, h) } sort.Strings(hosts) - ln(" inject: TLS terminated; credential headers added by the proxy (token never enters the sandbox)") + ln(" inject: TLS terminated; the proxy replaces a placeholder with the real credential in request headers (the real token never enters the sandbox)") for _, h := range hosts { - var headers []string - for _, inj := range p.InjectBindings[h] { - headers = append(headers, inj.Header) - } - sort.Strings(headers) - pr(" %s: %s\n", h, strings.Join(headers, ", ")) + pr(" %s\n", h) } ln(" ca-trust: per-run CA bundle in env (SSL_CERT_FILE, CURL_CA_BUNDLE, GIT_SSL_CAINFO, REQUESTS_CA_BUNDLE, NODE_EXTRA_CA_CERTS)") } @@ -1422,8 +1396,8 @@ func resolveSymlinks(paths []string) []string { func buildDegradedPlan(cfg *config.Config, caps *Capabilities) (*SandboxPlan, error) { plan := &SandboxPlan{Caps: caps} - if len(cfg.InjectBearer) > 0 || len(cfg.InjectHeader) > 0 { - return nil, fmt.Errorf("credential injection (--inject-bearer/--inject-header) is only supported on Linux") + if len(cfg.InjectHeader) > 0 { + return nil, fmt.Errorf("credential injection (--inject-header) is only supported on Linux") } if len(cfg.AllowedDomains) > 0 { diff --git a/sandbox/plan_darwin.go b/sandbox/plan_darwin.go index 830d18a..ea31154 100644 --- a/sandbox/plan_darwin.go +++ b/sandbox/plan_darwin.go @@ -24,8 +24,8 @@ func (darwinPlanBuilder) BuildPlan(cfg *config.Config, caps *Capabilities, logge if caps.Seatbelt != nil { return nil, fmt.Errorf("fatal: sandbox-exec is required on macOS: %w", caps.Seatbelt) } - if len(cfg.InjectBearer) > 0 || len(cfg.InjectHeader) > 0 { - return nil, fmt.Errorf("credential injection (--inject-bearer/--inject-header) is only supported on Linux") + if len(cfg.InjectHeader) > 0 { + return nil, fmt.Errorf("credential injection (--inject-header) is only supported on Linux") } plan := &SandboxPlan{Caps: caps, Quiet: cfg.Quiet, Logger: logger} diff --git a/sandbox/plan_linux.go b/sandbox/plan_linux.go index 16dd9a4..a97ed23 100644 --- a/sandbox/plan_linux.go +++ b/sandbox/plan_linux.go @@ -19,15 +19,10 @@ func (linuxPlanBuilder) BuildPlan(cfg *config.Config, caps *Capabilities, logger // Parse credential-injection bindings first and ensure their hosts are // allowed, so capability/proxy resolution routes them through the proxy. - injects, err := parseInjectBearer(cfg.InjectBearer) + injects, err := parseInjectHeader(cfg.InjectHeader) if err != nil { return nil, err } - headerInjects, err := parseInjectHeader(cfg.InjectHeader) - if err != nil { - return nil, err - } - injects = append(injects, headerInjects...) for _, s := range injects { cfg.AllowedDomains = appendUniq(cfg.AllowedDomains, s.host) } From 6ecfb8ebaed3e661a4366a82f8298f05c3a3ebec Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 15 Jun 2026 22:59:16 +0100 Subject: [PATCH 08/27] fix: address credential-injection review - Prevent placeholder prefix collisions: wrap the env var as curb-sealed--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 --- policy/validate.go | 2 +- sandbox/inject_integration_test.go | 2 +- sandbox/inject_test.go | 8 ++++++++ sandbox/plan.go | 17 ++++++----------- sandbox/plan_test.go | 16 ---------------- 5 files changed, 16 insertions(+), 29 deletions(-) diff --git a/policy/validate.go b/policy/validate.go index fae0839..958d4fd 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -40,7 +40,7 @@ func validateDomain(d string) error { return fmt.Errorf("--domains %q contains invalid character (whitespace or control)", d) } switch r { - case '/', '\\', ':', '@', '#', '?': + case '/', '\\', ':', '@', '#', '?', '=': return fmt.Errorf("--domains %q contains invalid character %q", d, string(r)) } } diff --git a/sandbox/inject_integration_test.go b/sandbox/inject_integration_test.go index ace86d3..5edc3c8 100644 --- a/sandbox/inject_integration_test.go +++ b/sandbox/inject_integration_test.go @@ -167,7 +167,7 @@ func TestCurb_Inject_EndToEnd(t *testing.T) { outStr := filterCurbOutput(string(out)) require.NoError(t, err, "sandboxed curl failed: %s", outStr) assert.Contains(t, outStr, "a=0", "request should succeed (sandbox trusts the per-run CA)") - assert.Contains(t, outStr, "seal=curb-sealed-placeholder-DEMO_TOKEN", "sandbox sees only the placeholder") + assert.Contains(t, outStr, "seal=curb-sealed-DEMO_TOKEN-placeholder", "sandbox sees only the placeholder") assert.NotContains(t, outStr, "integration-secret", "real secret must not enter the sandbox") seen := rec.got("Authorization") diff --git a/sandbox/inject_test.go b/sandbox/inject_test.go index 44db4b4..dbf4184 100644 --- a/sandbox/inject_test.go +++ b/sandbox/inject_test.go @@ -3,6 +3,7 @@ package sandbox import ( "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -22,6 +23,7 @@ func TestParseInjectHeader(t *testing.T) { "bad-var=h.com", // invalid env var name (dash) "T=*", "T=*.h.com", // wildcard hosts rejected "TOK=not a host", // invalid host + "A=b.com=x", // '=' in host (would bind a never-matching host) } { _, err := parseInjectHeader([]string{bad}) assert.Error(t, err, "expected error for %q", bad) @@ -38,6 +40,12 @@ func TestInjectPlaceholder(t *testing.T) { assert.Equal(t, injectPlaceholder("ANTHROPIC_API_KEY"), injectPlaceholder("ANTHROPIC_API_KEY")) assert.NotEqual(t, injectPlaceholder("A"), injectPlaceholder("B")) assert.Contains(t, injectPlaceholder("GH_TOKEN"), "GH_TOKEN") + + // No placeholder may be a prefix of another, or replaceInHeaders' substring + // substitution would corrupt one credential while replacing another bound to + // the same host (e.g. TOK vs TOK2). + tok, tok2 := injectPlaceholder("TOK"), injectPlaceholder("TOK2") + assert.False(t, strings.HasPrefix(tok2, tok), "%q must not be a prefix of %q", tok, tok2) } func TestWriteCABundle(t *testing.T) { diff --git a/sandbox/plan.go b/sandbox/plan.go index 024cd97..782ce97 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -621,8 +621,13 @@ func parseInjectHeader(entries []string) ([]injectSpec, error) { // replaces it with the real token before the request leaves the host — so the // string being predictable is harmless: an in-sandbox process already holds it // (curb set it in the env), and substitution only happens for the bound host. +// +// The env var is wrapped by a trailing "-placeholder": because a valid env var +// name (ValidEnvName) 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's. func injectPlaceholder(envVar string) string { - return "curb-sealed-placeholder-" + envVar + return "curb-sealed-" + envVar + "-placeholder" } // resolveInject generates the per-run CA, resolves each bound token, and @@ -1029,16 +1034,6 @@ func applyEnvPolicy(plan *SandboxPlan, cfg *config.Config, tmpDir string) { } for _, pair := range cfg.EnvSet { k, v, _ := strings.Cut(pair, "=") - // A "?" prefix on the value makes the assignment conditional: apply it - // only if k is set in the host environment. Used to placeholder a - // credential the sandbox would otherwise receive, without introducing - // one when it is absent (e.g. ANTHROPIC_API_KEY for OAuth users). - if rest, ok := strings.CutPrefix(v, "?"); ok { - if _, present := os.LookupEnv(k); !present { - continue - } - v = rest - } // Expand $VAR references against the sandbox env built so far. v = os.Expand(v, func(name string) string { if val, ok := plan.EnvSet[name]; ok { diff --git a/sandbox/plan_test.go b/sandbox/plan_test.go index 1cbfee1..c41d1bd 100644 --- a/sandbox/plan_test.go +++ b/sandbox/plan_test.go @@ -189,22 +189,6 @@ func TestBuildPlan_NoLandlock_NoFSRestrict(t *testing.T) { defer plan.Cleanup() } -func TestBuildPlan_ConditionalEnvSet(t *testing.T) { - // "VAR=?value" applies only when VAR is set in the host environment. - t.Setenv("CURB_COND_PRESENT", "real") - cfg := &config.Config{EnvSet: []string{ - "CURB_COND_PRESENT=?placeholder", - "CURB_COND_ABSENT=?placeholder", - }} - plan, err := BuildPlan(cfg, minCaps(), nil) - require.NoError(t, err) - defer plan.Cleanup() - - assert.Equal(t, "placeholder", plan.EnvSet["CURB_COND_PRESENT"], "set when host var is present") - _, ok := plan.EnvSet["CURB_COND_ABSENT"] - assert.False(t, ok, "skipped when host var is absent") -} - func TestBuildPlan_AllProxyDefaultAndOverride(t *testing.T) { // By default the SOCKS env is advertised via ALL_PROXY. cfg := &config.Config{AllowedDomains: []string{"example.com"}} From cbd0d7bbc6dd65d3d8a0e363e43142c802ba0ec2 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 15 Jun 2026 23:16:08 +0100 Subject: [PATCH 09/27] refactor: simplify credential-injection comments and drop "sealing" term 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 --- CLAUDE.md | 2 +- README.md | 2 +- config/profile_test.go | 8 ++--- config/profiles/claude.yaml | 15 ++------- docs/comparison-zerobox.md | 4 +-- docs/configuration.md | 4 +-- sandbox/inject_integration_test.go | 4 +-- sandbox/plan.go | 49 ++++++++++++------------------ 8 files changed, 34 insertions(+), 54 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 35af94e..11dc4a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ go test ./sandbox/ -run TestCurb_FS_ -v # run a subset - The SOCKS proxy is advertised via `ALL_PROXY`/`all_proxy` (HTTP/HTTPS still prefer `HTTP_PROXY`/`HTTPS_PROXY`). A user-provided value takes precedence, so `--env ALL_PROXY=` suppresses it — useful for HTTP-only workloads with clients that eagerly construct a SOCKS transport (e.g. httpx, which then needs the `httpx[socks]` extra). ssh routing does not use it (it has its own config). - `--ips` allows connections to specific IP addresses or CIDR ranges. Works alongside or independently of `--domains`. IP-matched connections bypass domain filtering (forwarded directly on any port via SOCKS5 or CONNECT). In IPs-only mode (no `--domains`), DNS queries are not intercepted. - `--unrestricted-net` skips network namespace creation entirely, allowing unrestricted network access while keeping FS sandboxing. Cannot be combined with `--domains` or `--ips`. -- `--inject-header ENV_VAR=HOST` (Linux) terminates TLS for `HOST` only and replaces a placeholder with a real credential in request headers, so the token never enters the sandbox. curb generates a stable per-var placeholder, sets the child's `ENV_VAR` to it (sealing — the sandbox sees the placeholder, never the real value), reads the real value from the host's `ENV_VAR`, and the proxy substitutes the placeholder wherever the client put it among the request headers (`Authorization: Bearer `, `x-api-key: `, any header). It is header-agnostic by design: curb needs no knowledge of the host's auth scheme, so a fast-changing API surface and end users writing config are not coupled to a wire-header name. The binding is var-first (`ENV_VAR=HOST`) because a credential belongs to its variable and may be valid for several hosts — a comma-separated host list is a natural future extension. The proxy holds the real credential and a per-run CA; curb delivers a combined CA bundle (system roots + per-run CA) to the child via the standard CA env vars. The left side is always an env var name (no literals — there would be no var to carry the placeholder). Injection is opt-in per credential: if `ENV_VAR` is unset or empty on the host, the binding is skipped silently (so the `claude` profile is a no-op for OAuth users). Settable via the flag, `CURB_INJECT_HEADER`, or the `inject-header:` config-file/profile key (all merged additively); the value resolves at run time. The CA key and real token are parent-only — never serialized to the child. +- `--inject-header ENV_VAR=HOST` (Linux) keeps a credential out of the sandbox. curb sets the child's `ENV_VAR` to a stable per-var placeholder; the child never sees the real value. For `HOST` only, the proxy terminates TLS and substitutes the real value (read from the host's `ENV_VAR`) wherever the client put the placeholder among the request headers — so it is header-agnostic (no knowledge of the auth scheme). The binding is var-first because a credential belongs to its variable and may be valid for several hosts. Opt-in per credential: if `ENV_VAR` is unset or empty on the host the binding is skipped (so the `claude` profile is a no-op for OAuth users). The proxy uses a per-run CA delivered to the child as a combined bundle (system roots + per-run CA) via the standard CA env vars; the CA key and real token stay parent-only. Settable via the flag, `CURB_INJECT_HEADER`, or the `inject-header:` config/profile key (merged additively). - `--domains` validates input: rejects URLs (suggests bare domain), IP addresses (suggests `--ips`), invalid characters, and malformed wildcards. `--ips` validates that values parse as IP addresses or CIDR prefixes. - `!` prefix in list flags removes from defaults AND actively denies via overmount when the path is under an allowed parent. `--read '!/path'` hides (tmpfs/dev-null), `--write '!/path'` makes read-only, `--exec '!/path'` makes noexec. `!*` clears all defaults (not deny-all). `\!` escapes literal `!`. Sub-path denials require mount NS; Landlock-only mode warns. - HOME defaults to a private tmpDir. `--env HOME` passes through the host HOME; `--env HOME=/path` sets it explicitly. `~` and `$HOME` in config/profile paths both resolve to the **host** home (the shell's view), not the sandbox HOME — they are host-side shorthands. When sandbox HOME differs from host HOME and a path uses `~`, curb warns that the sandboxed program cannot reach those paths via its own `$HOME`. Built-in profiles include `HOME` in their env passthrough so sandbox HOME = host HOME and `~` paths align. See `docs/configuration.md` for full details. diff --git a/README.md b/README.md index 4a72a55..5595e1b 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ GH_TOKEN=ghp_xxx curb --inject-header 'GH_TOKEN=api.github.com' -- gh api user |------|---------|-------------| | `--domains` | `CURB_DOMAINS` | Allowed domain patterns (comma-separated). `*.example.com` for subdomains, `*` to allow all. | | `--ips` | `CURB_IPS` | Allowed IP addresses or CIDR ranges (e.g. `10.0.0.1`, `192.168.0.0/16`, `::1`). | -| `--inject-header` | `CURB_INJECT_HEADER` | Terminate TLS for a host and replace a placeholder with a real credential in request headers, so the token never enters the sandbox. `ENV_VAR=HOST` reads the real value from the host's env var and seals the sandbox's copy to a placeholder; the proxy substitutes it wherever the client sent it (any header — `Authorization: Bearer`, `x-api-key`, etc.), so no auth-scheme knowledge is needed. Skipped if `ENV_VAR` is unset. Repeatable; also settable via the `inject-header:` config-file/profile key. Linux only. | +| `--inject-header` | `CURB_INJECT_HEADER` | Keep a credential out of the sandbox: `ENV_VAR=HOST` sets the sandbox's copy of `ENV_VAR` to a placeholder, and the proxy replaces it with the real host value in requests to `HOST` (terminating TLS), wherever the client sent it (any header — `Authorization: Bearer`, `x-api-key`, etc.), so no auth-scheme knowledge is needed. Skipped if `ENV_VAR` is unset. Repeatable; also settable via the `inject-header:` config-file/profile key. Linux only. | | `--host-loopback` | `CURB_HOST_LOOPBACK` | Forward localhost/127.0.0.1 traffic to the host loopback. Cannot combine with `--unrestricted-net`. | | `--unrestricted-net` | `CURB_UNRESTRICTED_NET` | Skip network filtering entirely. Cannot combine with `--domains`, `--ips`, or `--host-loopback`. | diff --git a/config/profile_test.go b/config/profile_test.go index 7034459..504241c 100644 --- a/config/profile_test.go +++ b/config/profile_test.go @@ -133,11 +133,11 @@ func TestMergeProfiles_ClaudeSealsApiKey(t *testing.T) { assert.Contains(t, cfg.AllowedDomains, "api.anthropic.com") - // The key is injected to api.anthropic.com from the host env var; curb seals - // the sandbox's ANTHROPIC_API_KEY to a placeholder itself, and skips - // injection when the host var is unset (OAuth). + // The key is injected to api.anthropic.com from the host env var; the + // sandbox's ANTHROPIC_API_KEY becomes a placeholder, and injection is + // skipped when the host var is unset (OAuth). assert.Contains(t, cfg.InjectHeader, "ANTHROPIC_API_KEY=api.anthropic.com") - // The real key is not passed through (it would defeat the seal). + // The real key is not passed through (it would defeat the injection). assert.NotContains(t, cfg.EnvPassthrough, "ANTHROPIC_API_KEY") } diff --git a/config/profiles/claude.yaml b/config/profiles/claude.yaml index 1469ea9..0f8fd70 100644 --- a/config/profiles/claude.yaml +++ b/config/profiles/claude.yaml @@ -5,15 +5,6 @@ # # Claude Code's own bwrap sandbox cannot nest inside curb's namespace. # Set sandbox.enabled: false in Claude Code settings — curb provides enforcement. -# -# Credential sealing (Linux): when ANTHROPIC_API_KEY is set on the host, it is -# kept out of the sandbox — the process sees only a placeholder, and curb's -# proxy replaces that placeholder with the real key in requests to -# api.anthropic.com (in whatever header Claude Code sends it). With no host key -# set, nothing is injected and OAuth/subscription auth works unchanged. On first -# run, Claude Code may prompt once to approve the placeholder as a custom API -# key. Caveat: a custom ANTHROPIC_BASE_URL is not covered (the seal targets -# api.anthropic.com). commands: [claude] # Node.js/Bun use AF_UNIX socketpairs internally for child_process.spawn() stdio. allow-unix-sockets: true @@ -53,10 +44,8 @@ exec: - /bin - /usr/local/bin - /opt/homebrew/bin -# Seal ANTHROPIC_API_KEY: the sandbox sees only a placeholder, and curb's proxy -# replaces it with the real key (read from the host env) in requests to -# api.anthropic.com — whichever header Claude Code sends it in. Skipped when the -# host key is unset, so OAuth users are unaffected. +# Keep ANTHROPIC_API_KEY out of the sandbox: the proxy injects it into requests +# to api.anthropic.com. Skipped when the host key is unset (OAuth users). inject-header: - ANTHROPIC_API_KEY=api.anthropic.com env: diff --git a/docs/comparison-zerobox.md b/docs/comparison-zerobox.md index f515bd4..6b4e0e9 100644 --- a/docs/comparison-zerobox.md +++ b/docs/comparison-zerobox.md @@ -140,7 +140,7 @@ once rather than every run. Both placeholders carry no secret weight — neither the credential. Second, zerobox couples the secret to an env var name (`--secret KEY=VALUE`) and the host separately (`--secret-host KEY=hosts`); curb's single `ENV_VAR=HOST` does both — the env var both reads the real value on the -host and carries the placeholder in the sandbox, and curb seals it automatically. +host and carries the placeholder in the sandbox. Both put the variable first, leaving room for a credential to name several hosts. Both approaches prevent a compromised or malicious process from reading the @@ -276,7 +276,7 @@ process from ever seeing real credentials. This is valuable for AI agent use cases where the agent needs to call authenticated APIs but should not be able to read or exfiltrate the actual tokens. -curb now implements the same model: `--inject-header ENV_VAR=HOST` seals the +curb now implements the same model: `--inject-header ENV_VAR=HOST` sets the sandbox's copy of the variable to a placeholder and has the proxy substitute the real value in request headers, header-agnostically. Unlike zerobox, TLS termination is confined to the named hosts rather than applied to all allowed diff --git a/docs/configuration.md b/docs/configuration.md index a52a6a4..a074934 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -224,7 +224,7 @@ injection that simply does nothing when the credential is absent. GH_TOKEN=ghp_xxx curb --inject-header 'GH_TOKEN=api.github.com' -- gh api user ``` -The placeholder is constant per variable (e.g. `curb-sealed-placeholder-GH_TOKEN`) +The placeholder is constant per variable (e.g. `curb-inject-GH_TOKEN-placeholder`) and carries no secret weight — the proxy overwrites it before the request leaves the host. Keeping it stable means a tool that approves a custom credential (such as Claude Code prompting to approve a custom API key) approves it once rather @@ -237,7 +237,7 @@ key in whichever header Claude Code sends (`x-api-key` or, for OAuth, `Authorization`). With no host key set it is a no-op and OAuth/subscription auth works unchanged. Linux only; on first run Claude Code may prompt once to approve the placeholder as a custom key. A custom `ANTHROPIC_BASE_URL` is not covered -(the seal targets `api.anthropic.com`). +(injection targets `api.anthropic.com`). The flag is repeatable (bindings accumulate) and implies network filtering: the host is added to the allowlist and routed through the proxy. curb generates a diff --git a/sandbox/inject_integration_test.go b/sandbox/inject_integration_test.go index 5edc3c8..2bed695 100644 --- a/sandbox/inject_integration_test.go +++ b/sandbox/inject_integration_test.go @@ -150,7 +150,7 @@ func TestCurb_Inject_EndToEnd(t *testing.T) { // the Authorization header; the proxy substitutes the real value. Echoing it // lets us confirm the sandbox never holds the real secret. script := fmt.Sprintf( - "echo \" seal=$DEMO_TOKEN\"; "+ + "echo \" ph=$DEMO_TOKEN\"; "+ "curl -sf --connect-timeout 10 -H \"Authorization: Bearer $DEMO_TOKEN\" %s; echo \" a=$?\"", url) @@ -167,7 +167,7 @@ func TestCurb_Inject_EndToEnd(t *testing.T) { outStr := filterCurbOutput(string(out)) require.NoError(t, err, "sandboxed curl failed: %s", outStr) assert.Contains(t, outStr, "a=0", "request should succeed (sandbox trusts the per-run CA)") - assert.Contains(t, outStr, "seal=curb-sealed-DEMO_TOKEN-placeholder", "sandbox sees only the placeholder") + assert.Contains(t, outStr, "ph=curb-inject-DEMO_TOKEN-placeholder", "sandbox sees only the placeholder") assert.NotContains(t, outStr, "integration-secret", "real secret must not enter the sandbox") seen := rec.got("Authorization") diff --git a/sandbox/plan.go b/sandbox/plan.go index 782ce97..6a87a1d 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -579,22 +579,17 @@ func appendUniq(s []string, v string) []string { return s } -// injectSpec is a parsed injection binding: the credential in env var envVar may -// be sent to host. The sandbox sees envVar set to a placeholder; the proxy -// replaces that placeholder with envVar's real (host) value in requests to host, -// wherever the client placed it among the request headers. +// injectSpec is a parsed injection binding: the sandbox sees envVar set to a +// placeholder; the proxy replaces it with envVar's real host value in requests +// to host, wherever the client placed it among the request headers. type injectSpec struct { envVar string host string } -// parseInjectHeader parses --inject-header "ENV_VAR=HOST". The env var both reads -// the real value on the host and carries the placeholder in the sandbox. The -// binding is var-first because a credential conceptually belongs to its variable -// and may be valid for more than one host (a comma-separated host list is a -// natural future extension). No header name is needed: the client emits the -// placeholder in whatever header its auth scheme uses, and the proxy substitutes -// it in place. +// parseInjectHeader parses --inject-header "ENV_VAR=HOST". The binding is +// var-first because a credential belongs to its variable and may be valid for +// more than one host (a comma-separated host list is a natural future extension). func parseInjectHeader(entries []string) ([]injectSpec, error) { var specs []injectSpec for _, e := range entries { @@ -614,20 +609,16 @@ func parseInjectHeader(entries []string) ([]injectSpec, error) { return specs, nil } -// injectPlaceholder returns the stable, distinctive sentinel the sandbox sees in -// place of a real credential. It is constant per env var: the same secret keeps -// the same placeholder across runs, so a tool that approves a custom key (e.g. -// Claude Code) approves it once. Its value carries no secret weight — the proxy -// replaces it with the real token before the request leaves the host — so the -// string being predictable is harmless: an in-sandbox process already holds it -// (curb set it in the env), and substitution only happens for the bound host. +// injectPlaceholder returns the sentinel the sandbox sees in place of a real +// credential. It is constant per env var so a tool that approves a custom key +// (e.g. Claude Code) approves it once. The value carries no secret weight: the +// proxy swaps it for the real token before the request leaves the host. // -// The env var is wrapped by a trailing "-placeholder": because a valid env var -// name (ValidEnvName) 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's. +// A valid env var name cannot contain "-", so the surrounding "-" guards ensure +// no placeholder is a prefix of another (TOK vs TOK2) — the substring +// substitution in replaceInHeaders cannot corrupt one placeholder via another. func injectPlaceholder(envVar string) string { - return "curb-sealed-" + envVar + "-placeholder" + return "curb-inject-" + envVar + "-placeholder" } // resolveInject generates the per-run CA, resolves each bound token, and @@ -643,7 +634,7 @@ func resolveInject(plan *SandboxPlan, specs []injectSpec) error { return nil } bindings := make(map[string][]proxy.Injection, len(specs)) - seals := make(map[string]string) + placeholders := make(map[string]string) for _, s := range specs { token, present := os.LookupEnv(s.envVar) if !present || token == "" { @@ -651,7 +642,7 @@ func resolveInject(plan *SandboxPlan, specs []injectSpec) error { } placeholder := injectPlaceholder(s.envVar) bindings[s.host] = append(bindings[s.host], proxy.Injection{Placeholder: placeholder, Value: token}) - seals[s.envVar] = placeholder + placeholders[s.envVar] = placeholder } if len(bindings) == 0 { return nil // nothing to inject (all source vars were absent) @@ -666,10 +657,10 @@ func resolveInject(plan *SandboxPlan, specs []injectSpec) error { plan.CA = ca plan.InjectBindings = bindings - // Seal the source vars: the sandbox sees only the placeholder. EnvSet wins - // over passthrough in ResolveEnv, so the real value cannot leak in even under - // --env '*'. - maps.Copy(plan.EnvSet, seals) + // Set the source vars to their placeholders: the sandbox sees only those. + // EnvSet wins over passthrough in ResolveEnv, so the real value cannot leak + // in even under --env '*'. + maps.Copy(plan.EnvSet, placeholders) // Trust-store delivery: a combined bundle (system roots + per-run CA) the // action trusts for the proxy's leaf certs. The CA validates only inside From 37d7cc63893efdb619c23d3e1fb629a58e0db93d Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 15 Jun 2026 23:23:22 +0100 Subject: [PATCH 10/27] refactor: deduplicate credential-injection helpers 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 --- config/configfile.go | 9 +-------- config/configfile_test.go | 2 +- policy/domain.go | 11 ++++++++--- policy/validate.go | 21 ++++++++++++++++++++- proxy/inject.go | 16 +++++----------- sandbox/plan.go | 13 ++----------- 6 files changed, 37 insertions(+), 35 deletions(-) diff --git a/config/configfile.go b/config/configfile.go index 166cc79..ab859c7 100644 --- a/config/configfile.go +++ b/config/configfile.go @@ -111,14 +111,7 @@ func (cf *ConfigFile) validate() error { } } for i, e := range cf.InjectHeader { - envVar, host, ok := strings.Cut(e, "=") - if !ok || envVar == "" || host == "" { - return fmt.Errorf("inject-header[%d] must be ENV_VAR=HOST, got %q", i, e) - } - if !policy.ValidEnvName(envVar) { - return fmt.Errorf("inject-header[%d] env var name %q is not valid", i, envVar) - } - if _, err := policy.ValidateInjectHost(host); err != nil { + if _, _, err := policy.ParseInjectHeader(e); err != nil { return fmt.Errorf("inject-header[%d] %w", i, err) } } diff --git a/config/configfile_test.go b/config/configfile_test.go index bcdb215..e5b57ca 100644 --- a/config/configfile_test.go +++ b/config/configfile_test.go @@ -203,7 +203,7 @@ inject-header: _, err := LoadConfigFile(path) require.Error(t, err) assert.Contains(t, err.Error(), "inject-header") - assert.Contains(t, err.Error(), "not valid") + assert.Contains(t, err.Error(), "not a valid environment variable name") } func TestFindConfigFile_InCWD(t *testing.T) { diff --git a/policy/domain.go b/policy/domain.go index df37090..e01dcee 100644 --- a/policy/domain.go +++ b/policy/domain.go @@ -21,7 +21,7 @@ func NewDomainMatcher(domains []string) *DomainMatcher { exactDomains: make(map[string]bool), } for _, d := range domains { - d = normalizeDomain(d) + d = NormalizeHost(d) if d == "*" { m.matchAll = true return m @@ -41,7 +41,7 @@ func (m *DomainMatcher) Match(domain string) bool { if m.matchAll { return true } - domain = normalizeDomain(domain) + domain = NormalizeHost(domain) if domain == "" { return false } @@ -62,6 +62,11 @@ func (m *DomainMatcher) Match(domain string) bool { return false } -func normalizeDomain(s string) string { +// NormalizeHost lowercases a host and trims surrounding whitespace and a +// trailing root-label dot, so api.example.com, API.EXAMPLE.COM, and +// api.example.com. all compare equal. 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. +func NormalizeHost(s string) string { return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s)), ".") } diff --git a/policy/validate.go b/policy/validate.go index 958d4fd..4be320e 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -71,7 +71,26 @@ func ValidateInjectHost(host string) (string, error) { if strings.Contains(host, "*") { return "", fmt.Errorf("host %q must be an exact hostname (no wildcards)", host) } - return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), "."), nil + return NormalizeHost(host), nil +} + +// ParseInjectHeader parses one credential-injection binding "ENV_VAR=HOST", +// returning the env var name and the normalized host. The binding is var-first +// because a credential belongs to its variable and may be valid for more than +// one host. Callers wrap the error with their own flag/field prefix. +func ParseInjectHeader(entry string) (envVar, host string, err error) { + envVar, host, ok := strings.Cut(entry, "=") + if !ok || envVar == "" || host == "" { + return "", "", fmt.Errorf("must be ENV_VAR=HOST, got %q", entry) + } + if !ValidEnvName(envVar) { + return "", "", fmt.Errorf("env var name %q is not a valid environment variable name", envVar) + } + host, err = ValidateInjectHost(host) + if err != nil { + return "", "", err + } + return envVar, host, nil } // ValidEnvName reports whether name is a valid environment variable name (a C diff --git a/proxy/inject.go b/proxy/inject.go index dd2dcb6..beec439 100644 --- a/proxy/inject.go +++ b/proxy/inject.go @@ -2,7 +2,6 @@ package proxy import ( "bufio" - "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" @@ -17,6 +16,8 @@ import ( "strings" "sync" "time" + + "github.com/upsun/curb/policy" ) // CA is a per-run certificate authority used to mint leaf certificates for the @@ -149,22 +150,15 @@ func NewInjector(ca *CA) *Injector { // Bind adds a header injection for a destination host. Multiple headers may be // bound to the same host. func (in *Injector) Bind(host string, inj Injection) { - host = normalizeHost(host) + host = policy.NormalizeHost(host) in.byHost[host] = append(in.byHost[host], inj) } func (in *Injector) binding(host string) ([]Injection, bool) { - injs, ok := in.byHost[normalizeHost(host)] + injs, ok := in.byHost[policy.NormalizeHost(host)] return injs, ok } -// normalizeHost lowercases and trims a trailing dot so binding keys match -// CONNECT targets regardless of case or root-label dot (api.example.com, -// API.EXAMPLE.COM, and api.example.com. are the same host). -func normalizeHost(host string) string { - return strings.TrimSuffix(strings.ToLower(host), ".") -} - // injectCONNECT terminates the client's TLS with a per-run leaf for host, // then forwards each decrypted request to the real upstream with the bound // credential injected. @@ -235,7 +229,7 @@ func (h *Handler) serveInjected(client net.Conn, host, port string, injs []Injec req.Host = authority req.RequestURI = "" - resp, err := rt.RoundTrip(req.WithContext(context.Background())) + resp, err := rt.RoundTrip(req) if err != nil { writeGatewayError(client) return diff --git a/sandbox/plan.go b/sandbox/plan.go index 6a87a1d..75cb7f0 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -587,20 +587,11 @@ type injectSpec struct { host string } -// parseInjectHeader parses --inject-header "ENV_VAR=HOST". The binding is -// var-first because a credential belongs to its variable and may be valid for -// more than one host (a comma-separated host list is a natural future extension). +// parseInjectHeader parses --inject-header "ENV_VAR=HOST" entries. func parseInjectHeader(entries []string) ([]injectSpec, error) { var specs []injectSpec for _, e := range entries { - envVar, host, ok := strings.Cut(e, "=") - if !ok || envVar == "" || host == "" { - return nil, fmt.Errorf("--inject-header must be ENV_VAR=HOST, got %q", e) - } - if !policy.ValidEnvName(envVar) { - return nil, fmt.Errorf("--inject-header env var name %q is not a valid environment variable name", envVar) - } - host, err := policy.ValidateInjectHost(host) + envVar, host, err := policy.ParseInjectHeader(e) if err != nil { return nil, fmt.Errorf("--inject-header %w", err) } From 5ba60cc73dea8501f64aa8a1c5e4421b15d51655 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 15 Jun 2026 23:52:39 +0100 Subject: [PATCH 11/27] fix: support credential injection on macOS 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. --- README.md | 4 +- cmd/root.go | 4 +- docs/comparison-zerobox.md | 9 +++-- docs/configuration.md | 24 ++++++------ sandbox/capabilities_linux_test.go | 3 +- sandbox/inject_integration_test.go | 2 + sandbox/inject_test.go | 59 +++++++++++++++++++++++++++--- sandbox/plan.go | 15 +++++++- sandbox/plan_darwin.go | 13 +++++-- sandbox/plan_darwin_test.go | 49 +++++++++++++++++++++++++ sandbox/plan_linux.go | 7 +--- 11 files changed, 154 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 5595e1b..dbda773 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ Inject a credential the sandboxed process never sees — the token stays in curb's proxy, which adds it to requests bound for the named host: ``` -GH_TOKEN=ghp_xxx curb --inject-header 'GH_TOKEN=api.github.com' -- gh api user +GH_TOKEN=ghp_xxx curb --domains api.github.com --inject-header 'GH_TOKEN=api.github.com' -- gh api user ``` ## CLI reference @@ -169,7 +169,7 @@ GH_TOKEN=ghp_xxx curb --inject-header 'GH_TOKEN=api.github.com' -- gh api user |------|---------|-------------| | `--domains` | `CURB_DOMAINS` | Allowed domain patterns (comma-separated). `*.example.com` for subdomains, `*` to allow all. | | `--ips` | `CURB_IPS` | Allowed IP addresses or CIDR ranges (e.g. `10.0.0.1`, `192.168.0.0/16`, `::1`). | -| `--inject-header` | `CURB_INJECT_HEADER` | Keep a credential out of the sandbox: `ENV_VAR=HOST` sets the sandbox's copy of `ENV_VAR` to a placeholder, and the proxy replaces it with the real host value in requests to `HOST` (terminating TLS), wherever the client sent it (any header — `Authorization: Bearer`, `x-api-key`, etc.), so no auth-scheme knowledge is needed. Skipped if `ENV_VAR` is unset. Repeatable; also settable via the `inject-header:` config-file/profile key. Linux only. | +| `--inject-header` | `CURB_INJECT_HEADER` | Keep a credential out of the sandbox: `ENV_VAR=HOST` sets the sandbox's copy of `ENV_VAR` to a placeholder, and the proxy replaces it with the real host value in requests to `HOST` (terminating TLS), wherever the client sent it (any header — `Authorization: Bearer`, `x-api-key`, etc.), so no auth-scheme knowledge is needed. `HOST` must also be allowed by `--domains` or a profile. Skipped if `ENV_VAR` is unset. Repeatable; also settable via the `inject-header:` config-file/profile key. | | `--host-loopback` | `CURB_HOST_LOOPBACK` | Forward localhost/127.0.0.1 traffic to the host loopback. Cannot combine with `--unrestricted-net`. | | `--unrestricted-net` | `CURB_UNRESTRICTED_NET` | Skip network filtering entirely. Cannot combine with `--domains`, `--ips`, or `--host-loopback`. | diff --git a/cmd/root.go b/cmd/root.go index 37ff700..c909a28 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,7 +28,7 @@ Use -- before the command when it has its own flags.`, curb -p node -w . -- npm install # profile + writable cwd curb -a cargo build # auto-select profile curb --dry-run -p node -- npm install # preview sandbox plan - curb --inject-header GH_TOKEN=api.github.com -- gh pr list # inject a token, kept out of the sandbox`, + curb --domains api.github.com --inject-header GH_TOKEN=api.github.com -- gh pr list # inject a token, kept out of the sandbox`, Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { @@ -330,7 +330,7 @@ func registerFlags(cmd *cobra.Command) { f.StringSlice("ips", nil, "allowed IP/CIDR ranges (e.g. 10.0.0.1, 192.168.0.0/16)") f.Bool("unrestricted-net", false, "skip network restrictions entirely") f.Bool("host-loopback", false, "forward localhost traffic to host loopback") - f.StringSlice("inject-header", nil, "inject ENV_VAR's value as a credential to HOST, kept out of the sandbox (ENV_VAR=HOST; TLS terminated for HOST, the sandbox sees only a placeholder; skipped if ENV_VAR is unset)") + f.StringSlice("inject-header", nil, "inject ENV_VAR's value as a credential to an allowed HOST, kept out of the sandbox (ENV_VAR=HOST; TLS terminated for HOST; skipped if ENV_VAR is unset)") // Executables. f.StringSlice("exec", nil, "allowed executables (default: invoked command only)") diff --git a/docs/comparison-zerobox.md b/docs/comparison-zerobox.md index 6b4e0e9..8dd50ed 100644 --- a/docs/comparison-zerobox.md +++ b/docs/comparison-zerobox.md @@ -113,12 +113,13 @@ only for approved hosts. The placeholder is 32 random bytes so it is long and unique enough not to collide with legitimate request content during that scan. -**curb** has opt-in credential injection via `--inject-header ENV_VAR=HOST` -(Linux only). The two tools' mechanisms have converged: the sandbox sees only a +**curb** has opt-in credential injection via `--inject-header ENV_VAR=HOST`. +The two tools' mechanisms have converged: the sandbox sees only a placeholder in `ENV_VAR`, never the real value; the proxy terminates TLS for `HOST` (presenting a per-run CA the sandbox trusts) and substitutes the placeholder with the real value wherever the client put it among the request -headers. Like zerobox, it is header-agnostic — `Authorization: Bearer`, +headers. `HOST` must also be explicitly allowed by the network policy. Like +zerobox, it is header-agnostic — `Authorization: Bearer`, `x-api-key`, or any other header all work, with no auth-scheme knowledge configured. Without the flag, curb performs no injection and no TLS termination; secrets otherwise reach the process only via `--env`. @@ -250,7 +251,7 @@ Linux. curb has no external runtime dependencies. | Default network | Blocked | Blocked | | FS enforcement | bwrap (mount NS) + Landlock fallback | pivot_root (mount NS) + Landlock layered | | Network filtering | HTTP proxy (MITM when secrets active) | HTTP + SOCKS5 proxy (CONNECT passthrough; per-host TLS termination only with `--inject-header`) | -| Secret injection | Yes (MITM all HTTPS, placeholder substitution) | Yes (`--inject-header`, placeholder substitution, TLS terminated per host, Linux) | +| Secret injection | Yes (MITM all HTTPS, placeholder substitution) | Yes (`--inject-header`, placeholder substitution, TLS terminated per explicitly allowed host) | | Snapshot/restore | Yes (BLAKE3 + Merkle tree) | No (stateless) | | IP/CIDR filtering | No | Yes (`--ips`) | | SOCKS5 (non-HTTP TCP) | No | Yes | diff --git a/docs/configuration.md b/docs/configuration.md index a074934..f4b6607 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -187,8 +187,6 @@ And passes through: `PATH`, `TERM`, `COLORTERM`, `NO_COLOR`, `LANG`, `LC_ALL`, ` ## Credential injection -> Linux only. - `--inject-header ENV_VAR=HOST` keeps a credential out of the sandbox entirely. curb generates a stable placeholder for `ENV_VAR`, sets the sandbox's copy of `ENV_VAR` to that placeholder (so the process never sees the real value), and @@ -218,10 +216,15 @@ Injection is opt-in per credential: if `ENV_VAR` is unset or empty on the host, the binding is skipped silently (no error). This is what lets a profile carry an injection that simply does nothing when the credential is absent. +An active injection binding does not grant network access by itself. `HOST` must +also match the explicit domain allowlist from `--domains`, `CURB_DOMAINS`, +config, or a profile. If the host credential is present but the host is not +allowed, curb fails during planning instead of broadening the sandbox policy. + ``` # Real value read from the host's $GH_TOKEN; the sandbox sees only a placeholder, # and gh sends it in whatever header it uses: -GH_TOKEN=ghp_xxx curb --inject-header 'GH_TOKEN=api.github.com' -- gh api user +GH_TOKEN=ghp_xxx curb --domains api.github.com --inject-header 'GH_TOKEN=api.github.com' -- gh api user ``` The placeholder is constant per variable (e.g. `curb-inject-GH_TOKEN-placeholder`) @@ -235,16 +238,15 @@ The built-in **`claude`** profile does exactly this for Claude Code: it injects host* the sandbox sees only the placeholder and the proxy substitutes the real key in whichever header Claude Code sends (`x-api-key` or, for OAuth, `Authorization`). With no host key set it is a no-op and OAuth/subscription auth -works unchanged. Linux only; on first run Claude Code may prompt once to approve -the placeholder as a custom key. A custom `ANTHROPIC_BASE_URL` is not covered +works unchanged. On first run Claude Code may prompt once to approve the +placeholder as a custom key. A custom `ANTHROPIC_BASE_URL` is not covered (injection targets `api.anthropic.com`). -The flag is repeatable (bindings accumulate) and implies network filtering: the -host is added to the allowlist and routed through the proxy. curb generates a -combined CA bundle (system roots plus the per-run CA) and points the standard CA -environment variables (`SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, -`REQUESTS_CA_BUNDLE`, `NODE_EXTRA_CA_CERTS`) at it, so common tools trust the -terminated connection while still reaching other hosts normally. +The flag is repeatable (bindings accumulate). For active bindings, curb +generates a combined CA bundle (system roots plus the per-run CA) and points the +standard CA environment variables (`SSL_CERT_FILE`, `CURL_CA_BUNDLE`, +`GIT_SSL_CAINFO`, `REQUESTS_CA_BUNDLE`, `NODE_EXTRA_CA_CERTS`) at it, so common +tools trust the terminated connection while still reaching other hosts normally. After terminating TLS, curb parses the decrypted stream as HTTP/1.1 and does not negotiate HTTP/2 (no `h2` ALPN). An HTTP/2-only client or protocol — some gRPC diff --git a/sandbox/capabilities_linux_test.go b/sandbox/capabilities_linux_test.go index 902f95b..1e39f99 100644 --- a/sandbox/capabilities_linux_test.go +++ b/sandbox/capabilities_linux_test.go @@ -230,7 +230,8 @@ func TestPrintDryRun_Injection(t *testing.T) { } t.Setenv("CURB_DRYRUN_TOKEN", "sk-secret-must-not-leak") cfg := &config.Config{ - InjectHeader: []string{"CURB_DRYRUN_TOKEN=api.example.com"}, + AllowedDomains: []string{"api.example.com"}, + InjectHeader: []string{"CURB_DRYRUN_TOKEN=api.example.com"}, } plan, err := BuildPlan(cfg, caps, nil) diff --git a/sandbox/inject_integration_test.go b/sandbox/inject_integration_test.go index 2bed695..6fdf967 100644 --- a/sandbox/inject_integration_test.go +++ b/sandbox/inject_integration_test.go @@ -156,6 +156,7 @@ func TestCurb_Inject_EndToEnd(t *testing.T) { cmd := exec.Command(curbBin, "--write", "*", "--exec", "*", "--host-loopback", + "--domains", "localhost", "--inject-header", "DEMO_TOKEN=localhost", "--", "sh", "-c", script) cmd.Env = append(envWithout("DEMO_TOKEN", "SSL_CERT_FILE"), @@ -193,6 +194,7 @@ func TestCurb_Inject_HeaderAgnostic(t *testing.T) { cmd := exec.Command(curbBin, "--write", "*", "--exec", "*", "--host-loopback", + "--domains", "localhost", "--inject-header", "DEMO_KEY=localhost", "--", "sh", "-c", script) cmd.Env = append(envWithout("DEMO_KEY", "SSL_CERT_FILE"), diff --git a/sandbox/inject_test.go b/sandbox/inject_test.go index dbf4184..7cb3c8d 100644 --- a/sandbox/inject_test.go +++ b/sandbox/inject_test.go @@ -19,11 +19,11 @@ func TestParseInjectHeader(t *testing.T) { for _, bad := range []string{ "", "ANTHROPIC_API_KEY", "=h.com", "TOK=", // missing var or host - "1BAD=h.com", // invalid env var name (leading digit) - "bad-var=h.com", // invalid env var name (dash) - "T=*", "T=*.h.com", // wildcard hosts rejected - "TOK=not a host", // invalid host - "A=b.com=x", // '=' in host (would bind a never-matching host) + "1BAD=h.com", // invalid env var name (leading digit) + "bad-var=h.com", // invalid env var name (dash) + "T=*", "T=*.h.com", // wildcard hosts rejected + "TOK=not a host", // invalid host + "A=b.com=x", // '=' in host (would bind a never-matching host) } { _, err := parseInjectHeader([]string{bad}) assert.Error(t, err, "expected error for %q", bad) @@ -48,6 +48,55 @@ func TestInjectPlaceholder(t *testing.T) { assert.False(t, strings.HasPrefix(tok2, tok), "%q must not be a prefix of %q", tok, tok2) } +func TestResolveInjectSkippedDoesNotRequireAllowedDomain(t *testing.T) { + t.Setenv("SKIPPED_TOKEN", "") + plan := &SandboxPlan{ + TempDir: t.TempDir(), + EnvSet: map[string]string{}, + } + specs := []injectSpec{{envVar: "SKIPPED_TOKEN", host: "api.example.com"}} + + require.NoError(t, resolveInject(plan, specs)) + assert.Empty(t, plan.AllowedDomains) + assert.Empty(t, plan.InjectBindings) + assert.Nil(t, plan.CA) +} + +func TestResolveInjectRequiresAllowedDomain(t *testing.T) { + t.Setenv("ACTIVE_TOKEN", "secret") + plan := &SandboxPlan{ + TempDir: t.TempDir(), + EnvSet: map[string]string{}, + ProxyEnabled: true, + } + specs := []injectSpec{{envVar: "ACTIVE_TOKEN", host: "api.example.com"}} + + err := resolveInject(plan, specs) + require.Error(t, err) + assert.Contains(t, err.Error(), `credential injection host "api.example.com" is not allowed`) + assert.Nil(t, plan.CA) + assert.Empty(t, plan.InjectBindings) +} + +func TestResolveInjectAllowsWildcardDomain(t *testing.T) { + if systemCABundle() == "" { + t.Skip("system CA bundle unavailable") + } + t.Setenv("ACTIVE_TOKEN", "secret") + plan := &SandboxPlan{ + TempDir: t.TempDir(), + EnvSet: map[string]string{}, + AllowedDomains: []string{"*.example.com"}, + ProxyEnabled: true, + } + specs := []injectSpec{{envVar: "ACTIVE_TOKEN", host: "api.example.com"}} + + require.NoError(t, resolveInject(plan, specs)) + assert.NotNil(t, plan.CA) + assert.Contains(t, plan.InjectBindings, "api.example.com") + assert.Equal(t, injectPlaceholder("ACTIVE_TOKEN"), plan.EnvSet["ACTIVE_TOKEN"]) +} + func TestWriteCABundle(t *testing.T) { dir := t.TempDir() caPEM := []byte("-----BEGIN CERTIFICATE-----\nPERRUNCA\n-----END CERTIFICATE-----\n") diff --git a/sandbox/plan.go b/sandbox/plan.go index 75cb7f0..ab8bbe6 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -638,8 +638,19 @@ func resolveInject(plan *SandboxPlan, specs []injectSpec) error { if len(bindings) == 0 { return nil // nothing to inject (all source vars were absent) } + matcher := policy.NewDomainMatcher(plan.AllowedDomains) + hosts := make([]string, 0, len(bindings)) + for host := range bindings { + hosts = append(hosts, host) + } + sort.Strings(hosts) + for _, host := range hosts { + if !matcher.Match(host) { + return fmt.Errorf("credential injection host %q is not allowed; add --domains %s", host, host) + } + } if !plan.ProxyEnabled { - return fmt.Errorf("credential injection requires the network proxy (needs user and network namespaces)") + return fmt.Errorf("credential injection requires the network proxy; allow the host with --domains and do not use --unrestricted-net") } ca, err := proxy.NewCA() if err != nil { @@ -1374,7 +1385,7 @@ func buildDegradedPlan(cfg *config.Config, caps *Capabilities) (*SandboxPlan, er plan := &SandboxPlan{Caps: caps} if len(cfg.InjectHeader) > 0 { - return nil, fmt.Errorf("credential injection (--inject-header) is only supported on Linux") + return nil, fmt.Errorf("credential injection (--inject-header) is only supported on Linux and macOS") } if len(cfg.AllowedDomains) > 0 { diff --git a/sandbox/plan_darwin.go b/sandbox/plan_darwin.go index ea31154..6e1bbe0 100644 --- a/sandbox/plan_darwin.go +++ b/sandbox/plan_darwin.go @@ -24,8 +24,12 @@ func (darwinPlanBuilder) BuildPlan(cfg *config.Config, caps *Capabilities, logge if caps.Seatbelt != nil { return nil, fmt.Errorf("fatal: sandbox-exec is required on macOS: %w", caps.Seatbelt) } - if len(cfg.InjectHeader) > 0 { - return nil, fmt.Errorf("credential injection (--inject-header) is only supported on Linux") + + // Parse credential-injection bindings early so invalid config fails before + // other plan work. Active bindings are resolved after network and env setup. + injects, err := parseInjectHeader(cfg.InjectHeader) + if err != nil { + return nil, err } plan := &SandboxPlan{Caps: caps, Quiet: cfg.Quiet, Logger: logger} @@ -49,7 +53,7 @@ func (darwinPlanBuilder) BuildPlan(cfg *config.Config, caps *Capabilities, logge } warnHostHomePathMismatch(cfg, sandboxHome, hostHome) - hasFiltering := (len(cfg.AllowedDomains) > 0 || len(cfg.AllowedIPs) > 0) && !cfg.UnrestrictedNet + hasFiltering := (len(cfg.AllowedDomains) > 0 || len(cfg.AllowedIPs) > 0 || cfg.HostLoopback) && !cfg.UnrestrictedNet plan.ProxyEnabled = hasFiltering // Seatbelt enforcement. @@ -69,6 +73,9 @@ func (darwinPlanBuilder) BuildPlan(cfg *config.Config, caps *Capabilities, logge if err := resolveEnv(plan, cfg); err != nil { return nil, err } + if err := resolveInject(plan, injects); err != nil { + return nil, err + } resolveDenials(plan, &removals) // Seatbelt-specific: system.sb (imported in generateSBPL) grants read diff --git a/sandbox/plan_darwin_test.go b/sandbox/plan_darwin_test.go index 4c32685..adac2e6 100644 --- a/sandbox/plan_darwin_test.go +++ b/sandbox/plan_darwin_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/upsun/curb/config" ) func TestIsCoveredBySubpath(t *testing.T) { @@ -82,3 +83,51 @@ func TestAddTerminfo_AlreadyCovered(t *testing.T) { assert.Equal(t, []string{parent}, plan.ROPaths) } + +func TestDarwinBuildPlan_InjectHeaderInactive(t *testing.T) { + t.Setenv("DARWIN_INJECT_TOKEN", "") + cfg := &config.Config{ + InjectHeader: []string{"DARWIN_INJECT_TOKEN=api.example.com"}, + } + + plan, err := BuildPlan(cfg, &Capabilities{}, nil) + require.NoError(t, err) + defer plan.Cleanup() + + assert.Empty(t, plan.InjectBindings) + assert.Nil(t, plan.CA) + assert.False(t, plan.ProxyEnabled) +} + +func TestDarwinBuildPlan_InjectHeaderActiveRequiresAllowedDomain(t *testing.T) { + t.Setenv("DARWIN_INJECT_TOKEN", "secret") + cfg := &config.Config{ + InjectHeader: []string{"DARWIN_INJECT_TOKEN=api.example.com"}, + } + + plan, err := BuildPlan(cfg, &Capabilities{}, nil) + require.Error(t, err) + assert.Nil(t, plan) + assert.Contains(t, err.Error(), `credential injection host "api.example.com" is not allowed`) +} + +func TestDarwinBuildPlan_InjectHeaderActive(t *testing.T) { + if systemCABundle() == "" { + t.Skip("system CA bundle unavailable") + } + t.Setenv("DARWIN_INJECT_TOKEN", "secret") + cfg := &config.Config{ + AllowedDomains: []string{"api.example.com"}, + InjectHeader: []string{"DARWIN_INJECT_TOKEN=api.example.com"}, + } + + plan, err := BuildPlan(cfg, &Capabilities{}, nil) + require.NoError(t, err) + defer plan.Cleanup() + + assert.True(t, plan.UseSeatbelt) + assert.True(t, plan.ProxyEnabled) + assert.NotNil(t, plan.CA) + assert.Contains(t, plan.InjectBindings, "api.example.com") + assert.Equal(t, injectPlaceholder("DARWIN_INJECT_TOKEN"), plan.EnvSet["DARWIN_INJECT_TOKEN"]) +} diff --git a/sandbox/plan_linux.go b/sandbox/plan_linux.go index a97ed23..d25f145 100644 --- a/sandbox/plan_linux.go +++ b/sandbox/plan_linux.go @@ -17,15 +17,12 @@ func (linuxPlanBuilder) BuildPlan(cfg *config.Config, caps *Capabilities, logger plan := &SandboxPlan{Caps: caps, Quiet: cfg.Quiet, Logger: logger} var removals planRemovals - // Parse credential-injection bindings first and ensure their hosts are - // allowed, so capability/proxy resolution routes them through the proxy. + // Parse credential-injection bindings early so invalid config fails before + // other plan work. Active bindings are resolved after network and env setup. injects, err := parseInjectHeader(cfg.InjectHeader) if err != nil { return nil, err } - for _, s := range injects { - cfg.AllowedDomains = appendUniq(cfg.AllowedDomains, s.host) - } // Create tmpDir first — needed for sandbox HOME fallback. tmpDir, err := createTempDir() From 3e5b7ebe2f03e4348516fb6c85f5e9d61690604e Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 08:25:17 +0100 Subject: [PATCH 12/27] fix: address credential-injection review findings 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) --- config/profile_test.go | 2 +- docs/configuration.md | 17 +++++- policy/validate.go | 2 +- proxy/handler.go | 17 ++++-- proxy/inject.go | 66 +++++++++++++++------- proxy/inject_test.go | 119 +++++++++++++++++++++++++++++++++++++-- proxy/socks5.go | 23 +++++++- sandbox/inject_test.go | 20 ++++++- sandbox/plan.go | 30 ++++++---- sandbox/proxy_handler.go | 29 ++++++---- 10 files changed, 262 insertions(+), 63 deletions(-) diff --git a/config/profile_test.go b/config/profile_test.go index 504241c..b381d0d 100644 --- a/config/profile_test.go +++ b/config/profile_test.go @@ -123,7 +123,7 @@ func TestMergeProfiles(t *testing.T) { assert.Contains(t, cfg.EnvPassthrough, "SSH_AUTH_SOCK") } -func TestMergeProfiles_ClaudeSealsApiKey(t *testing.T) { +func TestMergeProfiles_ClaudeInjectsApiKey(t *testing.T) { cmd := newTestCmd(nil) cfg, err := FromFlags(cmd) require.NoError(t, err) diff --git a/docs/configuration.md b/docs/configuration.md index f4b6607..b49ba1d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -239,8 +239,14 @@ host* the sandbox sees only the placeholder and the proxy substitutes the real key in whichever header Claude Code sends (`x-api-key` or, for OAuth, `Authorization`). With no host key set it is a no-op and OAuth/subscription auth works unchanged. On first run Claude Code may prompt once to approve the -placeholder as a custom key. A custom `ANTHROPIC_BASE_URL` is not covered -(injection targets `api.anthropic.com`). +placeholder as a custom key. + +The profile binding targets `api.anthropic.com`, so a custom `ANTHROPIC_BASE_URL` +(an internal gateway) is not covered: the gateway would receive the placeholder +and auth would fail. For a gateway, either bind the key to its host as well — +`--inject-header ANTHROPIC_API_KEY=gateway.example.com` (bindings accumulate, so +both hosts inject) — or, if the gateway is trusted, pass the key through with +`--env ANTHROPIC_API_KEY`. The flag is repeatable (bindings accumulate). For active bindings, curb generates a combined CA bundle (system roots plus the per-run CA) and points the @@ -254,6 +260,13 @@ setups, for example — may fail against a host with injection enabled. Hosts without an injection binding keep the untouched passthrough relay and are unaffected. +Injection requires HTTPS. A request to a bound host over plain HTTP is refused +(injecting over cleartext would expose the credential), so a binding host should +be one the client reaches over TLS. Injection applies on both the HTTPS proxy +and the SOCKS5 path (used by tools that route via `ALL_PROXY`), as long as the +client sends the hostname (`socks5h`, which curb advertises) rather than a +pre-resolved IP. + It is also settable via the `CURB_INJECT_HEADER` environment variable (comma-separated) and the `inject-header:` config-file/profile key, merged additively like `domains`. The value is resolved at run time wherever the diff --git a/policy/validate.go b/policy/validate.go index 4be320e..da69491 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -84,7 +84,7 @@ func ParseInjectHeader(entry string) (envVar, host string, err error) { return "", "", fmt.Errorf("must be ENV_VAR=HOST, got %q", entry) } if !ValidEnvName(envVar) { - return "", "", fmt.Errorf("env var name %q is not a valid environment variable name", envVar) + return "", "", fmt.Errorf("%q is not a valid environment variable name", envVar) } host, err = ValidateInjectHost(host) if err != nil { diff --git a/proxy/handler.go b/proxy/handler.go index 26c2c63..83a9006 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -47,11 +47,9 @@ func (h *Handler) handleCONNECT(w http.ResponseWriter, r *http.Request) { // A bound host is TLS-terminated so the credential can be injected; // everything else keeps the passthrough relay below. - if h.Injector != nil { - if injs, ok := h.Injector.binding(host); ok { - h.injectCONNECT(w, host, port, injs) - return - } + if injs, ok := h.Injector.binding(host); ok { + h.injectCONNECT(w, host, port, injs) + return } // Hijack the client connection. @@ -101,6 +99,15 @@ func (h *Handler) handleHTTP(w http.ResponseWriter, r *http.Request) { return } + // A credential is bound to this host but the request is plain HTTP. Refuse + // rather than forward: injecting over cleartext would expose the real + // credential, and forwarding the placeholder unchanged fails auth silently. + if _, ok := h.Injector.binding(host); ok { + http.Error(w, "curb: credential injection requires HTTPS for "+host, http.StatusBadGateway) + h.logEvent("proxy_http", r.Host, "blocked", "inject-requires-https") + return + } + // Dial the upstream (using request context for cancellation on client disconnect). target := net.JoinHostPort(host, port) upstream, err := h.getDialer().DialContext(r.Context(), "tcp", target) diff --git a/proxy/inject.go b/proxy/inject.go index beec439..7ee7bfb 100644 --- a/proxy/inject.go +++ b/proxy/inject.go @@ -28,7 +28,7 @@ type CA struct { key *ecdsa.PrivateKey certPEM []byte - mu sync.Mutex + mu sync.RWMutex leaves map[string]*tls.Certificate } @@ -71,13 +71,30 @@ func NewCA() (*CA, error) { func (ca *CA) CertPEM() []byte { return ca.certPEM } // leafFor returns a leaf certificate for host, signed by the CA. Leaves are -// cached per host for the life of the run. +// cached per host for the life of the run. Signing happens without the lock +// held, so a cache hit for one host never blocks behind a sign for another. func (ca *CA) leafFor(host string) (*tls.Certificate, error) { + ca.mu.RLock() + c := ca.leaves[host] + ca.mu.RUnlock() + if c != nil { + return c, nil + } + leaf, err := ca.signLeaf(host) + if err != nil { + return nil, err + } ca.mu.Lock() defer ca.mu.Unlock() - if c, ok := ca.leaves[host]; ok { - return c, nil + if existing := ca.leaves[host]; existing != nil { + return existing, nil // another goroutine won the race; reuse its leaf } + ca.leaves[host] = leaf + return leaf, nil +} + +// signLeaf mints a fresh leaf certificate for host. It holds no lock. +func (ca *CA) signLeaf(host string) (*tls.Certificate, error) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, err @@ -104,9 +121,7 @@ func (ca *CA) leafFor(host string) (*tls.Certificate, error) { if err != nil { return nil, err } - leaf := &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leafCert} - ca.leaves[host] = leaf - return leaf, nil + return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leafCert}, nil } // Injection is a credential bound to a destination host: on requests the proxy @@ -155,6 +170,9 @@ func (in *Injector) Bind(host string, inj Injection) { } func (in *Injector) binding(host string) ([]Injection, bool) { + if in == nil { + return nil, false // nil-safe so callers need no separate guard + } injs, ok := in.byHost[policy.NormalizeHost(host)] return injs, ok } @@ -179,29 +197,39 @@ func (h *Handler) injectCONNECT(w http.ResponseWriter, host, port string, injs [ return } - leaf, err := h.Injector.CA.leafFor(host) + h.logEvent("proxy_inject", host, "allowed", "") + if err := h.Injector.Serve(clientConn, host, port, injs); err != nil { + h.logEvent("proxy_inject", host, "error", err.Error()) + } +} + +// Serve terminates the client's TLS with a per-run leaf for host, then forwards +// each decrypted request to the real upstream with the bound credential +// injected. It is the shared injection path for both the HTTP CONNECT and the +// SOCKS5 egress routes; the caller has already accepted the connection (written +// the CONNECT 200 or the SOCKS5 success reply). +func (in *Injector) Serve(client net.Conn, host, port string, injs []Injection) error { + leaf, err := in.CA.leafFor(host) if err != nil { - h.logEvent("proxy_inject", host, "error", "leaf: "+err.Error()) - return + return fmt.Errorf("leaf: %w", err) } - tlsConn := tls.Server(clientConn, &tls.Config{ + tlsConn := tls.Server(client, &tls.Config{ Certificates: []tls.Certificate{*leaf}, MinVersion: tls.VersionTLS12, }) if err := tlsConn.Handshake(); err != nil { - h.logEvent("proxy_inject", host, "error", "tls handshake: "+err.Error()) - return + return fmt.Errorf("tls handshake: %w", err) } defer func() { _ = tlsConn.Close() }() - h.logEvent("proxy_inject", host, "allowed", "") - h.serveInjected(tlsConn, host, port, injs) + in.serveInjected(tlsConn, host, port, injs) + return nil } // serveInjected reads requests off the decrypted client stream and forwards // each to the upstream with the bound credentials set, relaying the response. -func (h *Handler) serveInjected(client net.Conn, host, port string, injs []Injection) { - rt := h.Injector.Upstream +func (in *Injector) serveInjected(client net.Conn, host, port string, injs []Injection) { + rt := in.Upstream // authority is the upstream the request is bound to. Drop the default // https port so the Host header matches what a direct client would send. authority := host @@ -250,9 +278,7 @@ func replaceInHeaders(hdr http.Header, injs []Injection) { for _, values := range hdr { for i, v := range values { for _, inj := range injs { - if inj.Placeholder != "" { - v = strings.ReplaceAll(v, inj.Placeholder, inj.Value) - } + v = strings.ReplaceAll(v, inj.Placeholder, inj.Value) } values[i] = v } diff --git a/proxy/inject_test.go b/proxy/inject_test.go index 1638c41..450b0fc 100644 --- a/proxy/inject_test.go +++ b/proxy/inject_test.go @@ -1,6 +1,7 @@ package proxy import ( + "bufio" "context" "crypto/tls" "crypto/x509" @@ -48,12 +49,10 @@ func (r *authRecorder) got(name string) []string { return out } -// injectTestProxy starts a curb proxy that allows the given hosts and applies -// the configured placeholder substitutions, routing upstream to the local -// recorders. -func injectTestProxy(t *testing.T, ca *CA, bindings map[string][]Injection, upstreams map[string]*authRecorder) *url.URL { - t.Helper() - +// newTestInjector builds an injector for the given bindings whose upstream +// reaches the local recorders instead of the real hosts, plus the set of +// allowed hosts derived from the upstreams. +func newTestInjector(ca *CA, bindings map[string][]Injection, upstreams map[string]*authRecorder) (*Injector, map[string]bool) { allowed := map[string]bool{} dialMap := map[string]*authRecorder{} for host, rec := range upstreams { @@ -89,7 +88,16 @@ func injectTestProxy(t *testing.T, ca *CA, bindings map[string][]Injection, upst return tc, nil }, } + return injector, allowed +} +// injectTestProxy starts a curb proxy that allows the given hosts and applies +// the configured placeholder substitutions, routing upstream to the local +// recorders. +func injectTestProxy(t *testing.T, ca *CA, bindings map[string][]Injection, upstreams map[string]*authRecorder) *url.URL { + t.Helper() + + injector, allowed := newTestInjector(ca, bindings, upstreams) handler := &Handler{ FilterBase: FilterBase{DomainCheck: func(h string) bool { return allowed[h] }}, Injector: injector, @@ -253,6 +261,105 @@ func TestInjector_BindsCredentialToDestination(t *testing.T) { assert.NotContains(t, other.got("Authorization")[0], "ghs_realtoken") } +// TestInjector_RefusesPlainHTTP confirms a bound host reached over plain HTTP +// is refused, not forwarded: the credential must never go out over cleartext. +func TestInjector_RefusesPlainHTTP(t *testing.T) { + ca, err := NewCA() + require.NoError(t, err) + + gh := newAuthRecorder(t) + injector, allowed := newTestInjector(ca, + map[string][]Injection{"api.github.com": {{Placeholder: "GH_PH", Value: "ghs_realtoken"}}}, + map[string]*authRecorder{"api.github.com": gh}, + ) + handler := &Handler{ + FilterBase: FilterBase{DomainCheck: func(h string) bool { return allowed[h] }}, + Injector: injector, + } + proxySrv := httptest.NewServer(handler) + t.Cleanup(proxySrv.Close) + proxyURL, err := url.Parse(proxySrv.URL) + require.NoError(t, err) + + client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}} + resp, err := client.Get("http://api.github.com/user") + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(body), "requires HTTPS") + // The request never reached an upstream. + assert.Equal(t, 0, gh.count()) +} + +// TestSOCKS5Server_Injects confirms the SOCKS5 egress path injects credentials +// for a bound host, matching the HTTP CONNECT path. A socks5h client sends the +// hostname, so the binding matches and TLS is terminated. +func TestSOCKS5Server_Injects(t *testing.T) { + ca, err := NewCA() + require.NoError(t, err) + + gh := newAuthRecorder(t) + injector, allowed := newTestInjector(ca, + map[string][]Injection{"api.github.com": {{Placeholder: "GH_PH", Value: "ghs_realtoken"}}}, + map[string]*authRecorder{"api.github.com": gh}, + ) + srv := &SOCKS5Server{ + FilterBase: FilterBase{DomainCheck: func(h string) bool { return allowed[h] }}, + Injector: injector, + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer func() { _ = ln.Close() }() + go func() { _ = srv.Serve(ln) }() + + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + // SOCKS5 no-auth handshake. + _, err = conn.Write([]byte{0x05, 0x01, 0x00}) + require.NoError(t, err) + reply := make([]byte, 2) + _, err = io.ReadFull(conn, reply) + require.NoError(t, err) + require.Equal(t, []byte{0x05, 0x00}, reply) + + // CONNECT api.github.com:443 by name (socks5h). + host := "api.github.com" + req := []byte{0x05, 0x01, 0x00, 0x03, byte(len(host))} + req = append(req, []byte(host)...) + req = append(req, byte(443>>8), byte(443&0xff)) + _, err = conn.Write(req) + require.NoError(t, err) + repHeader := make([]byte, 10) + _, err = io.ReadFull(conn, repHeader) + require.NoError(t, err) + require.Equal(t, byte(0x00), repHeader[1], "expected success reply") + + // The proxy now terminates TLS with the per-run CA; send a request carrying + // the placeholder and confirm the upstream sees the real credential. + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(ca.CertPEM()) + tc := tls.Client(conn, &tls.Config{RootCAs: pool, ServerName: host, MinVersion: tls.VersionTLS12}) + require.NoError(t, tc.Handshake()) + + httpReq, err := http.NewRequest(http.MethodGet, "https://api.github.com/user", nil) + require.NoError(t, err) + httpReq.Header.Set("Authorization", "Bearer GH_PH") + require.NoError(t, httpReq.Write(tc)) + + resp, err := http.ReadResponse(bufio.NewReader(tc), httpReq) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusOK, resp.StatusCode) + + require.Equal(t, 1, gh.count()) + assert.Equal(t, "Bearer ghs_realtoken", gh.got("Authorization")[0]) +} + // TestInjector_LeavesOtherHeadersUntouched confirms only the placeholder is // replaced; unrelated header content passes through verbatim. func TestInjector_LeavesOtherHeadersUntouched(t *testing.T) { diff --git a/proxy/socks5.go b/proxy/socks5.go index b28a6a1..e9e1b83 100644 --- a/proxy/socks5.go +++ b/proxy/socks5.go @@ -36,6 +36,9 @@ const ( // SOCKS5Server handles SOCKS5 CONNECT requests with domain/IP filtering. type SOCKS5Server struct { FilterBase + // Injector, when set, terminates TLS and injects a bound credential for + // hosts it has a binding for. Hosts without a binding are relayed as-is. + Injector *Injector } // Serve accepts connections from ln and handles each in a goroutine. @@ -104,8 +107,22 @@ func (s *SOCKS5Server) HandleConn(conn net.Conn) { return } - // 4. Dial the destination. + // 4. A bound host is TLS-terminated so the credential can be injected, + // matching the HTTP CONNECT path; everything else is relayed below. The + // binding matches on hostname, so this only fires for socks5h clients that + // send a name (curb advertises socks5h://). Address resolved to an IP by a + // socks5 client falls through to the raw relay. target := net.JoinHostPort(host, port) + if injs, ok := s.Injector.binding(host); ok { + s.sendReply(conn, socks5RepSuccess) + s.logEvent("socks5_inject", target, "allowed", "") + if err := s.Injector.Serve(conn, host, port, injs); err != nil { + s.logEvent("socks5_inject", target, "error", err.Error()) + } + return + } + + // 5. Dial the destination. remote, err := s.getDialer().Dial("tcp", target) if err != nil { s.sendReply(conn, socks5RepHostUnreachable) @@ -114,11 +131,11 @@ func (s *SOCKS5Server) HandleConn(conn net.Conn) { } defer func() { _ = remote.Close() }() - // 5. Success reply. + // 6. Success reply. s.sendReply(conn, socks5RepSuccess) s.logEvent("socks5_connect", target, "allowed", "") - // 6. Relay. + // 7. Relay. relay(conn, remote) } diff --git a/sandbox/inject_test.go b/sandbox/inject_test.go index 7cb3c8d..1c77692 100644 --- a/sandbox/inject_test.go +++ b/sandbox/inject_test.go @@ -104,12 +104,12 @@ func TestWriteCABundle(t *testing.T) { if systemCABundle() == "" { // Without system roots, the bundle would override TLS trust with an // incomplete set, so injection fails rather than break other hosts. - _, err := writeCABundle(dir, caPEM) + _, err := writeCABundle(dir, "", caPEM) require.Error(t, err) return } - path, err := writeCABundle(dir, caPEM) + path, err := writeCABundle(dir, "", caPEM) require.NoError(t, err) assert.Equal(t, filepath.Join(dir, "ca-bundle.pem"), path) @@ -119,3 +119,19 @@ func TestWriteCABundle(t *testing.T) { assert.Contains(t, string(data), "PERRUNCA") assert.Greater(t, len(data), len(caPEM), "combined bundle should include system roots") } + +func TestWriteCABundle_ExtendsUserBase(t *testing.T) { + dir := t.TempDir() + caPEM := []byte("-----BEGIN CERTIFICATE-----\nPERRUNCA\n-----END CERTIFICATE-----\n") + base := filepath.Join(dir, "custom-roots.pem") + require.NoError(t, os.WriteFile(base, []byte("-----BEGIN CERTIFICATE-----\nCUSTOMROOT\n-----END CERTIFICATE-----\n"), 0o644)) + + path, err := writeCABundle(dir, base, caPEM) + require.NoError(t, err) + + data, err := os.ReadFile(path) + require.NoError(t, err) + // A user-provided base is extended, not discarded. + assert.Contains(t, string(data), "CUSTOMROOT") + assert.Contains(t, string(data), "PERRUNCA") +} diff --git a/sandbox/plan.go b/sandbox/plan.go index ab8bbe6..a8861b9 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -666,8 +666,10 @@ func resolveInject(plan *SandboxPlan, specs []injectSpec) error { // Trust-store delivery: a combined bundle (system roots + per-run CA) the // action trusts for the proxy's leaf certs. The CA validates only inside - // this run, so it is not sensitive to the action. - bundle, err := writeCABundle(plan.TempDir, ca.CertPEM()) + // this run, so it is not sensitive to the action. A user-provided + // SSL_CERT_FILE is used as the base (extended, not discarded) so a custom + // trust store still applies to non-injected hosts. + bundle, err := writeCABundle(plan.TempDir, plan.EnvSet["SSL_CERT_FILE"], ca.CertPEM()) if err != nil { return err } @@ -678,19 +680,23 @@ func resolveInject(plan *SandboxPlan, specs []injectSpec) error { return nil } -// writeCABundle writes a PEM bundle of the system roots plus the per-run CA to -// the temp dir and returns its path. It fails if the system roots cannot be -// located or read: this bundle replaces the sandbox's TLS trust (SSL_CERT_FILE -// etc.), so a bundle holding only the per-run CA would break trust for every -// HTTPS destination other than the injected hosts. -func writeCABundle(tmpDir string, caPEM []byte) (string, error) { - sys := systemCABundle() - if sys == "" { +// writeCABundle writes a PEM bundle of the base roots plus the per-run CA to +// the temp dir and returns its path. base is the trust store to extend (a +// user-provided SSL_CERT_FILE); when empty it falls back to the system roots. +// It fails if the base roots cannot be located or read: this bundle replaces +// the sandbox's TLS trust (SSL_CERT_FILE etc.), so a bundle holding only the +// per-run CA would break trust for every HTTPS destination other than the +// injected hosts. +func writeCABundle(tmpDir, base string, caPEM []byte) (string, error) { + if base == "" { + base = systemCABundle() + } + if base == "" { return "", fmt.Errorf("credential injection: no system CA bundle found; cannot deliver TLS trust to the sandbox without overriding it") } - buf, err := os.ReadFile(sys) + buf, err := os.ReadFile(base) if err != nil { - return "", fmt.Errorf("credential injection: reading system CA bundle %s: %w", sys, err) + return "", fmt.Errorf("credential injection: reading CA bundle %s: %w", base, err) } if len(buf) > 0 && buf[len(buf)-1] != '\n' { buf = append(buf, '\n') diff --git a/sandbox/proxy_handler.go b/sandbox/proxy_handler.go index 86f518a..c014a48 100644 --- a/sandbox/proxy_handler.go +++ b/sandbox/proxy_handler.go @@ -19,24 +19,31 @@ func buildFilterBase(plan *SandboxPlan) proxy.FilterBase { return fb } -// buildProxyHandler creates the HTTP proxy handler from the sandbox plan. -func buildProxyHandler(plan *SandboxPlan) *proxy.Handler { - h := &proxy.Handler{FilterBase: buildFilterBase(plan)} - if plan.CA != nil && len(plan.InjectBindings) > 0 { - inj := proxy.NewInjector(plan.CA) - for host, injections := range plan.InjectBindings { - for _, injection := range injections { - inj.Bind(host, injection) - } +// buildInjector creates the credential injector from the plan, or nil when no +// bindings are configured. It is shared by the HTTP and SOCKS5 proxies so both +// egress paths inject for bound hosts. +func buildInjector(plan *SandboxPlan) *proxy.Injector { + if plan.CA == nil || len(plan.InjectBindings) == 0 { + return nil + } + inj := proxy.NewInjector(plan.CA) + for host, injections := range plan.InjectBindings { + for _, injection := range injections { + inj.Bind(host, injection) } - h.Injector = inj } - return h + return inj +} + +// buildProxyHandler creates the HTTP proxy handler from the sandbox plan. +func buildProxyHandler(plan *SandboxPlan) *proxy.Handler { + return &proxy.Handler{FilterBase: buildFilterBase(plan), Injector: buildInjector(plan)} } // buildSOCKS5Server creates the SOCKS5 proxy server from the sandbox plan. func buildSOCKS5Server(plan *SandboxPlan) *proxy.SOCKS5Server { return &proxy.SOCKS5Server{ FilterBase: buildFilterBase(plan), + Injector: buildInjector(plan), } } From 819a5c52811430b2af7b3a02fd6dbeca1a93305d Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 09:11:36 +0100 Subject: [PATCH 13/27] feat: extend --inject-header with colon syntax, ports, IPs, host lists 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) --- CLAUDE.md | 2 +- README.md | 6 +- cmd/root.go | 4 +- config/config_test.go | 6 +- config/configfile.go | 2 +- config/configfile_test.go | 14 ++-- config/profile_test.go | 2 +- config/profiles/claude.yaml | 2 +- docs/comparison-zerobox.md | 9 ++- docs/configuration.md | 60 ++++++++------ policy/validate.go | 122 +++++++++++++++++++++++------ policy/validate_test.go | 49 ++++++++---- proxy/handler.go | 4 +- proxy/inject.go | 54 ++++++++++--- proxy/inject_test.go | 18 +++-- proxy/socks5.go | 11 ++- sandbox/capabilities_linux_test.go | 2 +- sandbox/inject_integration_test.go | 4 +- sandbox/inject_test.go | 67 ++++++++++++---- sandbox/plan.go | 85 +++++++++++++------- sandbox/plan_darwin_test.go | 9 ++- sandbox/proxy_handler.go | 4 +- sandbox/skill.go | 10 +-- 23 files changed, 376 insertions(+), 170 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 11dc4a0..a2739aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ go test ./sandbox/ -run TestCurb_FS_ -v # run a subset - The SOCKS proxy is advertised via `ALL_PROXY`/`all_proxy` (HTTP/HTTPS still prefer `HTTP_PROXY`/`HTTPS_PROXY`). A user-provided value takes precedence, so `--env ALL_PROXY=` suppresses it — useful for HTTP-only workloads with clients that eagerly construct a SOCKS transport (e.g. httpx, which then needs the `httpx[socks]` extra). ssh routing does not use it (it has its own config). - `--ips` allows connections to specific IP addresses or CIDR ranges. Works alongside or independently of `--domains`. IP-matched connections bypass domain filtering (forwarded directly on any port via SOCKS5 or CONNECT). In IPs-only mode (no `--domains`), DNS queries are not intercepted. - `--unrestricted-net` skips network namespace creation entirely, allowing unrestricted network access while keeping FS sandboxing. Cannot be combined with `--domains` or `--ips`. -- `--inject-header ENV_VAR=HOST` (Linux) keeps a credential out of the sandbox. curb sets the child's `ENV_VAR` to a stable per-var placeholder; the child never sees the real value. For `HOST` only, the proxy terminates TLS and substitutes the real value (read from the host's `ENV_VAR`) wherever the client put the placeholder among the request headers — so it is header-agnostic (no knowledge of the auth scheme). The binding is var-first because a credential belongs to its variable and may be valid for several hosts. Opt-in per credential: if `ENV_VAR` is unset or empty on the host the binding is skipped (so the `claude` profile is a no-op for OAuth users). The proxy uses a per-run CA delivered to the child as a combined bundle (system roots + per-run CA) via the standard CA env vars; the CA key and real token stay parent-only. Settable via the flag, `CURB_INJECT_HEADER`, or the `inject-header:` config/profile key (merged additively). +- `--inject-header ENV_VAR:HOST[:PORT][,HOST...]` keeps a credential out of the sandbox. curb sets the child's `ENV_VAR` to a stable per-var placeholder; the child never sees the real value. Each destination is `HOST[:PORT]` (a hostname or IP, port defaulting to 443) and bindings are keyed by `host:port`: the proxy terminates TLS and substitutes the real value (read from the host's `ENV_VAR`) wherever the client put the placeholder among the request headers — header-agnostic (no knowledge of the auth scheme) — but only on that exact host:port, so a connection to the same host on a different port is relayed unchanged. The binding is var-first because a credential belongs to its variable and may be valid for several destinations; the right side is a comma-separated list. Hostname destinations must be allowed by `--domains`, IP destinations by `--ips`. Opt-in per credential: if `ENV_VAR` is unset or empty on the host the binding is skipped (so the `claude` profile is a no-op for OAuth users). The proxy uses a per-run CA delivered to the child as a combined bundle (system roots + per-run CA) via the standard CA env vars; the CA key and real token stay parent-only. Settable via the flag, `CURB_INJECT_HEADER`, or the `inject-header:` config/profile key (merged additively). In YAML, a list item needs no space after the colon (`- FOO:host`); `- FOO: host` parses as a map and is rejected. - `--domains` validates input: rejects URLs (suggests bare domain), IP addresses (suggests `--ips`), invalid characters, and malformed wildcards. `--ips` validates that values parse as IP addresses or CIDR prefixes. - `!` prefix in list flags removes from defaults AND actively denies via overmount when the path is under an allowed parent. `--read '!/path'` hides (tmpfs/dev-null), `--write '!/path'` makes read-only, `--exec '!/path'` makes noexec. `!*` clears all defaults (not deny-all). `\!` escapes literal `!`. Sub-path denials require mount NS; Landlock-only mode warns. - HOME defaults to a private tmpDir. `--env HOME` passes through the host HOME; `--env HOME=/path` sets it explicitly. `~` and `$HOME` in config/profile paths both resolve to the **host** home (the shell's view), not the sandbox HOME — they are host-side shorthands. When sandbox HOME differs from host HOME and a path uses `~`, curb warns that the sandboxed program cannot reach those paths via its own `$HOME`. Built-in profiles include `HOME` in their env passthrough so sandbox HOME = host HOME and `~` paths align. See `docs/configuration.md` for full details. diff --git a/README.md b/README.md index dbda773..968164c 100644 --- a/README.md +++ b/README.md @@ -155,10 +155,10 @@ curb --env 'DATABASE_URL' -- ./migrate.sh ``` Inject a credential the sandboxed process never sees — the token stays in -curb's proxy, which adds it to requests bound for the named host: +curb's proxy, which adds it to requests bound for the named destination: ``` -GH_TOKEN=ghp_xxx curb --domains api.github.com --inject-header 'GH_TOKEN=api.github.com' -- gh api user +GH_TOKEN=ghp_xxx curb --domains api.github.com --inject-header 'GH_TOKEN:api.github.com' -- gh api user ``` ## CLI reference @@ -169,7 +169,7 @@ GH_TOKEN=ghp_xxx curb --domains api.github.com --inject-header 'GH_TOKEN=api.git |------|---------|-------------| | `--domains` | `CURB_DOMAINS` | Allowed domain patterns (comma-separated). `*.example.com` for subdomains, `*` to allow all. | | `--ips` | `CURB_IPS` | Allowed IP addresses or CIDR ranges (e.g. `10.0.0.1`, `192.168.0.0/16`, `::1`). | -| `--inject-header` | `CURB_INJECT_HEADER` | Keep a credential out of the sandbox: `ENV_VAR=HOST` sets the sandbox's copy of `ENV_VAR` to a placeholder, and the proxy replaces it with the real host value in requests to `HOST` (terminating TLS), wherever the client sent it (any header — `Authorization: Bearer`, `x-api-key`, etc.), so no auth-scheme knowledge is needed. `HOST` must also be allowed by `--domains` or a profile. Skipped if `ENV_VAR` is unset. Repeatable; also settable via the `inject-header:` config-file/profile key. | +| `--inject-header` | `CURB_INJECT_HEADER` | Keep a credential out of the sandbox: `ENV_VAR:HOST[:PORT][,HOST...]` sets the sandbox's copy of `ENV_VAR` to a placeholder, and the proxy replaces it with the real value in requests to each destination (terminating TLS), wherever the client sent it (any header — `Authorization: Bearer`, `x-api-key`, etc.), so no auth-scheme knowledge is needed. A destination is a hostname or IP, with `PORT` defaulting to 443; the credential is injected only on that exact host:port. Each destination must also be allowed by `--domains` (hostnames) or `--ips` (IPs). Skipped if `ENV_VAR` is unset. Repeatable; also settable via the `inject-header:` config-file/profile key. | | `--host-loopback` | `CURB_HOST_LOOPBACK` | Forward localhost/127.0.0.1 traffic to the host loopback. Cannot combine with `--unrestricted-net`. | | `--unrestricted-net` | `CURB_UNRESTRICTED_NET` | Skip network filtering entirely. Cannot combine with `--domains`, `--ips`, or `--host-loopback`. | diff --git a/cmd/root.go b/cmd/root.go index c909a28..421bc4b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,7 +28,7 @@ Use -- before the command when it has its own flags.`, curb -p node -w . -- npm install # profile + writable cwd curb -a cargo build # auto-select profile curb --dry-run -p node -- npm install # preview sandbox plan - curb --domains api.github.com --inject-header GH_TOKEN=api.github.com -- gh pr list # inject a token, kept out of the sandbox`, + curb --domains api.github.com --inject-header GH_TOKEN:api.github.com -- gh pr list # inject a token, kept out of the sandbox`, Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { @@ -330,7 +330,7 @@ func registerFlags(cmd *cobra.Command) { f.StringSlice("ips", nil, "allowed IP/CIDR ranges (e.g. 10.0.0.1, 192.168.0.0/16)") f.Bool("unrestricted-net", false, "skip network restrictions entirely") f.Bool("host-loopback", false, "forward localhost traffic to host loopback") - f.StringSlice("inject-header", nil, "inject ENV_VAR's value as a credential to an allowed HOST, kept out of the sandbox (ENV_VAR=HOST; TLS terminated for HOST; skipped if ENV_VAR is unset)") + f.StringSlice("inject-header", nil, "inject ENV_VAR's value as a credential to allowed destinations, kept out of the sandbox (ENV_VAR:HOST[:PORT][,HOST...]; HOST is a hostname or IP, PORT defaults to 443; TLS terminated; skipped if ENV_VAR is unset)") // Executables. f.StringSlice("exec", nil, "allowed executables (default: invoked command only)") diff --git a/config/config_test.go b/config/config_test.go index 6adca33..2d0d221 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -126,14 +126,14 @@ func TestMergeEnv_ListsAdditive(t *testing.T) { } func TestMergeEnv_InjectHeaderAdditive(t *testing.T) { - cmd := newTestCmd([]string{"--inject-header", "B=b.com"}) + cmd := newTestCmd([]string{"--inject-header", "B:b.com"}) cfg, err := FromFlags(cmd) require.NoError(t, err) - t.Setenv("CURB_INJECT_HEADER", "A=a.com") + t.Setenv("CURB_INJECT_HEADER", "A:a.com") MergeEnv(cfg, cmd) - assert.Equal(t, []string{"B=b.com", "A=a.com"}, cfg.InjectHeader) + assert.Equal(t, []string{"B:b.com", "A:a.com"}, cfg.InjectHeader) } func TestMergeEnv_CommaSeparatedList(t *testing.T) { diff --git a/config/configfile.go b/config/configfile.go index ab859c7..13f91a6 100644 --- a/config/configfile.go +++ b/config/configfile.go @@ -112,7 +112,7 @@ func (cf *ConfigFile) validate() error { } for i, e := range cf.InjectHeader { if _, _, err := policy.ParseInjectHeader(e); err != nil { - return fmt.Errorf("inject-header[%d] %w", i, err) + return fmt.Errorf("inject-header[%d]: %w", i, err) } } for _, pair := range [...]struct { diff --git a/config/configfile_test.go b/config/configfile_test.go index e5b57ca..627624b 100644 --- a/config/configfile_test.go +++ b/config/configfile_test.go @@ -157,12 +157,12 @@ func TestLoadConfigFile_InjectHeader(t *testing.T) { path := filepath.Join(dir, ".curb.yaml") require.NoError(t, os.WriteFile(path, []byte(` inject-header: - - ANTHROPIC_API_KEY=api.anthropic.com + - ANTHROPIC_API_KEY:api.anthropic.com `), 0o644)) cf, err := LoadConfigFile(path) require.NoError(t, err) - assert.Equal(t, []string{"ANTHROPIC_API_KEY=api.anthropic.com"}, cf.InjectHeader) + assert.Equal(t, []string{"ANTHROPIC_API_KEY:api.anthropic.com"}, cf.InjectHeader) } func TestLoadConfigFile_InjectHeaderMissingHost(t *testing.T) { @@ -183,7 +183,7 @@ func TestLoadConfigFile_InjectHeaderWildcard(t *testing.T) { path := filepath.Join(dir, ".curb.yaml") require.NoError(t, os.WriteFile(path, []byte(` inject-header: - - "GH_TOKEN=*.github.com" + - "GH_TOKEN:*.github.com" `), 0o644)) _, err := LoadConfigFile(path) @@ -197,7 +197,7 @@ func TestLoadConfigFile_InjectHeaderInvalidName(t *testing.T) { path := filepath.Join(dir, ".curb.yaml") require.NoError(t, os.WriteFile(path, []byte(` inject-header: - - "bad-var=api.example.com" + - "bad-var:api.example.com" `), 0o644)) _, err := LoadConfigFile(path) @@ -253,14 +253,14 @@ func TestFindConfigFile_NotFound(t *testing.T) { } func TestMergeConfigFile_ListsPrepend(t *testing.T) { - cmd := newTestCmd([]string{"--domains", "cli.com", "--inject-header", "H=cli.com"}) + cmd := newTestCmd([]string{"--domains", "cli.com", "--inject-header", "H:cli.com"}) cfg, err := FromFlags(cmd) require.NoError(t, err) cf := &ConfigFile{ Domains: []string{"file.com"}, IPs: []string{"10.0.0.1"}, - InjectHeader: []string{"K=file.com"}, + InjectHeader: []string{"K:file.com"}, Read: []string{"/opt"}, Write: []string{"/data"}, Exec: []string{"python3"}, @@ -270,7 +270,7 @@ func TestMergeConfigFile_ListsPrepend(t *testing.T) { assert.Equal(t, []string{"file.com", "cli.com"}, cfg.AllowedDomains) assert.Equal(t, []string{"10.0.0.1"}, cfg.AllowedIPs) - assert.Equal(t, []string{"K=file.com", "H=cli.com"}, cfg.InjectHeader) + assert.Equal(t, []string{"K:file.com", "H:cli.com"}, cfg.InjectHeader) assert.Equal(t, []string{"/opt"}, cfg.ROPaths) assert.Equal(t, []string{"/data"}, cfg.RWPaths) assert.Equal(t, []string{"python3"}, cfg.ExecAllow) diff --git a/config/profile_test.go b/config/profile_test.go index b381d0d..fa58d6b 100644 --- a/config/profile_test.go +++ b/config/profile_test.go @@ -136,7 +136,7 @@ func TestMergeProfiles_ClaudeInjectsApiKey(t *testing.T) { // The key is injected to api.anthropic.com from the host env var; the // sandbox's ANTHROPIC_API_KEY becomes a placeholder, and injection is // skipped when the host var is unset (OAuth). - assert.Contains(t, cfg.InjectHeader, "ANTHROPIC_API_KEY=api.anthropic.com") + assert.Contains(t, cfg.InjectHeader, "ANTHROPIC_API_KEY:api.anthropic.com") // The real key is not passed through (it would defeat the injection). assert.NotContains(t, cfg.EnvPassthrough, "ANTHROPIC_API_KEY") } diff --git a/config/profiles/claude.yaml b/config/profiles/claude.yaml index 0f8fd70..aff76f0 100644 --- a/config/profiles/claude.yaml +++ b/config/profiles/claude.yaml @@ -47,7 +47,7 @@ exec: # Keep ANTHROPIC_API_KEY out of the sandbox: the proxy injects it into requests # to api.anthropic.com. Skipped when the host key is unset (OAuth users). inject-header: - - ANTHROPIC_API_KEY=api.anthropic.com + - ANTHROPIC_API_KEY:api.anthropic.com env: - HOME - SHELL diff --git a/docs/comparison-zerobox.md b/docs/comparison-zerobox.md index 8dd50ed..1d81580 100644 --- a/docs/comparison-zerobox.md +++ b/docs/comparison-zerobox.md @@ -113,7 +113,7 @@ only for approved hosts. The placeholder is 32 random bytes so it is long and unique enough not to collide with legitimate request content during that scan. -**curb** has opt-in credential injection via `--inject-header ENV_VAR=HOST`. +**curb** has opt-in credential injection via `--inject-header ENV_VAR:HOST`. The two tools' mechanisms have converged: the sandbox sees only a placeholder in `ENV_VAR`, never the real value; the proxy terminates TLS for `HOST` (presenting a per-run CA the sandbox trusts) and substitutes the @@ -140,9 +140,10 @@ letting a tool that approves a custom credential (e.g. Claude Code) approve it once rather than every run. Both placeholders carry no secret weight — neither is the credential. Second, zerobox couples the secret to an env var name (`--secret KEY=VALUE`) and the host separately (`--secret-host KEY=hosts`); curb's -single `ENV_VAR=HOST` does both — the env var both reads the real value on the +single `ENV_VAR:HOST` does both — the env var both reads the real value on the host and carries the placeholder in the sandbox. -Both put the variable first, leaving room for a credential to name several hosts. +Both put the variable first; curb's right side is a comma-separated list of +`HOST[:PORT]` destinations, so one credential can name several hosts. Both approaches prevent a compromised or malicious process from reading the real secret and exfiltrating it. The shared cost is TLS termination and CA @@ -277,7 +278,7 @@ process from ever seeing real credentials. This is valuable for AI agent use cases where the agent needs to call authenticated APIs but should not be able to read or exfiltrate the actual tokens. -curb now implements the same model: `--inject-header ENV_VAR=HOST` sets the +curb now implements the same model: `--inject-header ENV_VAR:HOST` sets the sandbox's copy of the variable to a placeholder and has the proxy substitute the real value in request headers, header-agnostically. Unlike zerobox, TLS termination is confined to the named hosts rather than applied to all allowed diff --git a/docs/configuration.md b/docs/configuration.md index b49ba1d..656ef47 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -187,23 +187,36 @@ And passes through: `PATH`, `TERM`, `COLORTERM`, `NO_COLOR`, `LANG`, `LC_ALL`, ` ## Credential injection -`--inject-header ENV_VAR=HOST` keeps a credential out of the sandbox entirely. -curb generates a stable placeholder for `ENV_VAR`, sets the sandbox's copy of -`ENV_VAR` to that placeholder (so the process never sees the real value), and -reads the real value from the *host's* `ENV_VAR`. Its proxy terminates TLS for -`HOST`, presenting a per-run CA that the sandbox trusts, and replaces the -placeholder with the real value wherever the client placed it among the request -headers on the way to the real upstream. The credential is bound to `HOST`, so -it is never attached to any other host the program connects to. `HOST` must be -an exact hostname — wildcards are rejected, since they cannot identify the single -destination a credential belongs to. The host is matched case-insensitively and a -trailing dot is ignored. +`--inject-header ENV_VAR:HOST[:PORT][,HOST...]` keeps a credential out of the +sandbox entirely. curb generates a stable placeholder for `ENV_VAR`, sets the +sandbox's copy of `ENV_VAR` to that placeholder (so the process never sees the +real value), and reads the real value from the *host's* `ENV_VAR`. Its proxy +terminates TLS for each destination, presenting a per-run CA that the sandbox +trusts, and replaces the placeholder with the real value wherever the client +placed it among the request headers on the way to the real upstream. The +credential is bound to those destinations only, so it is never attached to any +other host the program connects to. + +A destination is `HOST[:PORT]`, where `HOST` is a hostname or IP literal and +`PORT` defaults to 443. The credential is injected only on the exact host:port — +a connection to the same host on a different port is relayed unchanged, with no +credential. A hostname must be exact: wildcards are rejected, since they cannot +identify the single destination a credential belongs to. Hostnames are matched +case-insensitively and a trailing dot is ignored. List several destinations, +comma-separated, when one credential is valid for more than one host +(`GH_TOKEN:api.github.com,uploads.github.com`). An IPv6 literal needs no +brackets on its own but must be bracketed when a port follows +(`TOK:[2001:db8::1]:8443`). The binding is written variable-first because a credential belongs to its -variable and may be valid for more than one host; a comma-separated host list is -a natural future extension. The left side is always an environment variable name -— there is no literal form, because a literal would have no variable through -which to deliver the placeholder into the sandbox. +variable and may be valid for more than one destination. The left side is always +an environment variable name — there is no literal form, because a literal would +have no variable through which to deliver the placeholder into the sandbox. + +In a config file or profile, write a list item without a space after the colon +(`- GH_TOKEN:api.github.com`): that is a plain YAML string. Writing +`- GH_TOKEN: api.github.com` (with a space) makes YAML parse it as a mapping, and +curb rejects it. Injection is **header-agnostic**: curb substitutes the placeholder in whatever header the client emits, so it needs no knowledge of the host's auth scheme. The @@ -216,15 +229,16 @@ Injection is opt-in per credential: if `ENV_VAR` is unset or empty on the host, the binding is skipped silently (no error). This is what lets a profile carry an injection that simply does nothing when the credential is absent. -An active injection binding does not grant network access by itself. `HOST` must -also match the explicit domain allowlist from `--domains`, `CURB_DOMAINS`, -config, or a profile. If the host credential is present but the host is not -allowed, curb fails during planning instead of broadening the sandbox policy. +An active injection binding does not grant network access by itself. Each +destination must also be allowed: a hostname by `--domains` (`CURB_DOMAINS`, +config, or a profile), an IP by `--ips`. If the credential is present but the +destination is not allowed, curb fails during planning instead of broadening the +sandbox policy. ``` # Real value read from the host's $GH_TOKEN; the sandbox sees only a placeholder, # and gh sends it in whatever header it uses: -GH_TOKEN=ghp_xxx curb --domains api.github.com --inject-header 'GH_TOKEN=api.github.com' -- gh api user +GH_TOKEN=ghp_xxx curb --domains api.github.com --inject-header 'GH_TOKEN:api.github.com' -- gh api user ``` The placeholder is constant per variable (e.g. `curb-inject-GH_TOKEN-placeholder`) @@ -234,7 +248,7 @@ as Claude Code prompting to approve a custom API key) approves it once rather than on every run. The built-in **`claude`** profile does exactly this for Claude Code: it injects -`ANTHROPIC_API_KEY=api.anthropic.com`, so *when `ANTHROPIC_API_KEY` is set on the +`ANTHROPIC_API_KEY:api.anthropic.com`, so *when `ANTHROPIC_API_KEY` is set on the host* the sandbox sees only the placeholder and the proxy substitutes the real key in whichever header Claude Code sends (`x-api-key` or, for OAuth, `Authorization`). With no host key set it is a no-op and OAuth/subscription auth @@ -244,7 +258,7 @@ placeholder as a custom key. The profile binding targets `api.anthropic.com`, so a custom `ANTHROPIC_BASE_URL` (an internal gateway) is not covered: the gateway would receive the placeholder and auth would fail. For a gateway, either bind the key to its host as well — -`--inject-header ANTHROPIC_API_KEY=gateway.example.com` (bindings accumulate, so +`--inject-header ANTHROPIC_API_KEY:gateway.example.com` (bindings accumulate, so both hosts inject) — or, if the gateway is trusted, pass the key through with `--env ANTHROPIC_API_KEY`. @@ -276,7 +290,7 @@ is stored, never the token: ```yaml # .curb.yaml — the value is read from the host's $GH_TOKEN at run time, not committed. inject-header: - - GH_TOKEN=api.github.com + - GH_TOKEN:api.github.com ``` ## HOME and tilde expansion diff --git a/policy/validate.go b/policy/validate.go index da69491..f60ab74 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -2,7 +2,9 @@ package policy import ( "fmt" + "net" "net/netip" + "strconv" "strings" "unicode" ) @@ -59,38 +61,112 @@ func validateDomain(d string) error { return nil } -// ValidateInjectHost validates a credential-injection host and returns it -// normalized (lowercase, no trailing dot). Unlike a --domains pattern, an -// injection host must be an exact hostname: a wildcard cannot identify the -// single destination a credential belongs to, and "*" would broaden the -// allowlist to every domain while never matching a binding at runtime. -func ValidateInjectHost(host string) (string, error) { +// InjectTarget is one destination a credential is bound to: a hostname or IP +// literal plus the TLS port (default 443). The proxy injects the credential +// only for a connection matching both Host and Port, so the credential's +// destination is exact. +type InjectTarget struct { + Host string // normalized hostname, or canonical IP literal + Port string // numeric port, "443" by default + IsIP bool // Host is an IP literal (authorized via --ips, not --domains) +} + +// ParseInjectHeader parses one credential-injection binding +// "ENV_VAR:TARGET[,TARGET...]", where each TARGET is HOST[:PORT] and HOST is a +// hostname or IP literal (PORT defaults to 443). The binding is var-first +// because a credential belongs to its variable and may be valid for more than +// one destination. Splitting on the first ":" is unambiguous because an env var +// name can never contain one. Callers wrap the error with their own prefix. +func ParseInjectHeader(entry string) (envVar string, targets []InjectTarget, err error) { + envVar, rest, ok := strings.Cut(entry, ":") + if !ok || envVar == "" || rest == "" { + return "", nil, fmt.Errorf("must be ENV_VAR:HOST[,HOST...], got %q", entry) + } + if !ValidEnvName(envVar) { + return "", nil, fmt.Errorf("%q is not a valid environment variable name", envVar) + } + for item := range strings.SplitSeq(rest, ",") { + t, err := parseInjectTarget(item) + if err != nil { + return "", nil, err + } + targets = append(targets, t) + } + return envVar, targets, nil +} + +// parseInjectTarget parses one HOST[:PORT] target. HOST is a hostname or IP +// literal; PORT defaults to 443. An IPv6 literal must be bracketed when a port +// is present (net.SplitHostPort semantics); a bare IPv6 literal needs no +// brackets. Unlike a --domains pattern, an injection host must be exact: a +// wildcard cannot identify the single destination a credential belongs to. +func parseInjectTarget(item string) (InjectTarget, error) { + if item == "" { + return InjectTarget{}, fmt.Errorf("empty injection target") + } + // 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" + if h, p, ok := splitInjectPort(item); ok { + if err := validatePort(p); err != nil { + return InjectTarget{}, fmt.Errorf("injection target %q: %w", item, err) + } + host, port = h, p + } + if addr, err := netip.ParseAddr(host); err == nil { + return InjectTarget{Host: addr.String(), Port: port, IsIP: true}, nil + } if err := validateDomain(host); err != nil { - return "", err + return InjectTarget{}, err } if strings.Contains(host, "*") { - return "", fmt.Errorf("host %q must be an exact hostname (no wildcards)", host) + return InjectTarget{}, fmt.Errorf("host %q must be an exact hostname (no wildcards)", host) } - return NormalizeHost(host), nil + return InjectTarget{Host: NormalizeHost(host), Port: port, IsIP: false}, nil } -// ParseInjectHeader parses one credential-injection binding "ENV_VAR=HOST", -// returning the env var name and the normalized host. The binding is var-first -// because a credential belongs to its variable and may be valid for more than -// one host. Callers wrap the error with their own flag/field prefix. -func ParseInjectHeader(entry string) (envVar, host string, err error) { - envVar, host, ok := strings.Cut(entry, "=") - if !ok || envVar == "" || host == "" { - return "", "", fmt.Errorf("must be ENV_VAR=HOST, got %q", entry) +// splitInjectPort separates a custom port from a target, returning ok=false +// when none is present so the default applies. A bracketed IPv6 literal uses +// net.SplitHostPort; otherwise a port is the run of digits after a final colon, +// so "https://x" or "host:bad" fall through to host validation (which gives a +// clearer error than a bogus port would). +func splitInjectPort(item string) (host, port string, ok bool) { + if strings.HasPrefix(item, "[") { + h, p, err := net.SplitHostPort(item) + if err != nil { + return "", "", false + } + return h, p, true } - if !ValidEnvName(envVar) { - return "", "", fmt.Errorf("%q is not a valid environment variable name", envVar) + i := strings.LastIndexByte(item, ':') + if i < 0 || !isAllDigits(item[i+1:]) { + return "", "", false } - host, err = ValidateInjectHost(host) - if err != nil { - return "", "", err + return item[:i], item[i+1:], true +} + +func isAllDigits(s string) bool { + if s == "" { + return false } - return envVar, host, nil + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// validatePort checks that p is a numeric TCP port in 1..65535. +func validatePort(p string) error { + n, err := strconv.Atoi(p) + if err != nil || n < 1 || n > 65535 { + return fmt.Errorf("invalid port %q (want 1-65535)", p) + } + return nil } // ValidEnvName reports whether name is a valid environment variable name (a C diff --git a/policy/validate_test.go b/policy/validate_test.go index 46753ea..32d38cc 100644 --- a/policy/validate_test.go +++ b/policy/validate_test.go @@ -51,29 +51,48 @@ func TestValidateDomains(t *testing.T) { } } -func TestValidateInjectHost(t *testing.T) { +func TestParseInjectHeader(t *testing.T) { tests := []struct { - name string - host string - want string - wantErr string + name string + entry string + wantEnv string + wantTargets []InjectTarget + wantErr string }{ - {"exact host", "api.github.com", "api.github.com", ""}, - {"lowercased", "API.GitHub.COM", "api.github.com", ""}, - {"trailing dot trimmed", "api.github.com.", "api.github.com", ""}, - {"both", "API.GitHub.COM.", "api.github.com", ""}, + {"exact host", "TOK:api.github.com", "TOK", + []InjectTarget{{Host: "api.github.com", Port: "443"}}, ""}, + {"normalized host", "TOK:API.GitHub.COM.", "TOK", + []InjectTarget{{Host: "api.github.com", Port: "443"}}, ""}, + {"custom port", "TOK:api.example.com:8443", "TOK", + []InjectTarget{{Host: "api.example.com", Port: "8443"}}, ""}, + {"host list", "TOK:a.example.com,b.example.com:8443", "TOK", + []InjectTarget{{Host: "a.example.com", Port: "443"}, {Host: "b.example.com", Port: "8443"}}, ""}, + {"ipv4", "TOK:10.0.0.5", "TOK", + []InjectTarget{{Host: "10.0.0.5", Port: "443", IsIP: true}}, ""}, + {"ipv4 with port", "TOK:10.0.0.5:8443", "TOK", + []InjectTarget{{Host: "10.0.0.5", Port: "8443", IsIP: true}}, ""}, + {"bare ipv6", "TOK:2001:db8::1", "TOK", + []InjectTarget{{Host: "2001:db8::1", Port: "443", IsIP: true}}, ""}, + {"bracketed ipv6 with port", "TOK:[2001:db8::1]:8443", "TOK", + []InjectTarget{{Host: "2001:db8::1", Port: "8443", IsIP: true}}, ""}, - {"reject match-all", "*", "", "exact hostname"}, - {"reject wildcard", "*.github.com", "", "exact hostname"}, - {"reject URL", "https://api.github.com", "", "looks like a URL"}, - {"reject IP", "10.0.0.1", "", "use --ips instead"}, + {"reject no colon", "TOK", "", nil, "ENV_VAR:HOST"}, + {"reject empty host", "TOK:", "", nil, "ENV_VAR:HOST"}, + {"reject bad env name", "TO-K:api.github.com", "", nil, "valid environment variable"}, + {"reject match-all", "TOK:*", "", nil, "exact hostname"}, + {"reject wildcard", "TOK:*.github.com", "", nil, "exact hostname"}, + {"reject URL", "TOK:https://api.github.com", "", nil, "looks like a URL"}, + {"reject bad port", "TOK:api.example.com:0", "", nil, "invalid port"}, + {"reject huge port", "TOK:api.example.com:99999", "", nil, "invalid port"}, + {"reject empty target in list", "TOK:a.example.com,", "", nil, "empty injection target"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := ValidateInjectHost(tt.host) + env, targets, err := ParseInjectHeader(tt.entry) if tt.wantErr == "" { require.NoError(t, err) - assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantEnv, env) + assert.Equal(t, tt.wantTargets, targets) } else { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) diff --git a/proxy/handler.go b/proxy/handler.go index 83a9006..630098c 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -47,7 +47,7 @@ func (h *Handler) handleCONNECT(w http.ResponseWriter, r *http.Request) { // A bound host is TLS-terminated so the credential can be injected; // everything else keeps the passthrough relay below. - if injs, ok := h.Injector.binding(host); ok { + if injs, ok := h.Injector.binding(host, port); ok { h.injectCONNECT(w, host, port, injs) return } @@ -102,7 +102,7 @@ func (h *Handler) handleHTTP(w http.ResponseWriter, r *http.Request) { // A credential is bound to this host but the request is plain HTTP. Refuse // rather than forward: injecting over cleartext would expose the real // credential, and forwarding the placeholder unchanged fails auth silently. - if _, ok := h.Injector.binding(host); ok { + if h.Injector.hasHost(host) { http.Error(w, "curb: credential injection requires HTTPS for "+host, http.StatusBadGateway) h.logEvent("proxy_http", r.Host, "blocked", "inject-requires-https") return diff --git a/proxy/inject.go b/proxy/inject.go index 7ee7bfb..33b9568 100644 --- a/proxy/inject.go +++ b/proxy/inject.go @@ -13,6 +13,7 @@ import ( "math/big" "net" "net/http" + "net/netip" "strings" "sync" "time" @@ -133,10 +134,11 @@ type Injection struct { Value string } -// Injector terminates TLS for bound hosts and injects their credentials. A host -// without a binding is not terminated — the Handler relays it as passthrough, -// so a credential is never stapled to a destination it was not provisioned for -// (the central correctness property). +// Injector terminates TLS for bound destinations and injects their credentials. +// A destination without a binding is not terminated — the Handler relays it as +// passthrough, so a credential is never stapled to a destination it was not +// provisioned for (the central correctness property). Bindings are keyed by +// host:port: the credential is injected only when both match. type Injector struct { CA *CA // Upstream round-trips the decrypted, credential-injected request to the @@ -144,7 +146,8 @@ type Injector struct { // tests override it to reach local servers. Upstream http.RoundTripper - byHost map[string][]Injection + byTarget map[string][]Injection // key: net.JoinHostPort(host, port) + boundHosts map[string]struct{} // host only, for the plain-HTTP refusal } // NewInjector creates an injector backed by the per-run CA. Its upstream @@ -158,25 +161,50 @@ func NewInjector(ca *CA) *Injector { TLSHandshakeTimeout: dialTimeout, TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, }, - byHost: map[string][]Injection{}, + byTarget: map[string][]Injection{}, + boundHosts: map[string]struct{}{}, } } -// Bind adds a header injection for a destination host. Multiple headers may be -// bound to the same host. -func (in *Injector) Bind(host string, inj Injection) { - host = policy.NormalizeHost(host) - in.byHost[host] = append(in.byHost[host], inj) +// Bind adds a header injection for a destination host:port. Multiple headers +// may be bound to the same target. +func (in *Injector) Bind(host, port string, inj Injection) { + host = normalizeHost(host) + key := net.JoinHostPort(host, port) + in.byTarget[key] = append(in.byTarget[key], inj) + in.boundHosts[host] = struct{}{} } -func (in *Injector) binding(host string) ([]Injection, bool) { +// binding returns the injections bound to host:port, if any. +func (in *Injector) binding(host, port string) ([]Injection, bool) { if in == nil { return nil, false // nil-safe so callers need no separate guard } - injs, ok := in.byHost[policy.NormalizeHost(host)] + injs, ok := in.byTarget[net.JoinHostPort(normalizeHost(host), port)] return injs, ok } +// hasHost reports whether any binding exists for host on any port. The +// plain-HTTP path uses it to refuse cleartext to a host that holds a +// credential, regardless of the bound port. +func (in *Injector) hasHost(host string) bool { + if in == nil { + return false + } + _, ok := in.boundHosts[normalizeHost(host)] + return ok +} + +// normalizeHost canonicalizes a host for binding keys: an IP literal to its +// canonical form, otherwise a lowercased hostname. The binding side and the +// proxy lookup must agree, including when a client addresses an IP directly. +func normalizeHost(host string) string { + if addr, err := netip.ParseAddr(host); err == nil { + return addr.String() + } + return policy.NormalizeHost(host) +} + // injectCONNECT terminates the client's TLS with a per-run leaf for host, // then forwards each decrypted request to the real upstream with the bound // credential injected. diff --git a/proxy/inject_test.go b/proxy/inject_test.go index 450b0fc..59a51c5 100644 --- a/proxy/inject_test.go +++ b/proxy/inject_test.go @@ -63,7 +63,7 @@ func newTestInjector(ca *CA, bindings map[string][]Injection, upstreams map[stri injector := NewInjector(ca) for host, injs := range bindings { for _, inj := range injs { - injector.Bind(host, inj) + injector.Bind(host, "443", inj) } } // Reach the local recorders instead of the real hosts, trusting each @@ -151,19 +151,25 @@ func getWithHeader(t *testing.T, client *http.Client, rawURL, name, value string } // TestInjector_BindingNormalizesHost confirms bindings match CONNECT targets -// regardless of case or a trailing dot. +// regardless of case or a trailing dot, and only on the bound port. func TestInjector_BindingNormalizesHost(t *testing.T) { - in := &Injector{byHost: map[string][]Injection{}} - in.Bind("API.GitHub.COM.", Injection{Placeholder: "PH", Value: "real"}) + in := NewInjector(nil) + in.Bind("API.GitHub.COM.", "443", Injection{Placeholder: "PH", Value: "real"}) for _, host := range []string{"api.github.com", "API.GITHUB.COM", "api.github.com.", "Api.GitHub.Com."} { - injs, ok := in.binding(host) + injs, ok := in.binding(host, "443") require.True(t, ok, "expected binding to match %q", host) assert.Equal(t, "real", injs[0].Value) + assert.True(t, in.hasHost(host), "hasHost should match %q", host) } - _, ok := in.binding("other.com") + // A credential bound to :443 must not match a different port. + _, ok := in.binding("api.github.com", "8443") + assert.False(t, ok, "binding must not match a non-bound port") + + _, ok = in.binding("other.com", "443") assert.False(t, ok) + assert.False(t, in.hasHost("other.com")) } // TestInjector_SetsHostHeader confirms the upstream request's Host header is diff --git a/proxy/socks5.go b/proxy/socks5.go index e9e1b83..62c5806 100644 --- a/proxy/socks5.go +++ b/proxy/socks5.go @@ -107,13 +107,12 @@ func (s *SOCKS5Server) HandleConn(conn net.Conn) { return } - // 4. A bound host is TLS-terminated so the credential can be injected, - // matching the HTTP CONNECT path; everything else is relayed below. The - // binding matches on hostname, so this only fires for socks5h clients that - // send a name (curb advertises socks5h://). Address resolved to an IP by a - // socks5 client falls through to the raw relay. + // 4. A bound destination is TLS-terminated so the credential can be + // injected, matching the HTTP CONNECT path; everything else is relayed + // below. The binding matches on host:port — a name from a socks5h client + // (curb advertises socks5h://) or an IP literal when the target is an IP. target := net.JoinHostPort(host, port) - if injs, ok := s.Injector.binding(host); ok { + if injs, ok := s.Injector.binding(host, port); ok { s.sendReply(conn, socks5RepSuccess) s.logEvent("socks5_inject", target, "allowed", "") if err := s.Injector.Serve(conn, host, port, injs); err != nil { diff --git a/sandbox/capabilities_linux_test.go b/sandbox/capabilities_linux_test.go index 1e39f99..57cc0ff 100644 --- a/sandbox/capabilities_linux_test.go +++ b/sandbox/capabilities_linux_test.go @@ -231,7 +231,7 @@ func TestPrintDryRun_Injection(t *testing.T) { t.Setenv("CURB_DRYRUN_TOKEN", "sk-secret-must-not-leak") cfg := &config.Config{ AllowedDomains: []string{"api.example.com"}, - InjectHeader: []string{"CURB_DRYRUN_TOKEN=api.example.com"}, + InjectHeader: []string{"CURB_DRYRUN_TOKEN:api.example.com"}, } plan, err := BuildPlan(cfg, caps, nil) diff --git a/sandbox/inject_integration_test.go b/sandbox/inject_integration_test.go index 6fdf967..2632c5c 100644 --- a/sandbox/inject_integration_test.go +++ b/sandbox/inject_integration_test.go @@ -157,7 +157,7 @@ func TestCurb_Inject_EndToEnd(t *testing.T) { cmd := exec.Command(curbBin, "--write", "*", "--exec", "*", "--host-loopback", "--domains", "localhost", - "--inject-header", "DEMO_TOKEN=localhost", + "--inject-header", "DEMO_TOKEN:localhost:"+port, "--", "sh", "-c", script) cmd.Env = append(envWithout("DEMO_TOKEN", "SSL_CERT_FILE"), "DEMO_TOKEN=integration-secret", @@ -195,7 +195,7 @@ func TestCurb_Inject_HeaderAgnostic(t *testing.T) { cmd := exec.Command(curbBin, "--write", "*", "--exec", "*", "--host-loopback", "--domains", "localhost", - "--inject-header", "DEMO_KEY=localhost", + "--inject-header", "DEMO_KEY:localhost:"+port, "--", "sh", "-c", script) cmd.Env = append(envWithout("DEMO_KEY", "SSL_CERT_FILE"), "DEMO_KEY=sk-ant-real-integration", diff --git a/sandbox/inject_test.go b/sandbox/inject_test.go index 1c77692..102540f 100644 --- a/sandbox/inject_test.go +++ b/sandbox/inject_test.go @@ -8,31 +8,39 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/upsun/curb/policy" ) func TestParseInjectHeader(t *testing.T) { - specs, err := parseInjectHeader([]string{"ANTHROPIC_API_KEY=api.anthropic.com"}) + specs, err := parseInjectHeader([]string{"ANTHROPIC_API_KEY:api.anthropic.com"}) require.NoError(t, err) require.Len(t, specs, 1) assert.Equal(t, "ANTHROPIC_API_KEY", specs[0].envVar) - assert.Equal(t, "api.anthropic.com", specs[0].host) + require.Len(t, specs[0].targets, 1) + assert.Equal(t, "api.anthropic.com", specs[0].targets[0].Host) + assert.Equal(t, "443", specs[0].targets[0].Port) for _, bad := range []string{ - "", "ANTHROPIC_API_KEY", "=h.com", "TOK=", // missing var or host - "1BAD=h.com", // invalid env var name (leading digit) - "bad-var=h.com", // invalid env var name (dash) - "T=*", "T=*.h.com", // wildcard hosts rejected - "TOK=not a host", // invalid host - "A=b.com=x", // '=' in host (would bind a never-matching host) + "", "ANTHROPIC_API_KEY", ":h.com", "TOK:", // missing var or host + "1BAD:h.com", // invalid env var name (leading digit) + "bad-var:h.com", // invalid env var name (dash) + "T:*", "T:*.h.com", // wildcard hosts rejected + "TOK:not a host", // invalid host + "TOK:h.com:0", // invalid port + "TOK:h.com,", // empty target in list } { _, err := parseInjectHeader([]string{bad}) assert.Error(t, err, "expected error for %q", bad) } - // Hosts are normalized to lowercase with no trailing dot. - specs, err = parseInjectHeader([]string{"T=API.Anthropic.COM."}) + // A list of targets with a mix of default and custom ports. + specs, err = parseInjectHeader([]string{"T:API.Anthropic.COM.,b.example.com:8443"}) require.NoError(t, err) - assert.Equal(t, "api.anthropic.com", specs[0].host) + require.Len(t, specs[0].targets, 2) + assert.Equal(t, "api.anthropic.com", specs[0].targets[0].Host) + assert.Equal(t, "b.example.com", specs[0].targets[1].Host) + assert.Equal(t, "8443", specs[0].targets[1].Port) } func TestInjectPlaceholder(t *testing.T) { @@ -54,7 +62,7 @@ func TestResolveInjectSkippedDoesNotRequireAllowedDomain(t *testing.T) { TempDir: t.TempDir(), EnvSet: map[string]string{}, } - specs := []injectSpec{{envVar: "SKIPPED_TOKEN", host: "api.example.com"}} + specs := []injectSpec{{envVar: "SKIPPED_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} require.NoError(t, resolveInject(plan, specs)) assert.Empty(t, plan.AllowedDomains) @@ -69,7 +77,7 @@ func TestResolveInjectRequiresAllowedDomain(t *testing.T) { EnvSet: map[string]string{}, ProxyEnabled: true, } - specs := []injectSpec{{envVar: "ACTIVE_TOKEN", host: "api.example.com"}} + specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} err := resolveInject(plan, specs) require.Error(t, err) @@ -89,14 +97,43 @@ func TestResolveInjectAllowsWildcardDomain(t *testing.T) { AllowedDomains: []string{"*.example.com"}, ProxyEnabled: true, } - specs := []injectSpec{{envVar: "ACTIVE_TOKEN", host: "api.example.com"}} + specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} require.NoError(t, resolveInject(plan, specs)) assert.NotNil(t, plan.CA) - assert.Contains(t, plan.InjectBindings, "api.example.com") + assert.Contains(t, plan.InjectBindings, policy.InjectTarget{Host: "api.example.com", Port: "443"}) assert.Equal(t, injectPlaceholder("ACTIVE_TOKEN"), plan.EnvSet["ACTIVE_TOKEN"]) } +func TestResolveInjectIPTarget(t *testing.T) { + if systemCABundle() == "" { + t.Skip("system CA bundle unavailable") + } + t.Setenv("ACTIVE_TOKEN", "secret") + + // An IP target must be authorized via --ips, not --domains. + notAllowed := &SandboxPlan{ + TempDir: t.TempDir(), + EnvSet: map[string]string{}, + ProxyEnabled: true, + } + specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "10.0.0.5", Port: "8443", IsIP: true}}}} + err := resolveInject(notAllowed, specs) + require.Error(t, err) + assert.Contains(t, err.Error(), `credential injection IP "10.0.0.5" is not allowed`) + + // Allowed by --ips (CIDR), bound under host:port. + plan := &SandboxPlan{ + TempDir: t.TempDir(), + EnvSet: map[string]string{}, + AllowedIPs: []string{"10.0.0.0/24"}, + ProxyEnabled: true, + } + require.NoError(t, resolveInject(plan, specs)) + assert.NotNil(t, plan.CA) + assert.Contains(t, plan.InjectBindings, policy.InjectTarget{Host: "10.0.0.5", Port: "8443", IsIP: true}) +} + func TestWriteCABundle(t *testing.T) { dir := t.TempDir() caPEM := []byte("-----BEGIN CERTIFICATE-----\nPERRUNCA\n-----END CERTIFICATE-----\n") diff --git a/sandbox/plan.go b/sandbox/plan.go index a8861b9..a99de38 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -5,6 +5,8 @@ import ( "io" "maps" "math/rand/v2" + "net" + "net/netip" "os" "os/exec" "path/filepath" @@ -109,10 +111,10 @@ type SandboxPlan struct { Logger *clog.Logger // Credential injection (parent-only; never serialized to the child). - // CA mints leaf certs for injected hosts; InjectBindings maps a host to - // the credential headers the proxy attaches to it. + // CA mints leaf certs for injected destinations; InjectBindings maps a + // destination to the credential headers the proxy attaches to it. CA *proxy.CA - InjectBindings map[string][]proxy.Injection + InjectBindings map[policy.InjectTarget][]proxy.Injection } // LandlockPaths returns the path sets for Landlock rule construction. @@ -580,26 +582,53 @@ func appendUniq(s []string, v string) []string { } // injectSpec is a parsed injection binding: the sandbox sees envVar set to a -// placeholder; the proxy replaces it with envVar's real host value in requests -// to host, wherever the client placed it among the request headers. +// placeholder; the proxy replaces it with envVar's real value in requests to +// any of targets, wherever the client placed it among the request headers. type injectSpec struct { - envVar string - host string + envVar string + targets []policy.InjectTarget } -// parseInjectHeader parses --inject-header "ENV_VAR=HOST" entries. +// parseInjectHeader parses --inject-header "ENV_VAR:HOST[,HOST...]" entries. func parseInjectHeader(entries []string) ([]injectSpec, error) { var specs []injectSpec for _, e := range entries { - envVar, host, err := policy.ParseInjectHeader(e) + envVar, targets, err := policy.ParseInjectHeader(e) if err != nil { return nil, fmt.Errorf("--inject-header %w", err) } - specs = append(specs, injectSpec{envVar: envVar, host: host}) + specs = append(specs, injectSpec{envVar: envVar, targets: targets}) } return specs, nil } +// displayInjectTarget formats an injection target for human output, dropping +// the default :443 so the common case reads as a bare host. +func displayInjectTarget(t policy.InjectTarget) string { + if t.Port == "443" { + return t.Host + } + return net.JoinHostPort(t.Host, t.Port) +} + +// authorizeInjectTarget checks that an injection target is reachable under the +// network policy: an IP target against --ips, a hostname target against +// --domains. A credential must never be provisioned for a destination the +// sandbox cannot otherwise reach. +func authorizeInjectTarget(t policy.InjectTarget, domains *policy.DomainMatcher, ips *policy.IPMatcher) error { + if t.IsIP { + addr, err := netip.ParseAddr(t.Host) + if err == nil && ips.Match(addr) { + return nil + } + return fmt.Errorf("credential injection IP %q is not allowed; add --ips %s", t.Host, t.Host) + } + if domains.Match(t.Host) { + return nil + } + return fmt.Errorf("credential injection host %q is not allowed; add --domains %s", t.Host, t.Host) +} + // injectPlaceholder returns the sentinel the sandbox sees in place of a real // credential. It is constant per env var so a tool that approves a custom key // (e.g. Claude Code) approves it once. The value carries no secret weight: the @@ -624,7 +653,9 @@ func resolveInject(plan *SandboxPlan, specs []injectSpec) error { if len(specs) == 0 { return nil } - bindings := make(map[string][]proxy.Injection, len(specs)) + domainMatcher := policy.NewDomainMatcher(plan.AllowedDomains) + ipMatcher := policy.NewIPMatcher(plan.AllowedIPs) + bindings := make(map[policy.InjectTarget][]proxy.Injection, len(specs)) placeholders := make(map[string]string) for _, s := range specs { token, present := os.LookupEnv(s.envVar) @@ -632,25 +663,19 @@ func resolveInject(plan *SandboxPlan, specs []injectSpec) error { continue // source var absent — nothing to inject for this binding } placeholder := injectPlaceholder(s.envVar) - bindings[s.host] = append(bindings[s.host], proxy.Injection{Placeholder: placeholder, Value: token}) + for _, t := range s.targets { + if err := authorizeInjectTarget(t, domainMatcher, ipMatcher); err != nil { + return err + } + bindings[t] = append(bindings[t], proxy.Injection{Placeholder: placeholder, Value: token}) + } placeholders[s.envVar] = placeholder } if len(bindings) == 0 { return nil // nothing to inject (all source vars were absent) } - matcher := policy.NewDomainMatcher(plan.AllowedDomains) - hosts := make([]string, 0, len(bindings)) - for host := range bindings { - hosts = append(hosts, host) - } - sort.Strings(hosts) - for _, host := range hosts { - if !matcher.Match(host) { - return fmt.Errorf("credential injection host %q is not allowed; add --domains %s", host, host) - } - } if !plan.ProxyEnabled { - return fmt.Errorf("credential injection requires the network proxy; allow the host with --domains and do not use --unrestricted-net") + return fmt.Errorf("credential injection requires the network proxy; allow the destination with --domains/--ips and do not use --unrestricted-net") } ca, err := proxy.NewCA() if err != nil { @@ -928,14 +953,14 @@ func (p *SandboxPlan) PrintDryRun(w io.Writer) { } ln(" blocked: everything else") if len(p.InjectBindings) > 0 { - hosts := make([]string, 0, len(p.InjectBindings)) - for h := range p.InjectBindings { - hosts = append(hosts, h) + dests := make([]string, 0, len(p.InjectBindings)) + for t := range p.InjectBindings { + dests = append(dests, displayInjectTarget(t)) } - sort.Strings(hosts) + sort.Strings(dests) ln(" inject: TLS terminated; the proxy replaces a placeholder with the real credential in request headers (the real token never enters the sandbox)") - for _, h := range hosts { - pr(" %s\n", h) + for _, d := range dests { + pr(" %s\n", d) } ln(" ca-trust: per-run CA bundle in env (SSL_CERT_FILE, CURL_CA_BUNDLE, GIT_SSL_CAINFO, REQUESTS_CA_BUNDLE, NODE_EXTRA_CA_CERTS)") } diff --git a/sandbox/plan_darwin_test.go b/sandbox/plan_darwin_test.go index adac2e6..fcdc35f 100644 --- a/sandbox/plan_darwin_test.go +++ b/sandbox/plan_darwin_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/upsun/curb/config" + "github.com/upsun/curb/policy" ) func TestIsCoveredBySubpath(t *testing.T) { @@ -87,7 +88,7 @@ func TestAddTerminfo_AlreadyCovered(t *testing.T) { func TestDarwinBuildPlan_InjectHeaderInactive(t *testing.T) { t.Setenv("DARWIN_INJECT_TOKEN", "") cfg := &config.Config{ - InjectHeader: []string{"DARWIN_INJECT_TOKEN=api.example.com"}, + InjectHeader: []string{"DARWIN_INJECT_TOKEN:api.example.com"}, } plan, err := BuildPlan(cfg, &Capabilities{}, nil) @@ -102,7 +103,7 @@ func TestDarwinBuildPlan_InjectHeaderInactive(t *testing.T) { func TestDarwinBuildPlan_InjectHeaderActiveRequiresAllowedDomain(t *testing.T) { t.Setenv("DARWIN_INJECT_TOKEN", "secret") cfg := &config.Config{ - InjectHeader: []string{"DARWIN_INJECT_TOKEN=api.example.com"}, + InjectHeader: []string{"DARWIN_INJECT_TOKEN:api.example.com"}, } plan, err := BuildPlan(cfg, &Capabilities{}, nil) @@ -118,7 +119,7 @@ func TestDarwinBuildPlan_InjectHeaderActive(t *testing.T) { t.Setenv("DARWIN_INJECT_TOKEN", "secret") cfg := &config.Config{ AllowedDomains: []string{"api.example.com"}, - InjectHeader: []string{"DARWIN_INJECT_TOKEN=api.example.com"}, + InjectHeader: []string{"DARWIN_INJECT_TOKEN:api.example.com"}, } plan, err := BuildPlan(cfg, &Capabilities{}, nil) @@ -128,6 +129,6 @@ func TestDarwinBuildPlan_InjectHeaderActive(t *testing.T) { assert.True(t, plan.UseSeatbelt) assert.True(t, plan.ProxyEnabled) assert.NotNil(t, plan.CA) - assert.Contains(t, plan.InjectBindings, "api.example.com") + assert.Contains(t, plan.InjectBindings, policy.InjectTarget{Host: "api.example.com", Port: "443"}) assert.Equal(t, injectPlaceholder("DARWIN_INJECT_TOKEN"), plan.EnvSet["DARWIN_INJECT_TOKEN"]) } diff --git a/sandbox/proxy_handler.go b/sandbox/proxy_handler.go index c014a48..e01bfae 100644 --- a/sandbox/proxy_handler.go +++ b/sandbox/proxy_handler.go @@ -27,9 +27,9 @@ func buildInjector(plan *SandboxPlan) *proxy.Injector { return nil } inj := proxy.NewInjector(plan.CA) - for host, injections := range plan.InjectBindings { + for target, injections := range plan.InjectBindings { for _, injection := range injections { - inj.Bind(host, injection) + inj.Bind(target.Host, target.Port, injection) } } return inj diff --git a/sandbox/skill.go b/sandbox/skill.go index 9afc4b8..6ad3bc4 100644 --- a/sandbox/skill.go +++ b/sandbox/skill.go @@ -69,12 +69,12 @@ description: Active sandbox constraints for this environment. Check when encount b.WriteString("- Localhost: sandbox-internal\n") } if len(plan.InjectBindings) > 0 { - hosts := make([]string, 0, len(plan.InjectBindings)) - for h := range plan.InjectBindings { - hosts = append(hosts, h) + dests := make([]string, 0, len(plan.InjectBindings)) + for t := range plan.InjectBindings { + dests = append(dests, displayInjectTarget(t)) } - sort.Strings(hosts) - fmt.Fprintf(&b, "- Credential injection: credential headers are added automatically to requests for %s; no token is present in this sandbox, so do not expect or look for one.\n", strings.Join(hosts, ", ")) + sort.Strings(dests) + fmt.Fprintf(&b, "- Credential injection: credential headers are added automatically to requests for %s; no token is present in this sandbox, so do not expect or look for one.\n", strings.Join(dests, ", ")) } if !plan.ProxyEnabled && len(plan.AllowedDomains) == 0 && len(plan.AllowedIPs) == 0 { b.WriteString("- Allowed: none\n") From 6b12ca5354d225e5dc2442041c12b1057e5f71cf Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 16 Jun 2026 13:38:20 +0100 Subject: [PATCH 14/27] fix: address credential-injection review round - 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 --- policy/validate.go | 30 +++++++++++++++++++----------- proxy/inject.go | 4 ++++ sandbox/skill.go | 2 +- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/policy/validate.go b/policy/validate.go index f60ab74..640f17f 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -21,40 +21,48 @@ func ValidateDomains(domains []string) error { } func validateDomain(d string) error { + return validateDomainPattern(d, "--domains") +} + +// validateDomainPattern checks that d is a plausible domain pattern, attributing +// any error to subject (a flag or context name) so the message matches how the +// value was supplied — `--domains` for the flag, `injection host` for a +// credential-injection target. +func validateDomainPattern(d, subject string) error { if d == "*" || d == "localhost" { return nil } lower := strings.ToLower(d) if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { - return fmt.Errorf("--domains %q looks like a URL; use the bare domain instead (e.g. %q)", d, stripScheme(d)) + return fmt.Errorf("%s %q looks like a URL; use the bare domain instead (e.g. %q)", subject, d, stripScheme(d)) } if _, err := netip.ParseAddr(d); err == nil { - return fmt.Errorf("--domains %q is an IP address; use --ips instead", d) + return fmt.Errorf("%s %q is an IP address; use --ips instead", subject, d) } if _, err := netip.ParsePrefix(d); err == nil { - return fmt.Errorf("--domains %q is a CIDR range; use --ips instead", d) + return fmt.Errorf("%s %q is a CIDR range; use --ips instead", subject, d) } for _, r := range d { if unicode.IsControl(r) || unicode.IsSpace(r) { - return fmt.Errorf("--domains %q contains invalid character (whitespace or control)", d) + return fmt.Errorf("%s %q contains invalid character (whitespace or control)", subject, d) } switch r { case '/', '\\', ':', '@', '#', '?', '=': - return fmt.Errorf("--domains %q contains invalid character %q", d, string(r)) + return fmt.Errorf("%s %q contains invalid character %q", subject, d, string(r)) } } // Wildcard validation: only "*" (handled above) or "*.suffix". if strings.Contains(d, "*") { if !strings.HasPrefix(d, "*.") { - return fmt.Errorf("--domains %q: wildcards must be * (match-all) or *.suffix", d) + return fmt.Errorf("%s %q: wildcards must be * (match-all) or *.suffix", subject, d) } suffix := d[2:] if suffix == "" { - return fmt.Errorf("--domains %q: wildcard suffix must not be empty", d) + return fmt.Errorf("%s %q: wildcard suffix must not be empty", subject, d) } } @@ -119,11 +127,11 @@ func parseInjectTarget(item string) (InjectTarget, error) { if addr, err := netip.ParseAddr(host); err == nil { return InjectTarget{Host: addr.String(), Port: port, IsIP: true}, nil } - if err := validateDomain(host); err != nil { - return InjectTarget{}, err - } if strings.Contains(host, "*") { - return InjectTarget{}, fmt.Errorf("host %q must be an exact hostname (no wildcards)", host) + return InjectTarget{}, fmt.Errorf("injection host %q must be an exact hostname (no wildcards)", host) + } + if err := validateDomainPattern(host, "injection host"); err != nil { + return InjectTarget{}, err } return InjectTarget{Host: NormalizeHost(host), Port: port, IsIP: false}, nil } diff --git a/proxy/inject.go b/proxy/inject.go index 33b9568..222c17e 100644 --- a/proxy/inject.go +++ b/proxy/inject.go @@ -237,6 +237,10 @@ func (h *Handler) injectCONNECT(w http.ResponseWriter, host, port string, injs [ // SOCKS5 egress routes; the caller has already accepted the connection (written // the CONNECT 200 or the SOCKS5 success reply). func (in *Injector) Serve(client net.Conn, host, port string, injs []Injection) error { + // The binding matched on a normalized host (case-folded, no trailing dot, + // canonical IP); mint the leaf and route upstream on the same form so the + // cert, SNI, and Host header all agree. + host = normalizeHost(host) leaf, err := in.CA.leafFor(host) if err != nil { return fmt.Errorf("leaf: %w", err) diff --git a/sandbox/skill.go b/sandbox/skill.go index 6ad3bc4..196adbb 100644 --- a/sandbox/skill.go +++ b/sandbox/skill.go @@ -74,7 +74,7 @@ description: Active sandbox constraints for this environment. Check when encount dests = append(dests, displayInjectTarget(t)) } sort.Strings(dests) - fmt.Fprintf(&b, "- Credential injection: credential headers are added automatically to requests for %s; no token is present in this sandbox, so do not expect or look for one.\n", strings.Join(dests, ", ")) + fmt.Fprintf(&b, "- Credential injection: requests to %s have their credential substituted in transit. The relevant environment variable holds a placeholder, so use it normally; the real value is never present in this sandbox, so do not expect or look for one.\n", strings.Join(dests, ", ")) } if !plan.ProxyEnabled && len(plan.AllowedDomains) == 0 && len(plan.AllowedIPs) == 0 { b.WriteString("- Allowed: none\n") From c82079a126b1325a7dbbd5a9a8ff310394ec968c Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 30 Jun 2026 11:16:52 +0100 Subject: [PATCH 15/27] fix: address credential injection regressions 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. --- config/defaults.go | 1 - proxy/inject.go | 15 +++++++++++---- proxy/inject_test.go | 6 ++++++ sandbox/capabilities.go | 2 -- sandbox/inject_test.go | 39 ++++++++++++++++++++++++++++++++++++--- sandbox/plan.go | 34 ++++++++++++++++++++++++++++++---- sandbox/plan_test.go | 10 ++++++++++ 7 files changed, 93 insertions(+), 14 deletions(-) diff --git a/config/defaults.go b/config/defaults.go index 938d155..5e0266b 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -158,4 +158,3 @@ func ExpandHomeRefs(paths []string, home string) []string { func PathReferencesHome(p string) bool { return p == "~" || strings.HasPrefix(p, "~/") || strings.Contains(p, homeRefMarker) } - diff --git a/proxy/inject.go b/proxy/inject.go index 222c17e..41c946c 100644 --- a/proxy/inject.go +++ b/proxy/inject.go @@ -264,10 +264,7 @@ func (in *Injector) serveInjected(client net.Conn, host, port string, injs []Inj rt := in.Upstream // authority is the upstream the request is bound to. Drop the default // https port so the Host header matches what a direct client would send. - authority := host - if port != "443" { - authority = net.JoinHostPort(host, port) - } + authority := injectedAuthority(host, port) br := bufio.NewReader(client) for { req, err := http.ReadRequest(br) @@ -302,6 +299,16 @@ func (in *Injector) serveInjected(client net.Conn, host, port string, injs []Inj } } +func injectedAuthority(host, port string) string { + if port != "443" { + return net.JoinHostPort(host, port) + } + if ip, err := netip.ParseAddr(host); err == nil && ip.Is6() { + return "[" + host + "]" + } + return host +} + // replaceInHeaders substitutes each binding's placeholder with its real value in // every request header value. Placement is the client's choice — Authorization: // Bearer , x-api-key: , or any other header all work, so the proxy needs diff --git a/proxy/inject_test.go b/proxy/inject_test.go index 59a51c5..fca9c7f 100644 --- a/proxy/inject_test.go +++ b/proxy/inject_test.go @@ -190,6 +190,12 @@ func TestInjector_SetsHostHeader(t *testing.T) { assert.Equal(t, "api.github.com", gh.hosts[0]) } +func TestInjectedAuthorityBracketsDefaultPortIPv6(t *testing.T) { + assert.Equal(t, "[2001:db8::1]", injectedAuthority("2001:db8::1", "443")) + assert.Equal(t, "[2001:db8::1]:8443", injectedAuthority("2001:db8::1", "8443")) + assert.Equal(t, "api.github.com", injectedAuthority("api.github.com", "443")) +} + // TestInjector_ReplacesPlaceholder verifies the placeholder the sandbox sends is // replaced with the real credential, wherever the client placed it. func TestInjector_ReplacesPlaceholder(t *testing.T) { diff --git a/sandbox/capabilities.go b/sandbox/capabilities.go index c26aa5b..105559a 100644 --- a/sandbox/capabilities.go +++ b/sandbox/capabilities.go @@ -1,6 +1,5 @@ package sandbox - // Capabilities holds the results of probing system capabilities. type Capabilities struct { UserNS error // nil = ok, non-nil = fatal. @@ -27,4 +26,3 @@ func userNSErrMessage() string { func netNSErrMessage() string { return `Network namespaces are required for --domains but are not available.` } - diff --git a/sandbox/inject_test.go b/sandbox/inject_test.go index 102540f..787fff1 100644 --- a/sandbox/inject_test.go +++ b/sandbox/inject_test.go @@ -26,9 +26,9 @@ func TestParseInjectHeader(t *testing.T) { "1BAD:h.com", // invalid env var name (leading digit) "bad-var:h.com", // invalid env var name (dash) "T:*", "T:*.h.com", // wildcard hosts rejected - "TOK:not a host", // invalid host - "TOK:h.com:0", // invalid port - "TOK:h.com,", // empty target in list + "TOK:not a host", // invalid host + "TOK:h.com:0", // invalid port + "TOK:h.com,", // empty target in list } { _, err := parseInjectHeader([]string{bad}) assert.Error(t, err, "expected error for %q", bad) @@ -105,6 +105,39 @@ func TestResolveInjectAllowsWildcardDomain(t *testing.T) { assert.Equal(t, injectPlaceholder("ACTIVE_TOKEN"), plan.EnvSet["ACTIVE_TOKEN"]) } +func TestResolveInjectExtendsPassthroughSSL_CERT_FILE(t *testing.T) { + t.Setenv("ACTIVE_TOKEN", "secret") + dir := t.TempDir() + base := filepath.Join(dir, "custom-roots.pem") + require.NoError(t, os.WriteFile(base, []byte("-----BEGIN CERTIFICATE-----\nCUSTOMROOT\n-----END CERTIFICATE-----\n"), 0o644)) + + for _, tc := range []struct { + name string + passthrough []string + }{ + {name: "named", passthrough: []string{"SSL_CERT_FILE"}}, + {name: "all", passthrough: []string{envPassthroughAll}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("SSL_CERT_FILE", base) + plan := &SandboxPlan{ + TempDir: t.TempDir(), + EnvSet: map[string]string{}, + EnvPassthrough: tc.passthrough, + AllowedDomains: []string{"api.example.com"}, + ProxyEnabled: true, + } + specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} + + require.NoError(t, resolveInject(plan, specs)) + bundle := plan.EnvSet["SSL_CERT_FILE"] + data, err := os.ReadFile(bundle) + require.NoError(t, err) + assert.Contains(t, string(data), "CUSTOMROOT") + }) + } +} + func TestResolveInjectIPTarget(t *testing.T) { if systemCABundle() == "" { t.Skip("system CA bundle unavailable") diff --git a/sandbox/plan.go b/sandbox/plan.go index a99de38..d414280 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -689,12 +689,17 @@ func resolveInject(plan *SandboxPlan, specs []injectSpec) error { // in even under --env '*'. maps.Copy(plan.EnvSet, placeholders) + caBase := plan.EnvSet["SSL_CERT_FILE"] + if _, set := plan.EnvSet["SSL_CERT_FILE"]; !set { + caBase, _ = plan.passthroughEnvValue("SSL_CERT_FILE") + } + // Trust-store delivery: a combined bundle (system roots + per-run CA) the // action trusts for the proxy's leaf certs. The CA validates only inside // this run, so it is not sensitive to the action. A user-provided // SSL_CERT_FILE is used as the base (extended, not discarded) so a custom // trust store still applies to non-injected hosts. - bundle, err := writeCABundle(plan.TempDir, plan.EnvSet["SSL_CERT_FILE"], ca.CertPEM()) + bundle, err := writeCABundle(plan.TempDir, caBase, ca.CertPEM()) if err != nil { return err } @@ -734,6 +739,21 @@ func writeCABundle(tmpDir, base string, caPEM []byte) (string, error) { return path, nil } +func (p *SandboxPlan) passthroughEnvValue(name string) (string, bool) { + if isInternalEnvVar(name) { + return "", false + } + if len(p.EnvPassthrough) > 0 && p.EnvPassthrough[0] == envPassthroughAll { + return os.LookupEnv(name) + } + for _, pat := range p.EnvPassthrough { + if matched, _ := filepath.Match(pat, name); matched { + return os.LookupEnv(name) + } + } + return "", false +} + // systemCABundle returns the first system CA bundle found, or "". func systemCABundle() string { for _, p := range []string{ @@ -1094,10 +1114,16 @@ func applyEnvPolicy(plan *SandboxPlan, cfg *config.Config, tmpDir string) { if plan.SOCKSPort > 0 { socksAddr := fmt.Sprintf("127.0.0.1:%d", plan.SOCKSPort) socksURL := fmt.Sprintf("socks5h://%s", socksAddr) - if _, ok := plan.EnvSet["ALL_PROXY"]; !ok { + allProxy, allProxySet := plan.EnvSet["ALL_PROXY"] + lowerAllProxy, lowerAllProxySet := plan.EnvSet["all_proxy"] + switch { + case allProxySet && lowerAllProxySet: + case allProxySet: + plan.EnvSet["all_proxy"] = allProxy + case lowerAllProxySet: + plan.EnvSet["ALL_PROXY"] = lowerAllProxy + default: plan.EnvSet["ALL_PROXY"] = socksURL - } - if _, ok := plan.EnvSet["all_proxy"]; !ok { plan.EnvSet["all_proxy"] = socksURL } plan.EnvSet[SOCKSAddrEnvKey] = socksAddr diff --git a/sandbox/plan_test.go b/sandbox/plan_test.go index c41d1bd..86578bc 100644 --- a/sandbox/plan_test.go +++ b/sandbox/plan_test.go @@ -215,6 +215,16 @@ func TestBuildPlan_AllProxyDefaultAndOverride(t *testing.T) { assert.Empty(t, allProxy, "user-provided empty ALL_PROXY must be preserved") assert.Empty(t, plan.EnvSet["all_proxy"]) assert.NotEmpty(t, plan.EnvSet[SOCKSAddrEnvKey], "curb's SOCKS address var is unaffected") + + cfg = &config.Config{ + AllowedDomains: []string{"example.com"}, + EnvSet: []string{"ALL_PROXY="}, + } + plan, err = BuildPlan(cfg, minCaps(), nil) + require.NoError(t, err) + defer plan.Cleanup() + assert.Empty(t, plan.EnvSet["ALL_PROXY"], "user-provided ALL_PROXY must be preserved") + assert.Empty(t, plan.EnvSet["all_proxy"], "ALL_PROXY suppression must apply to both casings") } func TestBuildPlan_NoExecRestrict(t *testing.T) { From 8648632245f9d9134550ae1a659e04d6021c3e10 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 30 Jun 2026 11:47:57 +0100 Subject: [PATCH 16/27] fix: address credential injection regressions 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. --- CLAUDE.md | 2 +- README.md | 2 +- cmd/root.go | 2 +- config/config.go | 12 ++++- config/config_test.go | 14 ++++-- docs/configuration.md | 21 +++++---- sandbox/inject_test.go | 67 ++++++++++++++++++++++++++++ sandbox/plan.go | 99 ++++++++++++++++++++++++++++++++---------- sandbox/plan_test.go | 26 +++++++++++ 9 files changed, 206 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a2739aa..92fd4ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ go test ./sandbox/ -run TestCurb_FS_ -v # run a subset - The SOCKS proxy is advertised via `ALL_PROXY`/`all_proxy` (HTTP/HTTPS still prefer `HTTP_PROXY`/`HTTPS_PROXY`). A user-provided value takes precedence, so `--env ALL_PROXY=` suppresses it — useful for HTTP-only workloads with clients that eagerly construct a SOCKS transport (e.g. httpx, which then needs the `httpx[socks]` extra). ssh routing does not use it (it has its own config). - `--ips` allows connections to specific IP addresses or CIDR ranges. Works alongside or independently of `--domains`. IP-matched connections bypass domain filtering (forwarded directly on any port via SOCKS5 or CONNECT). In IPs-only mode (no `--domains`), DNS queries are not intercepted. - `--unrestricted-net` skips network namespace creation entirely, allowing unrestricted network access while keeping FS sandboxing. Cannot be combined with `--domains` or `--ips`. -- `--inject-header ENV_VAR:HOST[:PORT][,HOST...]` keeps a credential out of the sandbox. curb sets the child's `ENV_VAR` to a stable per-var placeholder; the child never sees the real value. Each destination is `HOST[:PORT]` (a hostname or IP, port defaulting to 443) and bindings are keyed by `host:port`: the proxy terminates TLS and substitutes the real value (read from the host's `ENV_VAR`) wherever the client put the placeholder among the request headers — header-agnostic (no knowledge of the auth scheme) — but only on that exact host:port, so a connection to the same host on a different port is relayed unchanged. The binding is var-first because a credential belongs to its variable and may be valid for several destinations; the right side is a comma-separated list. Hostname destinations must be allowed by `--domains`, IP destinations by `--ips`. Opt-in per credential: if `ENV_VAR` is unset or empty on the host the binding is skipped (so the `claude` profile is a no-op for OAuth users). The proxy uses a per-run CA delivered to the child as a combined bundle (system roots + per-run CA) via the standard CA env vars; the CA key and real token stay parent-only. Settable via the flag, `CURB_INJECT_HEADER`, or the `inject-header:` config/profile key (merged additively). In YAML, a list item needs no space after the colon (`- FOO:host`); `- FOO: host` parses as a map and is rejected. +- `--inject-header ENV_VAR:HOST[:PORT][,HOST...]` keeps a credential out of the sandbox. curb sets the child's `ENV_VAR` to a stable per-var placeholder; the child never sees the real value. Each destination is `HOST[:PORT]` (a hostname or IP, port defaulting to 443) and bindings are keyed by `host:port`: the proxy terminates TLS and substitutes the real value (read from the host's `ENV_VAR`) wherever the client put the placeholder among the request headers — header-agnostic (no knowledge of the auth scheme) — but only on that exact host:port, so a connection to the same host on a different port is relayed unchanged. The binding is var-first because a credential belongs to its variable and may be valid for several destinations; the right side is a comma-separated list. Hostname destinations must be allowed by `--domains`, IP destinations by `--ips`. Opt-in per credential: if `ENV_VAR` is unset or empty on the host the binding is skipped (so the `claude` profile is a no-op for OAuth users). Exact passthrough (`--env ENV_VAR`) is an explicit trust decision and disables injection for that variable; wildcard passthrough does not. The proxy uses a per-run CA delivered to the child by extending each standard CA env var's existing bundle, or system roots when unset; the CA key and real token stay parent-only. Settable via the repeatable flag, one full binding in `CURB_INJECT_HEADER`, or the `inject-header:` config/profile key (merged additively). In YAML, a list item needs no space after the colon (`- FOO:host`); `- FOO: host` parses as a map and is rejected. - `--domains` validates input: rejects URLs (suggests bare domain), IP addresses (suggests `--ips`), invalid characters, and malformed wildcards. `--ips` validates that values parse as IP addresses or CIDR prefixes. - `!` prefix in list flags removes from defaults AND actively denies via overmount when the path is under an allowed parent. `--read '!/path'` hides (tmpfs/dev-null), `--write '!/path'` makes read-only, `--exec '!/path'` makes noexec. `!*` clears all defaults (not deny-all). `\!` escapes literal `!`. Sub-path denials require mount NS; Landlock-only mode warns. - HOME defaults to a private tmpDir. `--env HOME` passes through the host HOME; `--env HOME=/path` sets it explicitly. `~` and `$HOME` in config/profile paths both resolve to the **host** home (the shell's view), not the sandbox HOME — they are host-side shorthands. When sandbox HOME differs from host HOME and a path uses `~`, curb warns that the sandboxed program cannot reach those paths via its own `$HOME`. Built-in profiles include `HOME` in their env passthrough so sandbox HOME = host HOME and `~` paths align. See `docs/configuration.md` for full details. diff --git a/README.md b/README.md index 968164c..362965b 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ GH_TOKEN=ghp_xxx curb --domains api.github.com --inject-header 'GH_TOKEN:api.git |------|---------|-------------| | `--domains` | `CURB_DOMAINS` | Allowed domain patterns (comma-separated). `*.example.com` for subdomains, `*` to allow all. | | `--ips` | `CURB_IPS` | Allowed IP addresses or CIDR ranges (e.g. `10.0.0.1`, `192.168.0.0/16`, `::1`). | -| `--inject-header` | `CURB_INJECT_HEADER` | Keep a credential out of the sandbox: `ENV_VAR:HOST[:PORT][,HOST...]` sets the sandbox's copy of `ENV_VAR` to a placeholder, and the proxy replaces it with the real value in requests to each destination (terminating TLS), wherever the client sent it (any header — `Authorization: Bearer`, `x-api-key`, etc.), so no auth-scheme knowledge is needed. A destination is a hostname or IP, with `PORT` defaulting to 443; the credential is injected only on that exact host:port. Each destination must also be allowed by `--domains` (hostnames) or `--ips` (IPs). Skipped if `ENV_VAR` is unset. Repeatable; also settable via the `inject-header:` config-file/profile key. | +| `--inject-header` | `CURB_INJECT_HEADER` | Keep a credential out of the sandbox: `ENV_VAR:HOST[:PORT][,HOST...]` sets the sandbox's copy of `ENV_VAR` to a placeholder, and the proxy replaces it with the real value in requests to each destination (terminating TLS), wherever the client sent it (any header — `Authorization: Bearer`, `x-api-key`, etc.), so no auth-scheme knowledge is needed. A destination is a hostname or IP, with `PORT` defaulting to 443; the credential is injected only on that exact host:port. Each destination must also be allowed by `--domains` (hostnames) or `--ips` (IPs). Skipped if `ENV_VAR` is unset, and disabled for that variable by exact passthrough such as `--env ENV_VAR`. Repeatable; `CURB_INJECT_HEADER` holds one full binding; also settable via the `inject-header:` config-file/profile key. | | `--host-loopback` | `CURB_HOST_LOOPBACK` | Forward localhost/127.0.0.1 traffic to the host loopback. Cannot combine with `--unrestricted-net`. | | `--unrestricted-net` | `CURB_UNRESTRICTED_NET` | Skip network filtering entirely. Cannot combine with `--domains`, `--ips`, or `--host-loopback`. | diff --git a/cmd/root.go b/cmd/root.go index 421bc4b..d038b8b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -330,7 +330,7 @@ func registerFlags(cmd *cobra.Command) { f.StringSlice("ips", nil, "allowed IP/CIDR ranges (e.g. 10.0.0.1, 192.168.0.0/16)") f.Bool("unrestricted-net", false, "skip network restrictions entirely") f.Bool("host-loopback", false, "forward localhost traffic to host loopback") - f.StringSlice("inject-header", nil, "inject ENV_VAR's value as a credential to allowed destinations, kept out of the sandbox (ENV_VAR:HOST[:PORT][,HOST...]; HOST is a hostname or IP, PORT defaults to 443; TLS terminated; skipped if ENV_VAR is unset)") + f.StringArray("inject-header", nil, "inject ENV_VAR's value as a credential to allowed destinations, kept out of the sandbox (ENV_VAR:HOST[:PORT][,HOST...]; HOST is a hostname or IP, PORT defaults to 443; TLS terminated; skipped if ENV_VAR is unset)") // Executables. f.StringSlice("exec", nil, "allowed executables (default: invoked command only)") diff --git a/config/config.go b/config/config.go index 00bf0af..7ed0ea9 100644 --- a/config/config.go +++ b/config/config.go @@ -69,7 +69,7 @@ func FromFlags(cmd *cobra.Command) (*Config, error) { if err != nil { return nil, err } - injectHeader, err := flags.GetStringSlice("inject-header") + injectHeader, err := flags.GetStringArray("inject-header") if err != nil { return nil, err } @@ -175,7 +175,7 @@ func MergeEnv(cfg *Config, cmd *cobra.Command) { // List values: always additive, with wildcard detection. cfg.AllowedDomains = appendEnvList(cfg.AllowedDomains, "CURB_DOMAINS") cfg.AllowedIPs = appendEnvList(cfg.AllowedIPs, "CURB_IPS") - cfg.InjectHeader = appendEnvList(cfg.InjectHeader, "CURB_INJECT_HEADER") + cfg.InjectHeader = appendEnvValue(cfg.InjectHeader, "CURB_INJECT_HEADER") roEnv := appendEnvList(nil, "CURB_READ") if containsStar(roEnv) { @@ -255,6 +255,14 @@ func appendEnvList(existing []string, envKey string) []string { return append(existing, SplitComma(val)...) } +func appendEnvValue(existing []string, envKey string) []string { + val, ok := os.LookupEnv(envKey) + if !ok || val == "" { + return existing + } + return append(existing, val) +} + // SplitComma splits a comma-separated string, trimming whitespace and dropping empties. func SplitComma(s string) []string { var result []string diff --git a/config/config_test.go b/config/config_test.go index 2d0d221..6b678ec 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -22,7 +22,7 @@ func newTestCmd(args []string) *cobra.Command { f.StringSlice("exec", nil, "") f.StringSlice("env", nil, "") f.StringSlice("ips", nil, "") - f.StringSlice("inject-header", nil, "") + f.StringArray("inject-header", nil, "") f.Bool("unrestricted-net", false, "") f.Bool("allow-unix-sockets", false, "") f.Bool("host-loopback", false, "") @@ -130,10 +130,18 @@ func TestMergeEnv_InjectHeaderAdditive(t *testing.T) { cfg, err := FromFlags(cmd) require.NoError(t, err) - t.Setenv("CURB_INJECT_HEADER", "A:a.com") + t.Setenv("CURB_INJECT_HEADER", "A:a.com,c.com") MergeEnv(cfg, cmd) - assert.Equal(t, []string{"B:b.com", "A:a.com"}, cfg.InjectHeader) + assert.Equal(t, []string{"B:b.com", "A:a.com,c.com"}, cfg.InjectHeader) +} + +func TestFromFlags_InjectHeaderPreservesTargetCommaList(t *testing.T) { + cmd := newTestCmd([]string{"--inject-header", "TOK:a.com,b.com"}) + cfg, err := FromFlags(cmd) + require.NoError(t, err) + + assert.Equal(t, []string{"TOK:a.com,b.com"}, cfg.InjectHeader) } func TestMergeEnv_CommaSeparatedList(t *testing.T) { diff --git a/docs/configuration.md b/docs/configuration.md index 656ef47..c9111af 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -259,14 +259,18 @@ The profile binding targets `api.anthropic.com`, so a custom `ANTHROPIC_BASE_URL (an internal gateway) is not covered: the gateway would receive the placeholder and auth would fail. For a gateway, either bind the key to its host as well — `--inject-header ANTHROPIC_API_KEY:gateway.example.com` (bindings accumulate, so -both hosts inject) — or, if the gateway is trusted, pass the key through with -`--env ANTHROPIC_API_KEY`. +both hosts inject) — or, if the gateway is trusted, pass the key through with an +exact `--env ANTHROPIC_API_KEY`. Exact passthrough is treated as an explicit +trust decision and disables injection for that variable; wildcard passthrough +such as `--env 'ANTHROPIC_*'` or `--env '*'` does not. The flag is repeatable (bindings accumulate). For active bindings, curb -generates a combined CA bundle (system roots plus the per-run CA) and points the -standard CA environment variables (`SSL_CERT_FILE`, `CURL_CA_BUNDLE`, -`GIT_SSL_CAINFO`, `REQUESTS_CA_BUNDLE`, `NODE_EXTRA_CA_CERTS`) at it, so common -tools trust the terminated connection while still reaching other hosts normally. +extends each existing CA bundle from the standard CA environment variables +(`SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, `REQUESTS_CA_BUNDLE`, +`NODE_EXTRA_CA_CERTS`) with the per-run CA and points that variable at the +extended bundle. If a variable is unset, curb uses the system roots as its base. +This lets common tools trust the terminated connection while preserving custom +trust for other hosts. After terminating TLS, curb parses the decrypted stream as HTTP/1.1 and does not negotiate HTTP/2 (no `h2` ALPN). An HTTP/2-only client or protocol — some gRPC @@ -281,8 +285,9 @@ and the SOCKS5 path (used by tools that route via `ALL_PROXY`), as long as the client sends the hostname (`socks5h`, which curb advertises) rather than a pre-resolved IP. -It is also settable via the `CURB_INJECT_HEADER` environment variable -(comma-separated) and the `inject-header:` config-file/profile key, merged +It is also settable via the `CURB_INJECT_HEADER` environment variable, which +holds one full `ENV_VAR:HOST[,HOST...]` binding. For multiple bindings, repeat +the flag or use the `inject-header:` config-file/profile key. Values are merged additively like `domains`. The value is resolved at run time wherever the binding comes from, so prefer config files and profiles — only the variable name is stored, never the token: diff --git a/sandbox/inject_test.go b/sandbox/inject_test.go index 787fff1..e043a64 100644 --- a/sandbox/inject_test.go +++ b/sandbox/inject_test.go @@ -105,6 +105,42 @@ func TestResolveInjectAllowsWildcardDomain(t *testing.T) { assert.Equal(t, injectPlaceholder("ACTIVE_TOKEN"), plan.EnvSet["ACTIVE_TOKEN"]) } +func TestResolveInjectExactPassthroughSkipsInjection(t *testing.T) { + t.Setenv("ACTIVE_TOKEN", "secret") + plan := &SandboxPlan{ + TempDir: t.TempDir(), + EnvSet: map[string]string{}, + EnvPassthrough: []string{"ACTIVE_TOKEN"}, + ProxyEnabled: true, + } + specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} + + require.NoError(t, resolveInject(plan, specs)) + assert.Empty(t, plan.InjectBindings) + assert.Nil(t, plan.CA) + _, set := plan.EnvSet["ACTIVE_TOKEN"] + assert.False(t, set) +} + +func TestResolveInjectWildcardPassthroughStillInjects(t *testing.T) { + if systemCABundle() == "" { + t.Skip("system CA bundle unavailable") + } + t.Setenv("ACTIVE_TOKEN", "secret") + plan := &SandboxPlan{ + TempDir: t.TempDir(), + EnvSet: map[string]string{}, + EnvPassthrough: []string{"ACTIVE_*"}, + AllowedDomains: []string{"api.example.com"}, + ProxyEnabled: true, + } + specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} + + require.NoError(t, resolveInject(plan, specs)) + assert.Contains(t, plan.InjectBindings, policy.InjectTarget{Host: "api.example.com", Port: "443"}) + assert.Equal(t, injectPlaceholder("ACTIVE_TOKEN"), plan.EnvSet["ACTIVE_TOKEN"]) +} + func TestResolveInjectExtendsPassthroughSSL_CERT_FILE(t *testing.T) { t.Setenv("ACTIVE_TOKEN", "secret") dir := t.TempDir() @@ -138,6 +174,37 @@ func TestResolveInjectExtendsPassthroughSSL_CERT_FILE(t *testing.T) { } } +func TestResolveInjectExtendsEachExistingCABundleEnv(t *testing.T) { + t.Setenv("ACTIVE_TOKEN", "secret") + dir := t.TempDir() + curlBase := filepath.Join(dir, "curl-roots.pem") + requestsBase := filepath.Join(dir, "requests-roots.pem") + require.NoError(t, os.WriteFile(curlBase, []byte("-----BEGIN CERTIFICATE-----\nCURLROOT\n-----END CERTIFICATE-----\n"), 0o644)) + require.NoError(t, os.WriteFile(requestsBase, []byte("-----BEGIN CERTIFICATE-----\nREQUESTSROOT\n-----END CERTIFICATE-----\n"), 0o644)) + t.Setenv("REQUESTS_CA_BUNDLE", requestsBase) + + plan := &SandboxPlan{ + TempDir: t.TempDir(), + EnvSet: map[string]string{"CURL_CA_BUNDLE": curlBase}, + EnvPassthrough: []string{"REQUESTS_CA_BUNDLE"}, + AllowedDomains: []string{"api.example.com"}, + ProxyEnabled: true, + } + specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} + + require.NoError(t, resolveInject(plan, specs)) + curlData, err := os.ReadFile(plan.EnvSet["CURL_CA_BUNDLE"]) + require.NoError(t, err) + assert.Contains(t, string(curlData), "CURLROOT") + + requestsData, err := os.ReadFile(plan.EnvSet["REQUESTS_CA_BUNDLE"]) + require.NoError(t, err) + assert.Contains(t, string(requestsData), "REQUESTSROOT") + + _, err = os.Stat(plan.EnvSet["SSL_CERT_FILE"]) + assert.NoError(t, err) +} + func TestResolveInjectIPTarget(t *testing.T) { if systemCABundle() == "" { t.Skip("system CA bundle unavailable") diff --git a/sandbox/plan.go b/sandbox/plan.go index d414280..b0f7a83 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -602,6 +602,36 @@ func parseInjectHeader(entries []string) ([]injectSpec, error) { return specs, nil } +func activeInjectSpecs(specs []injectSpec, exactPassthrough func(string) bool) []injectSpec { + active := make([]injectSpec, 0, len(specs)) + for _, s := range specs { + token, present := os.LookupEnv(s.envVar) + if !present || token == "" { + continue + } + if exactPassthrough != nil && exactPassthrough(s.envVar) { + continue + } + active = append(active, s) + } + return active +} + +func (p *SandboxPlan) hasExactEnvPassthrough(name string) bool { + if len(p.EnvPassthrough) > 0 && p.EnvPassthrough[0] == envPassthroughAll { + return false + } + return slices.Contains(p.EnvPassthrough, name) +} + +func configHasExactEnvPassthrough(cfg *config.Config, name string) bool { + if cfg.EnvPassthroughAll { + return false + } + adds, _, _ := config.ParseExclusions(cfg.EnvPassthrough) + return slices.Contains(adds, name) +} + // displayInjectTarget formats an injection target for human output, dropping // the default :443 so the common case reads as a bare host. func displayInjectTarget(t policy.InjectTarget) string { @@ -650,6 +680,10 @@ func injectPlaceholder(envVar string) string { // per credential, and the common case (e.g. the claude profile for an OAuth // user) is that the key is simply not present. func resolveInject(plan *SandboxPlan, specs []injectSpec) error { + if len(specs) == 0 { + return nil + } + specs = activeInjectSpecs(specs, plan.hasExactEnvPassthrough) if len(specs) == 0 { return nil } @@ -658,10 +692,7 @@ func resolveInject(plan *SandboxPlan, specs []injectSpec) error { bindings := make(map[policy.InjectTarget][]proxy.Injection, len(specs)) placeholders := make(map[string]string) for _, s := range specs { - token, present := os.LookupEnv(s.envVar) - if !present || token == "" { - continue // source var absent — nothing to inject for this binding - } + token, _ := os.LookupEnv(s.envVar) placeholder := injectPlaceholder(s.envVar) for _, t := range s.targets { if err := authorizeInjectTarget(t, domainMatcher, ipMatcher); err != nil { @@ -689,35 +720,51 @@ func resolveInject(plan *SandboxPlan, specs []injectSpec) error { // in even under --env '*'. maps.Copy(plan.EnvSet, placeholders) - caBase := plan.EnvSet["SSL_CERT_FILE"] - if _, set := plan.EnvSet["SSL_CERT_FILE"]; !set { - caBase, _ = plan.passthroughEnvValue("SSL_CERT_FILE") + // Trust-store delivery: each standard CA env var receives a bundle that + // extends its existing value, or system roots when unset. The CA validates + // only inside this run, so it is not sensitive to the action. + for _, k := range caBundleEnvKeys { + bundle, err := writeCABundleFile(plan.TempDir, caBundleFilename(k), plan.caBundleBase(k), ca.CertPEM()) + if err != nil { + return err + } + plan.ROFiles = appendUniq(plan.ROFiles, bundle) + plan.EnvSet[k] = bundle } + return nil +} - // Trust-store delivery: a combined bundle (system roots + per-run CA) the - // action trusts for the proxy's leaf certs. The CA validates only inside - // this run, so it is not sensitive to the action. A user-provided - // SSL_CERT_FILE is used as the base (extended, not discarded) so a custom - // trust store still applies to non-injected hosts. - bundle, err := writeCABundle(plan.TempDir, caBase, ca.CertPEM()) - if err != nil { - return err +var caBundleEnvKeys = []string{"SSL_CERT_FILE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS"} + +func (p *SandboxPlan) caBundleBase(name string) string { + if base := p.EnvSet[name]; base != "" { + return base } - plan.ROFiles = appendUniq(plan.ROFiles, bundle) - for _, k := range []string{"SSL_CERT_FILE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS"} { - plan.EnvSet[k] = bundle + if base, _ := p.passthroughEnvValue(name); base != "" { + return base } - return nil + return "" +} + +func caBundleFilename(name string) string { + if name == "SSL_CERT_FILE" { + return "ca-bundle.pem" + } + return "ca-bundle-" + strings.ToLower(name) + ".pem" } // writeCABundle writes a PEM bundle of the base roots plus the per-run CA to -// the temp dir and returns its path. base is the trust store to extend (a -// user-provided SSL_CERT_FILE); when empty it falls back to the system roots. +// the temp dir and returns its path. Base is the trust store to extend; when +// empty it falls back to the system roots. // It fails if the base roots cannot be located or read: this bundle replaces // the sandbox's TLS trust (SSL_CERT_FILE etc.), so a bundle holding only the // per-run CA would break trust for every HTTPS destination other than the // injected hosts. func writeCABundle(tmpDir, base string, caPEM []byte) (string, error) { + return writeCABundleFile(tmpDir, "ca-bundle.pem", base, caPEM) +} + +func writeCABundleFile(tmpDir, filename, base string, caPEM []byte) (string, error) { if base == "" { base = systemCABundle() } @@ -732,7 +779,7 @@ func writeCABundle(tmpDir, base string, caPEM []byte) (string, error) { buf = append(buf, '\n') } buf = append(buf, caPEM...) - path := filepath.Join(tmpDir, "ca-bundle.pem") + path := filepath.Join(tmpDir, filename) if err := os.WriteFile(path, buf, 0o644); err != nil { return "", fmt.Errorf("writing CA bundle: %w", err) } @@ -1441,7 +1488,13 @@ func resolveSymlinks(paths []string) []string { func buildDegradedPlan(cfg *config.Config, caps *Capabilities) (*SandboxPlan, error) { plan := &SandboxPlan{Caps: caps} - if len(cfg.InjectHeader) > 0 { + injects, err := parseInjectHeader(cfg.InjectHeader) + if err != nil { + return nil, err + } + if len(activeInjectSpecs(injects, func(name string) bool { + return configHasExactEnvPassthrough(cfg, name) + })) > 0 { return nil, fmt.Errorf("credential injection (--inject-header) is only supported on Linux and macOS") } diff --git a/sandbox/plan_test.go b/sandbox/plan_test.go index 86578bc..eaa7556 100644 --- a/sandbox/plan_test.go +++ b/sandbox/plan_test.go @@ -170,6 +170,32 @@ func TestBuildDegradedPlan_NoISandboxEnv(t *testing.T) { assert.False(t, ok, "IS_SANDBOX should not be set for non-isolated platforms") } +func TestBuildDegradedPlan_InactiveInjectHeaderAllowed(t *testing.T) { + t.Setenv("INACTIVE_TOKEN", "") + cfg := &config.Config{ + InjectHeader: []string{"INACTIVE_TOKEN:api.example.com"}, + NoFSRestrict: true, + NoExecRestrict: true, + } + plan, err := buildDegradedPlan(cfg, minCaps()) + require.NoError(t, err) + defer plan.Cleanup() + + assert.Empty(t, plan.InjectBindings) +} + +func TestBuildDegradedPlan_ActiveInjectHeaderUnsupported(t *testing.T) { + t.Setenv("ACTIVE_TOKEN", "secret") + cfg := &config.Config{ + InjectHeader: []string{"ACTIVE_TOKEN:api.example.com"}, + NoFSRestrict: true, + NoExecRestrict: true, + } + _, err := buildDegradedPlan(cfg, minCaps()) + require.Error(t, err) + assert.Contains(t, err.Error(), "credential injection (--inject-header) is only supported") +} + // --- BuildPlan --- func TestBuildPlan_NoLandlock_WithMountNS(t *testing.T) { From 53a6e4cabc39963b15293b846dfb730d7a395170 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 30 Jun 2026 12:10:46 +0100 Subject: [PATCH 17/27] fix: renew injected certificates for long sessions 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. --- proxy/inject.go | 23 +++++++++++++++++++---- proxy/inject_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/proxy/inject.go b/proxy/inject.go index 41c946c..6ce9091 100644 --- a/proxy/inject.go +++ b/proxy/inject.go @@ -21,6 +21,12 @@ import ( "github.com/upsun/curb/policy" ) +const ( + caValidity = 365 * 24 * time.Hour + leafValidity = 30 * 24 * time.Hour + leafRenewBefore = 24 * time.Hour +) + // CA is a per-run certificate authority used to mint leaf certificates for the // hosts whose TLS the injecting proxy terminates. It is trusted only inside one // sandbox: even if the action reads the key, all it can do is MITM itself. @@ -45,7 +51,7 @@ func NewCA() (*CA, error) { SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "curb per-run CA"}, NotBefore: now.Add(-time.Hour), - NotAfter: now.Add(24 * time.Hour), + NotAfter: now.Add(caValidity), KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, BasicConstraintsValid: true, IsCA: true, @@ -75,10 +81,11 @@ func (ca *CA) CertPEM() []byte { return ca.certPEM } // cached per host for the life of the run. Signing happens without the lock // held, so a cache hit for one host never blocks behind a sign for another. func (ca *CA) leafFor(host string) (*tls.Certificate, error) { + now := time.Now() ca.mu.RLock() c := ca.leaves[host] ca.mu.RUnlock() - if c != nil { + if c != nil && !leafNeedsRenewal(c, now) { return c, nil } leaf, err := ca.signLeaf(host) @@ -87,7 +94,7 @@ func (ca *CA) leafFor(host string) (*tls.Certificate, error) { } ca.mu.Lock() defer ca.mu.Unlock() - if existing := ca.leaves[host]; existing != nil { + if existing := ca.leaves[host]; existing != nil && !leafNeedsRenewal(existing, now) { return existing, nil // another goroutine won the race; reuse its leaf } ca.leaves[host] = leaf @@ -101,11 +108,15 @@ func (ca *CA) signLeaf(host string) (*tls.Certificate, error) { return nil, err } now := time.Now() + notAfter := now.Add(leafValidity) + if notAfter.After(ca.cert.NotAfter) { + notAfter = ca.cert.NotAfter + } tmpl := &x509.Certificate{ SerialNumber: big.NewInt(now.UnixNano()), Subject: pkix.Name{CommonName: host}, NotBefore: now.Add(-time.Hour), - NotAfter: now.Add(24 * time.Hour), + NotAfter: notAfter, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, } @@ -125,6 +136,10 @@ func (ca *CA) signLeaf(host string) (*tls.Certificate, error) { return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leafCert}, nil } +func leafNeedsRenewal(cert *tls.Certificate, now time.Time) bool { + return cert.Leaf == nil || !cert.Leaf.NotAfter.After(now.Add(leafRenewBefore)) +} + // Injection is a credential bound to a destination host: on requests the proxy // forwards to that host, it replaces every occurrence of Placeholder in the // request header values with Value, and does so for no other host. The sandbox diff --git a/proxy/inject_test.go b/proxy/inject_test.go index fca9c7f..9447d07 100644 --- a/proxy/inject_test.go +++ b/proxy/inject_test.go @@ -12,6 +12,7 @@ import ( "net/http/httptest" "net/url" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -150,6 +151,37 @@ func getWithHeader(t *testing.T, client *http.Client, rawURL, name, value string return string(body) } +func TestCA_CertificatesSupportLongSessions(t *testing.T) { + ca, err := NewCA() + require.NoError(t, err) + + now := time.Now() + assert.Greater(t, ca.cert.NotAfter.Sub(now), 7*24*time.Hour) + + leaf, err := ca.leafFor("api.github.com") + require.NoError(t, err) + require.NotNil(t, leaf.Leaf) + assert.Greater(t, leaf.Leaf.NotAfter.Sub(now), 7*24*time.Hour) + assert.False(t, leaf.Leaf.NotAfter.After(ca.cert.NotAfter), "leaf must not outlive the CA") +} + +func TestCA_LeafForRenewsExpiringCachedLeaf(t *testing.T) { + ca, err := NewCA() + require.NoError(t, err) + + first, err := ca.leafFor("api.github.com") + require.NoError(t, err) + require.NotNil(t, first.Leaf) + + first.Leaf.NotAfter = time.Now().Add(leafRenewBefore / 2) + renewed, err := ca.leafFor("api.github.com") + require.NoError(t, err) + + assert.NotSame(t, first, renewed) + assert.NotEqual(t, first.Certificate[0], renewed.Certificate[0]) + assert.Greater(t, time.Until(renewed.Leaf.NotAfter), leafRenewBefore) +} + // TestInjector_BindingNormalizesHost confirms bindings match CONNECT targets // regardless of case or a trailing dot, and only on the bound port. func TestInjector_BindingNormalizesHost(t *testing.T) { From 638cc841e59a713b5103b03cb44e3ea2393dd576 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Wed, 1 Jul 2026 23:35:23 +0100 Subject: [PATCH 18/27] fix: address credential-injection review findings - Serve injected connections via http.Server + httputil.ReverseProxy. Fixes a race on the client reader when the upstream responds before consuming a keep-alive request body, supports HTTP/1.1 Upgrade (WebSocket) relays on bound hosts, and answers Expect: 100-continue instead of stalling clients. Streamed responses (SSE) flush immediately, matching the old direct relay. - Treat an explicit --env VAR=value as an injection opt-out like exact passthrough. Previously the placeholder silently clobbered the value and the proxy injected the host environment's credential instead. - Canonicalize injection target ports ("0443" -> "443") so the binding key matches a real connection's port. - Make the plain-HTTP refusal port-exact, matching the documented binding contract; other ports of a bound host are relayed unchanged. - Fail with an actionable error naming the variable and the --env passthrough alternative when a binding is active without the proxy (--unrestricted-net, or a platform without network filtering). - Fall back to system roots with a warning when a CA bundle env var points at a directory, instead of aborting the run. - Share one Injector (one upstream connection pool) between the HTTP and SOCKS5 proxies; drop the dead writeCABundle wrapper and the unreachable empty-bindings branch. - Document that responses from bound hosts are relayed unscrubbed: an endpoint that reflects request headers returns the real credential to the sandbox, so a credential should be bound only to destinations it was issued for. Note in the claude profile that a custom ANTHROPIC_BASE_URL gateway is not covered by the built-in binding. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- config/profiles/claude.yaml | 3 + docs/configuration.md | 38 +++++-- policy/validate.go | 16 +-- policy/validate_test.go | 4 + proxy/handler.go | 9 +- proxy/inject.go | 134 ++++++++++++++---------- proxy/inject_test.go | 200 +++++++++++++++++++++++++++++++++--- sandbox/inject_test.go | 67 +++++++++++- sandbox/parent_darwin.go | 5 +- sandbox/parent_linux.go | 5 +- sandbox/plan.go | 79 +++++++++----- sandbox/proxy_handler.go | 12 ++- 13 files changed, 442 insertions(+), 132 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 92fd4ef..a84e586 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ go test ./sandbox/ -run TestCurb_FS_ -v # run a subset - The SOCKS proxy is advertised via `ALL_PROXY`/`all_proxy` (HTTP/HTTPS still prefer `HTTP_PROXY`/`HTTPS_PROXY`). A user-provided value takes precedence, so `--env ALL_PROXY=` suppresses it — useful for HTTP-only workloads with clients that eagerly construct a SOCKS transport (e.g. httpx, which then needs the `httpx[socks]` extra). ssh routing does not use it (it has its own config). - `--ips` allows connections to specific IP addresses or CIDR ranges. Works alongside or independently of `--domains`. IP-matched connections bypass domain filtering (forwarded directly on any port via SOCKS5 or CONNECT). In IPs-only mode (no `--domains`), DNS queries are not intercepted. - `--unrestricted-net` skips network namespace creation entirely, allowing unrestricted network access while keeping FS sandboxing. Cannot be combined with `--domains` or `--ips`. -- `--inject-header ENV_VAR:HOST[:PORT][,HOST...]` keeps a credential out of the sandbox. curb sets the child's `ENV_VAR` to a stable per-var placeholder; the child never sees the real value. Each destination is `HOST[:PORT]` (a hostname or IP, port defaulting to 443) and bindings are keyed by `host:port`: the proxy terminates TLS and substitutes the real value (read from the host's `ENV_VAR`) wherever the client put the placeholder among the request headers — header-agnostic (no knowledge of the auth scheme) — but only on that exact host:port, so a connection to the same host on a different port is relayed unchanged. The binding is var-first because a credential belongs to its variable and may be valid for several destinations; the right side is a comma-separated list. Hostname destinations must be allowed by `--domains`, IP destinations by `--ips`. Opt-in per credential: if `ENV_VAR` is unset or empty on the host the binding is skipped (so the `claude` profile is a no-op for OAuth users). Exact passthrough (`--env ENV_VAR`) is an explicit trust decision and disables injection for that variable; wildcard passthrough does not. The proxy uses a per-run CA delivered to the child by extending each standard CA env var's existing bundle, or system roots when unset; the CA key and real token stay parent-only. Settable via the repeatable flag, one full binding in `CURB_INJECT_HEADER`, or the `inject-header:` config/profile key (merged additively). In YAML, a list item needs no space after the colon (`- FOO:host`); `- FOO: host` parses as a map and is rejected. +- `--inject-header ENV_VAR:HOST[:PORT][,HOST...]` keeps a credential out of the sandbox. curb sets the child's `ENV_VAR` to a stable per-var placeholder; the child never sees the real value. Each destination is `HOST[:PORT]` (a hostname or IP, port defaulting to 443) and bindings are keyed by `host:port`: the proxy terminates TLS and substitutes the real value (read from the host's `ENV_VAR`) wherever the client put the placeholder among the request headers — header-agnostic (no knowledge of the auth scheme) — but only on that exact host:port, so a connection to the same host on a different port is relayed unchanged. The binding is var-first because a credential belongs to its variable and may be valid for several destinations; the right side is a comma-separated list. Hostname destinations must be allowed by `--domains`, IP destinations by `--ips`. Opt-in per credential: if `ENV_VAR` is unset or empty on the host the binding is skipped (so the `claude` profile is a no-op for OAuth users). Exact passthrough (`--env ENV_VAR`) or an explicit value (`--env ENV_VAR=value`) is an explicit trust decision and disables injection for that variable; wildcard passthrough does not. Injection requires the proxy: with `--unrestricted-net` (or on platforms without network filtering) an active binding fails at planning, suggesting `--env ENV_VAR` instead. The terminated stream is served as HTTP/1.1 (keep-alive, Upgrade/WebSocket, 100-continue; no h2); a plain-HTTP request to a bound host:port is refused. Responses are relayed unscrubbed — a bound endpoint that reflects request headers hands the real value back to the child. The proxy uses a per-run CA delivered to the child by extending each standard CA env var's existing bundle, or system roots when unset; the CA key and real token stay parent-only. Settable via the repeatable flag, one full binding in `CURB_INJECT_HEADER`, or the `inject-header:` config/profile key (merged additively). In YAML, a list item needs no space after the colon (`- FOO:host`); `- FOO: host` parses as a map and is rejected. - `--domains` validates input: rejects URLs (suggests bare domain), IP addresses (suggests `--ips`), invalid characters, and malformed wildcards. `--ips` validates that values parse as IP addresses or CIDR prefixes. - `!` prefix in list flags removes from defaults AND actively denies via overmount when the path is under an allowed parent. `--read '!/path'` hides (tmpfs/dev-null), `--write '!/path'` makes read-only, `--exec '!/path'` makes noexec. `!*` clears all defaults (not deny-all). `\!` escapes literal `!`. Sub-path denials require mount NS; Landlock-only mode warns. - HOME defaults to a private tmpDir. `--env HOME` passes through the host HOME; `--env HOME=/path` sets it explicitly. `~` and `$HOME` in config/profile paths both resolve to the **host** home (the shell's view), not the sandbox HOME — they are host-side shorthands. When sandbox HOME differs from host HOME and a path uses `~`, curb warns that the sandboxed program cannot reach those paths via its own `$HOME`. Built-in profiles include `HOME` in their env passthrough so sandbox HOME = host HOME and `~` paths align. See `docs/configuration.md` for full details. diff --git a/config/profiles/claude.yaml b/config/profiles/claude.yaml index aff76f0..f3ade62 100644 --- a/config/profiles/claude.yaml +++ b/config/profiles/claude.yaml @@ -46,6 +46,9 @@ exec: - /opt/homebrew/bin # Keep ANTHROPIC_API_KEY out of the sandbox: the proxy injects it into requests # to api.anthropic.com. Skipped when the host key is unset (OAuth users). +# A custom ANTHROPIC_BASE_URL gateway is not covered: bind the key to the +# gateway host too, or pass it through with --env ANTHROPIC_API_KEY. +# See docs/configuration.md ("Credential injection"). inject-header: - ANTHROPIC_API_KEY:api.anthropic.com env: diff --git a/docs/configuration.md b/docs/configuration.md index c9111af..05d7bfb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -225,6 +225,14 @@ same binding works whether the client sends `Authorization: Bearer `, across tools or changes over time, and so that a binding can be written by someone who knows the credential's env var but not the API's header. +Injection is one-way: requests are rewritten, responses are relayed untouched. +If an endpoint on a bound host reflects request headers back — a debug or echo +endpoint, or an error response quoting the `Authorization` header — the +response carries the real credential into the sandbox. APIs do not normally do +this, but it means the binding protects the credential from the sandboxed +program, not from the bound host: bind a credential only to destinations it was +issued for. + Injection is opt-in per credential: if `ENV_VAR` is unset or empty on the host, the binding is skipped silently (no error). This is what lets a profile carry an injection that simply does nothing when the credential is absent. @@ -235,6 +243,11 @@ config, or a profile), an IP by `--ips`. If the credential is present but the destination is not allowed, curb fails during planning instead of broadening the sandbox policy. +Injection also requires the network proxy, so it cannot be combined with +`--unrestricted-net` (or run on a platform without network filtering): curb +fails during planning and suggests the alternative, an exact `--env ENV_VAR` +passthrough that puts the real credential in the sandbox instead. + ``` # Real value read from the host's $GH_TOKEN; the sandbox sees only a placeholder, # and gh sends it in whatever header it uses: @@ -260,9 +273,10 @@ The profile binding targets `api.anthropic.com`, so a custom `ANTHROPIC_BASE_URL and auth would fail. For a gateway, either bind the key to its host as well — `--inject-header ANTHROPIC_API_KEY:gateway.example.com` (bindings accumulate, so both hosts inject) — or, if the gateway is trusted, pass the key through with an -exact `--env ANTHROPIC_API_KEY`. Exact passthrough is treated as an explicit -trust decision and disables injection for that variable; wildcard passthrough -such as `--env 'ANTHROPIC_*'` or `--env '*'` does not. +exact `--env ANTHROPIC_API_KEY`. Exact passthrough (`--env ENV_VAR`) and an +explicit value (`--env ENV_VAR=value`) are treated as explicit trust decisions +and disable injection for that variable; wildcard passthrough such as +`--env 'ANTHROPIC_*'` or `--env '*'` does not. The flag is repeatable (bindings accumulate). For active bindings, curb extends each existing CA bundle from the standard CA environment variables @@ -270,20 +284,24 @@ extends each existing CA bundle from the standard CA environment variables `NODE_EXTRA_CA_CERTS`) with the per-run CA and points that variable at the extended bundle. If a variable is unset, curb uses the system roots as its base. This lets common tools trust the terminated connection while preserving custom -trust for other hosts. +trust for other hosts. A variable pointing at a *directory* of certificates +cannot be extended by concatenation; curb warns and uses the system roots as +that variable's base instead. -After terminating TLS, curb parses the decrypted stream as HTTP/1.1 and does not +After terminating TLS, curb serves the decrypted stream as HTTP/1.1 — including +`Upgrade` flows such as WebSocket and `Expect: 100-continue` — and does not negotiate HTTP/2 (no `h2` ALPN). An HTTP/2-only client or protocol — some gRPC setups, for example — may fail against a host with injection enabled. Hosts without an injection binding keep the untouched passthrough relay and are unaffected. -Injection requires HTTPS. A request to a bound host over plain HTTP is refused +Injection requires HTTPS. A plain-HTTP request to a bound host:port is refused (injecting over cleartext would expose the credential), so a binding host should -be one the client reaches over TLS. Injection applies on both the HTTPS proxy -and the SOCKS5 path (used by tools that route via `ALL_PROXY`), as long as the -client sends the hostname (`socks5h`, which curb advertises) rather than a -pre-resolved IP. +be one the client reaches over TLS. The refusal is port-exact, like the binding: +plain HTTP to the same host on a port without a binding is relayed unchanged. +Injection applies on both the HTTPS proxy and the SOCKS5 path (used by tools +that route via `ALL_PROXY`), as long as the client sends the hostname +(`socks5h`, which curb advertises) rather than a pre-resolved IP. It is also settable via the `CURB_INJECT_HEADER` environment variable, which holds one full `ENV_VAR:HOST[,HOST...]` binding. For multiple bindings, repeat diff --git a/policy/validate.go b/policy/validate.go index 640f17f..ee6466a 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -119,10 +119,11 @@ func parseInjectTarget(item string) (InjectTarget, error) { } host, port := item, "443" if h, p, ok := splitInjectPort(item); ok { - if err := validatePort(p); err != nil { + canonical, err := canonicalPort(p) + if err != nil { return InjectTarget{}, fmt.Errorf("injection target %q: %w", item, err) } - host, port = h, p + host, port = h, canonical } if addr, err := netip.ParseAddr(host); err == nil { return InjectTarget{Host: addr.String(), Port: port, IsIP: true}, nil @@ -168,13 +169,16 @@ func isAllDigits(s string) bool { return true } -// validatePort checks that p is a numeric TCP port in 1..65535. -func validatePort(p string) error { +// canonicalPort checks that p is a numeric TCP port in 1..65535 and returns +// its canonical decimal form. Binding keys are built from it, so a +// non-canonical spelling ("0443") must not produce a key that never matches a +// real connection's port. +func canonicalPort(p string) (string, error) { n, err := strconv.Atoi(p) if err != nil || n < 1 || n > 65535 { - return fmt.Errorf("invalid port %q (want 1-65535)", p) + return "", fmt.Errorf("invalid port %q (want 1-65535)", p) } - return nil + return strconv.Itoa(n), nil } // ValidEnvName reports whether name is a valid environment variable name (a C diff --git a/policy/validate_test.go b/policy/validate_test.go index 32d38cc..61ba185 100644 --- a/policy/validate_test.go +++ b/policy/validate_test.go @@ -75,6 +75,10 @@ func TestParseInjectHeader(t *testing.T) { []InjectTarget{{Host: "2001:db8::1", Port: "443", IsIP: true}}, ""}, {"bracketed ipv6 with port", "TOK:[2001:db8::1]:8443", "TOK", []InjectTarget{{Host: "2001:db8::1", Port: "8443", IsIP: true}}, ""}, + // A port with leading zeros is canonicalized, or the binding key would + // never match a real connection's port. + {"leading-zero port", "TOK:api.example.com:0443", "TOK", + []InjectTarget{{Host: "api.example.com", Port: "443"}}, ""}, {"reject no colon", "TOK", "", nil, "ENV_VAR:HOST"}, {"reject empty host", "TOK:", "", nil, "ENV_VAR:HOST"}, diff --git a/proxy/handler.go b/proxy/handler.go index 630098c..6355560 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -99,10 +99,11 @@ func (h *Handler) handleHTTP(w http.ResponseWriter, r *http.Request) { return } - // A credential is bound to this host but the request is plain HTTP. Refuse - // rather than forward: injecting over cleartext would expose the real - // credential, and forwarding the placeholder unchanged fails auth silently. - if h.Injector.hasHost(host) { + // A credential is bound to this exact host:port but the request is plain + // HTTP. Refuse rather than forward: injecting over cleartext would expose + // the real credential. Other ports of the same host are relayed unchanged, + // per the port-exact binding contract. + if _, bound := h.Injector.binding(host, port); bound { http.Error(w, "curb: credential injection requires HTTPS for "+host, http.StatusBadGateway) h.logEvent("proxy_http", r.Host, "blocked", "inject-requires-https") return diff --git a/proxy/inject.go b/proxy/inject.go index 6ce9091..c91b510 100644 --- a/proxy/inject.go +++ b/proxy/inject.go @@ -1,7 +1,6 @@ package proxy import ( - "bufio" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" @@ -10,9 +9,12 @@ import ( "crypto/x509/pkix" "encoding/pem" "fmt" + "io" + "log" "math/big" "net" "net/http" + "net/http/httputil" "net/netip" "strings" "sync" @@ -161,8 +163,7 @@ type Injector struct { // tests override it to reach local servers. Upstream http.RoundTripper - byTarget map[string][]Injection // key: net.JoinHostPort(host, port) - boundHosts map[string]struct{} // host only, for the plain-HTTP refusal + byTarget map[string][]Injection // key: net.JoinHostPort(host, port) } // NewInjector creates an injector backed by the per-run CA. Its upstream @@ -176,18 +177,15 @@ func NewInjector(ca *CA) *Injector { TLSHandshakeTimeout: dialTimeout, TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, }, - byTarget: map[string][]Injection{}, - boundHosts: map[string]struct{}{}, + byTarget: map[string][]Injection{}, } } // Bind adds a header injection for a destination host:port. Multiple headers // may be bound to the same target. func (in *Injector) Bind(host, port string, inj Injection) { - host = normalizeHost(host) - key := net.JoinHostPort(host, port) + key := net.JoinHostPort(normalizeHost(host), port) in.byTarget[key] = append(in.byTarget[key], inj) - in.boundHosts[host] = struct{}{} } // binding returns the injections bound to host:port, if any. @@ -199,17 +197,6 @@ func (in *Injector) binding(host, port string) ([]Injection, bool) { return injs, ok } -// hasHost reports whether any binding exists for host on any port. The -// plain-HTTP path uses it to refuse cleartext to a host that holds a -// credential, regardless of the bound port. -func (in *Injector) hasHost(host string) bool { - if in == nil { - return false - } - _, ok := in.boundHosts[normalizeHost(host)] - return ok -} - // normalizeHost canonicalizes a host for binding keys: an IP literal to its // canonical form, otherwise a lowercased hostname. The binding side and the // proxy lookup must agree, including when a client addresses an IP directly. @@ -273,47 +260,86 @@ func (in *Injector) Serve(client net.Conn, host, port string, injs []Injection) return nil } -// serveInjected reads requests off the decrypted client stream and forwards -// each to the upstream with the bound credentials set, relaying the response. +// serveInjected serves HTTP on the decrypted client stream and forwards each +// request to the upstream with the bound credentials set. A single-connection +// http.Server drives a ReverseProxy: the server side owns HTTP/1.1 framing — +// draining request bodies between keep-alive requests and answering Expect: +// 100-continue — and the ReverseProxy relays Upgrade (WebSocket) streams. +// Responses are relayed as-is, without scrubbing the real credential out of +// reflected headers or bodies (see docs/configuration.md). func (in *Injector) serveInjected(client net.Conn, host, port string, injs []Injection) { - rt := in.Upstream // authority is the upstream the request is bound to. Drop the default // https port so the Host header matches what a direct client would send. authority := injectedAuthority(host, port) - br := bufio.NewReader(client) - for { - req, err := http.ReadRequest(br) - if err != nil { - return - } - // Strip hop-by-hop headers first so the client cannot smuggle a - // placeholder past substitution by naming a header in Connection. - stripHopByHop(req.Header) - // Replace the placeholder with the real credential wherever the client - // placed it among the request headers. The sandbox never holds the real - // value, so this is where the credential is first introduced. Header - // values only: bodies and the request URI are left untouched. - replaceInHeaders(req.Header, injs) - req.URL.Scheme = "https" - req.URL.Host = authority - // Align the Host header with the upstream we dial; the client may have - // sent a placeholder or an incorrect port. - req.Host = authority - req.RequestURI = "" + rp := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + // Hop-by-hop headers are already removed from the outbound request, + // so the client cannot smuggle a placeholder past substitution by + // naming a header in Connection. Replace the placeholder with the + // real credential wherever the client placed it among the request + // headers. The sandbox never holds the real value, so this is where + // the credential is first introduced. Header values only: bodies + // and the request URI are left untouched. + replaceInHeaders(pr.Out.Header, injs) + pr.Out.URL.Scheme = "https" + pr.Out.URL.Host = authority + // Align the Host header with the upstream we dial; the client may + // have sent a placeholder or an incorrect port. + pr.Out.Host = authority + }, + Transport: in.Upstream, + // Flush each write immediately: API responses are often streamed (SSE) + // and the old direct relay never buffered. + FlushInterval: -1, + ErrorLog: log.New(io.Discard, "", 0), + } + done := make(chan struct{}) + conn := &signalOnCloseConn{Conn: client, done: done} + srv := &http.Server{Handler: rp, ErrorLog: log.New(io.Discard, "", 0)} + // Serve returns when the connection closes: the listener yields the one + // connection, then blocks until it is closed — by the server loop after the + // final request, or by the ReverseProxy at the end of an Upgrade relay. + _ = srv.Serve(&singleConnListener{conn: conn, addr: client.LocalAddr(), done: done}) +} - resp, err := rt.RoundTrip(req) - if err != nil { - writeGatewayError(client) - return - } - werr := resp.Write(client) - _ = resp.Body.Close() - if werr != nil { - return - } +// signalOnCloseConn closes done when the connection is closed, whichever code +// path closes it. +type signalOnCloseConn struct { + net.Conn + once sync.Once + done chan struct{} +} + +func (c *signalOnCloseConn) Close() error { + err := c.Conn.Close() + c.once.Do(func() { close(c.done) }) + return err +} + +// singleConnListener yields one already-accepted connection, then blocks until +// that connection is closed before reporting the listener as closed. +type singleConnListener struct { + mu sync.Mutex + conn net.Conn + addr net.Addr + done <-chan struct{} +} + +func (l *singleConnListener) Accept() (net.Conn, error) { + l.mu.Lock() + c := l.conn + l.conn = nil + l.mu.Unlock() + if c != nil { + return c, nil } + <-l.done + return nil, net.ErrClosed } +func (l *singleConnListener) Close() error { return nil } +func (l *singleConnListener) Addr() net.Addr { return l.addr } + func injectedAuthority(host, port string) string { if port != "443" { return net.JoinHostPort(host, port) @@ -338,7 +364,3 @@ func replaceInHeaders(hdr http.Header, injs []Injection) { } } } - -func writeGatewayError(c net.Conn) { - _, _ = c.Write([]byte("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")) -} diff --git a/proxy/inject_test.go b/proxy/inject_test.go index 9447d07..e19539e 100644 --- a/proxy/inject_test.go +++ b/proxy/inject_test.go @@ -11,6 +11,8 @@ import ( "net/http" "net/http/httptest" "net/url" + "strconv" + "strings" "testing" "time" @@ -192,7 +194,6 @@ func TestInjector_BindingNormalizesHost(t *testing.T) { injs, ok := in.binding(host, "443") require.True(t, ok, "expected binding to match %q", host) assert.Equal(t, "real", injs[0].Value) - assert.True(t, in.hasHost(host), "hasHost should match %q", host) } // A credential bound to :443 must not match a different port. @@ -201,7 +202,6 @@ func TestInjector_BindingNormalizesHost(t *testing.T) { _, ok = in.binding("other.com", "443") assert.False(t, ok) - assert.False(t, in.hasHost("other.com")) } // TestInjector_SetsHostHeader confirms the upstream request's Host header is @@ -305,37 +305,50 @@ func TestInjector_BindsCredentialToDestination(t *testing.T) { assert.NotContains(t, other.got("Authorization")[0], "ghs_realtoken") } -// TestInjector_RefusesPlainHTTP confirms a bound host reached over plain HTTP -// is refused, not forwarded: the credential must never go out over cleartext. +// TestInjector_RefusesPlainHTTP confirms a bound host:port reached over plain +// HTTP is refused, not forwarded: the credential must never go out over +// cleartext. The refusal is port-exact — plain HTTP to the same host on a port +// without a binding is relayed unchanged, per the documented contract. func TestInjector_RefusesPlainHTTP(t *testing.T) { ca, err := NewCA() require.NoError(t, err) - gh := newAuthRecorder(t) - injector, allowed := newTestInjector(ca, - map[string][]Injection{"api.github.com": {{Placeholder: "GH_PH", Value: "ghs_realtoken"}}}, - map[string]*authRecorder{"api.github.com": gh}, - ) + // A loopback port with nothing listening: the unbound-port request should + // get a dial failure, not the injection refusal. + closedLn, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + closedPort := closedLn.Addr().(*net.TCPAddr).Port + require.NoError(t, closedLn.Close()) + + injector := NewInjector(ca) + injector.Bind("localhost", strconv.Itoa(closedPort), Injection{Placeholder: "PH", Value: "real"}) handler := &Handler{ - FilterBase: FilterBase{DomainCheck: func(h string) bool { return allowed[h] }}, + FilterBase: FilterBase{DomainCheck: func(h string) bool { return h == "localhost" }}, Injector: injector, } proxySrv := httptest.NewServer(handler) t.Cleanup(proxySrv.Close) proxyURL, err := url.Parse(proxySrv.URL) require.NoError(t, err) - client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}} - resp, err := client.Get("http://api.github.com/user") - require.NoError(t, err) - defer func() { _ = resp.Body.Close() }() - assert.Equal(t, http.StatusBadGateway, resp.StatusCode) + // Plain HTTP to the bound host:port is refused before dialing. + resp, err := client.Get(fmt.Sprintf("http://localhost:%d/user", closedPort)) + require.NoError(t, err) body, err := io.ReadAll(resp.Body) require.NoError(t, err) + _ = resp.Body.Close() + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) assert.Contains(t, string(body), "requires HTTPS") - // The request never reached an upstream. - assert.Equal(t, 0, gh.count()) + + // Plain HTTP to the same host on an unbound port is relayed (and fails only + // at dial time, since nothing is listening). + resp, err = client.Get(fmt.Sprintf("http://localhost:%d/user", closedPort+1)) + require.NoError(t, err) + body, err = io.ReadAll(resp.Body) + require.NoError(t, err) + _ = resp.Body.Close() + assert.NotContains(t, string(body), "requires HTTPS") } // TestSOCKS5Server_Injects confirms the SOCKS5 egress path injects credentials @@ -404,6 +417,159 @@ func TestSOCKS5Server_Injects(t *testing.T) { assert.Equal(t, "Bearer ghs_realtoken", gh.got("Authorization")[0]) } +// dialInjectTLS dials the proxy, issues a CONNECT for host:443, and completes +// a TLS handshake trusted via the per-run CA. It returns the decrypted stream +// and a reader for responses on it. +func dialInjectTLS(t *testing.T, proxyURL *url.URL, ca *CA, host string) (*tls.Conn, *bufio.Reader) { + t.Helper() + raw, err := net.Dial("tcp", proxyURL.Host) + require.NoError(t, err) + t.Cleanup(func() { _ = raw.Close() }) + require.NoError(t, raw.SetDeadline(time.Now().Add(10*time.Second))) + + _, err = fmt.Fprintf(raw, "CONNECT %s:443 HTTP/1.1\r\nHost: %s:443\r\n\r\n", host, host) + require.NoError(t, err) + resp, err := http.ReadResponse(bufio.NewReader(raw), &http.Request{Method: http.MethodConnect}) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(ca.CertPEM()) + tc := tls.Client(raw, &tls.Config{RootCAs: pool, ServerName: host, MinVersion: tls.VersionTLS12}) + require.NoError(t, tc.Handshake()) + return tc, bufio.NewReader(tc) +} + +// TestInjector_UpgradeWebSocket confirms an HTTP/1.1 Upgrade flow (WebSocket +// handshake and bidirectional stream) works on a bound host, with the +// credential injected into the handshake request. +func TestInjector_UpgradeWebSocket(t *testing.T) { + ca, err := NewCA() + require.NoError(t, err) + + rec := &authRecorder{} + rec.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec.headers = append(rec.headers, r.Header.Clone()) + rec.hosts = append(rec.hosts, r.Host) + if r.Header.Get("Upgrade") != "websocket" { + http.Error(w, "expected upgrade", http.StatusBadRequest) + return + } + conn, brw, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + _, _ = brw.WriteString("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n") + _ = brw.Flush() + line, err := brw.ReadString('\n') + if err != nil { + return + } + _, _ = brw.WriteString("echo:" + line) + _ = brw.Flush() + })) + t.Cleanup(rec.srv.Close) + + proxyURL := injectTestProxy(t, ca, + map[string][]Injection{"api.example.com": {{Placeholder: "EX_PH", Value: "real_token"}}}, + map[string]*authRecorder{"api.example.com": rec}, + ) + tc, br := dialInjectTLS(t, proxyURL, ca, "api.example.com") + + _, err = fmt.Fprintf(tc, "GET /ws HTTP/1.1\r\nHost: api.example.com\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nAuthorization: Bearer EX_PH\r\n\r\n") + require.NoError(t, err) + resp, err := http.ReadResponse(br, nil) + require.NoError(t, err) + require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) + + _, err = io.WriteString(tc, "hello\n") + require.NoError(t, err) + line, err := br.ReadString('\n') + require.NoError(t, err) + assert.Equal(t, "echo:hello\n", line) + + require.Equal(t, 1, rec.count()) + assert.Equal(t, "Bearer real_token", rec.got("Authorization")[0]) +} + +// TestInjector_KeepAliveAfterUnreadBody confirms the connection stays usable +// when the upstream responds without consuming the request body: the next +// request on the same connection must not be corrupted by leftover body bytes. +func TestInjector_KeepAliveAfterUnreadBody(t *testing.T) { + ca, err := NewCA() + require.NoError(t, err) + + rec := &authRecorder{} + rec.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec.headers = append(rec.headers, r.Header.Clone()) + rec.hosts = append(rec.hosts, r.Host) + if r.Method == http.MethodPost { + // Reply without reading the body. + w.WriteHeader(http.StatusRequestEntityTooLarge) + return + } + _, _ = io.WriteString(w, "ok") + })) + t.Cleanup(rec.srv.Close) + + proxyURL := injectTestProxy(t, ca, + map[string][]Injection{"api.example.com": {{Placeholder: "EX_PH", Value: "real_token"}}}, + map[string]*authRecorder{"api.example.com": rec}, + ) + tc, br := dialInjectTLS(t, proxyURL, ca, "api.example.com") + + body := strings.Repeat("x", 128<<10) + _, err = fmt.Fprintf(tc, "POST /upload HTTP/1.1\r\nHost: api.example.com\r\nContent-Length: %d\r\n\r\n%s", len(body), body) + require.NoError(t, err) + resp, err := http.ReadResponse(br, nil) + require.NoError(t, err) + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + require.Equal(t, http.StatusRequestEntityTooLarge, resp.StatusCode) + + _, err = io.WriteString(tc, "GET /after HTTP/1.1\r\nHost: api.example.com\r\nAuthorization: Bearer EX_PH\r\n\r\n") + require.NoError(t, err) + resp, err = http.ReadResponse(br, nil) + require.NoError(t, err) + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + require.Equal(t, 2, rec.count()) + assert.Equal(t, "Bearer real_token", rec.got("Authorization")[1]) +} + +// TestInjector_ExpectContinue confirms a client using Expect: 100-continue +// receives an interim 100 response instead of stalling until timeout. +func TestInjector_ExpectContinue(t *testing.T) { + ca, err := NewCA() + require.NoError(t, err) + + rec := newAuthRecorder(t) + proxyURL := injectTestProxy(t, ca, + map[string][]Injection{"api.example.com": {{Placeholder: "EX_PH", Value: "real_token"}}}, + map[string]*authRecorder{"api.example.com": rec}, + ) + tc, br := dialInjectTLS(t, proxyURL, ca, "api.example.com") + + _, err = io.WriteString(tc, "POST /upload HTTP/1.1\r\nHost: api.example.com\r\nContent-Length: 5\r\nExpect: 100-continue\r\n\r\n") + require.NoError(t, err) + require.NoError(t, tc.SetReadDeadline(time.Now().Add(5*time.Second))) + resp, err := http.ReadResponse(br, nil) + require.NoError(t, err, "expected an interim 100 Continue") + require.Equal(t, http.StatusContinue, resp.StatusCode) + + _, err = io.WriteString(tc, "hello") + require.NoError(t, err) + resp, err = http.ReadResponse(br, nil) + require.NoError(t, err) + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 1, rec.count()) +} + // TestInjector_LeavesOtherHeadersUntouched confirms only the placeholder is // replaced; unrelated header content passes through verbatim. func TestInjector_LeavesOtherHeadersUntouched(t *testing.T) { diff --git a/sandbox/inject_test.go b/sandbox/inject_test.go index e043a64..f69d738 100644 --- a/sandbox/inject_test.go +++ b/sandbox/inject_test.go @@ -122,6 +122,67 @@ func TestResolveInjectExactPassthroughSkipsInjection(t *testing.T) { assert.False(t, set) } +// TestResolveInjectExplicitEnvValueSkipsInjection confirms --env VAR=value is +// an injection opt-out like exact passthrough: the user-supplied value reaches +// the sandbox instead of being clobbered by the placeholder. +func TestResolveInjectExplicitEnvValueSkipsInjection(t *testing.T) { + t.Setenv("ACTIVE_TOKEN", "host-secret") + plan := &SandboxPlan{ + TempDir: t.TempDir(), + EnvSet: map[string]string{"ACTIVE_TOKEN": "user-value"}, + explicitEnvVars: map[string]bool{"ACTIVE_TOKEN": true}, + AllowedDomains: []string{"api.example.com"}, + ProxyEnabled: true, + } + specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} + + require.NoError(t, resolveInject(plan, specs)) + assert.Empty(t, plan.InjectBindings) + assert.Nil(t, plan.CA) + assert.Equal(t, "user-value", plan.EnvSet["ACTIVE_TOKEN"]) +} + +// TestResolveInjectWithoutProxySuggestsPassthrough confirms the planning error +// for an active binding without the proxy (e.g. --unrestricted-net) names the +// variable and the --env escape hatch. +func TestResolveInjectWithoutProxySuggestsPassthrough(t *testing.T) { + t.Setenv("ACTIVE_TOKEN", "secret") + plan := &SandboxPlan{ + TempDir: t.TempDir(), + EnvSet: map[string]string{}, + } + specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} + + err := resolveInject(plan, specs) + require.Error(t, err) + assert.Contains(t, err.Error(), "ACTIVE_TOKEN") + assert.Contains(t, err.Error(), "--env ACTIVE_TOKEN") +} + +// TestCABundleBaseDirectoryFallsBack confirms a CA env var pointing at a +// directory (accepted by e.g. python-requests) does not abort the run: the +// system bundle is used as the base instead. +func TestCABundleBaseDirectoryFallsBack(t *testing.T) { + if systemCABundle() == "" { + t.Skip("system CA bundle unavailable") + } + t.Setenv("ACTIVE_TOKEN", "secret") + t.Setenv("REQUESTS_CA_BUNDLE", t.TempDir()) + plan := &SandboxPlan{ + TempDir: t.TempDir(), + EnvSet: map[string]string{}, + EnvPassthrough: []string{"REQUESTS_CA_BUNDLE"}, + AllowedDomains: []string{"api.example.com"}, + ProxyEnabled: true, + } + specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} + + require.NoError(t, resolveInject(plan, specs)) + data, err := os.ReadFile(plan.EnvSet["REQUESTS_CA_BUNDLE"]) + require.NoError(t, err) + assert.Contains(t, string(data), "CERTIFICATE") +} + func TestResolveInjectWildcardPassthroughStillInjects(t *testing.T) { if systemCABundle() == "" { t.Skip("system CA bundle unavailable") @@ -241,12 +302,12 @@ func TestWriteCABundle(t *testing.T) { if systemCABundle() == "" { // Without system roots, the bundle would override TLS trust with an // incomplete set, so injection fails rather than break other hosts. - _, err := writeCABundle(dir, "", caPEM) + _, err := writeCABundleFile(dir, "ca-bundle.pem", "", caPEM) require.Error(t, err) return } - path, err := writeCABundle(dir, "", caPEM) + path, err := writeCABundleFile(dir, "ca-bundle.pem", "", caPEM) require.NoError(t, err) assert.Equal(t, filepath.Join(dir, "ca-bundle.pem"), path) @@ -263,7 +324,7 @@ func TestWriteCABundle_ExtendsUserBase(t *testing.T) { base := filepath.Join(dir, "custom-roots.pem") require.NoError(t, os.WriteFile(base, []byte("-----BEGIN CERTIFICATE-----\nCUSTOMROOT\n-----END CERTIFICATE-----\n"), 0o644)) - path, err := writeCABundle(dir, base, caPEM) + path, err := writeCABundleFile(dir, "ca-bundle.pem", base, caPEM) require.NoError(t, err) data, err := os.ReadFile(path) diff --git a/sandbox/parent_darwin.go b/sandbox/parent_darwin.go index 84d29c7..5e23f01 100644 --- a/sandbox/parent_darwin.go +++ b/sandbox/parent_darwin.go @@ -35,7 +35,8 @@ func StartSandbox(plan *SandboxPlan) (int, error) { } defer ln.Close() - handler := buildProxyHandler(plan) + injector := buildInjector(plan) + handler := buildProxyHandler(plan, injector) proxySrv = &http.Server{Handler: handler} go func() { _ = proxySrv.Serve(ln) }() defer proxySrv.Close() @@ -47,7 +48,7 @@ func StartSandbox(plan *SandboxPlan) (int, error) { return -1, fmt.Errorf("socks5 listener: %w", socksErr) } defer socksLn.Close() - socksSrv := buildSOCKS5Server(plan) + socksSrv := buildSOCKS5Server(plan, injector) go func() { _ = socksSrv.Serve(socksLn) }() } } diff --git a/sandbox/parent_linux.go b/sandbox/parent_linux.go index 1916601..2370ae5 100644 --- a/sandbox/parent_linux.go +++ b/sandbox/parent_linux.go @@ -121,7 +121,8 @@ func StartSandbox(plan *SandboxPlan) (int, error) { // Start proxy if enabled. var proxySrv *http.Server if plan.ProxyEnabled { - handler := buildProxyHandler(plan) + injector := buildInjector(plan) + handler := buildProxyHandler(plan, injector) // ConnListeners fed by recvFDLoop (child relays accepted fds to parent). httpAddr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: plan.ProxyPort} @@ -135,7 +136,7 @@ func StartSandbox(plan *SandboxPlan) (int, error) { socksAddr := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: plan.SOCKSPort} socksCL = proxy.NewConnListener(socksAddr) res.push(func() { _ = socksCL.Close() }) - socksSrv := buildSOCKS5Server(plan) + socksSrv := buildSOCKS5Server(plan, injector) go func() { _ = socksSrv.Serve(socksCL) }() } diff --git a/sandbox/plan.go b/sandbox/plan.go index b0f7a83..21cfa87 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -100,6 +100,7 @@ type SandboxPlan struct { UserPaths []string // Paths explicitly requested by the user (--read/--write/--exec adds). EnvSet map[string]string EnvPassthrough []string + explicitEnvVars map[string]bool // Vars set via --env NAME=value (disables injection for them). DegradedLayers []DegradedLayer TempDir string SandboxHome string // Resolved HOME for the sandbox (used for tilde expansion and env fallback). @@ -602,14 +603,14 @@ func parseInjectHeader(entries []string) ([]injectSpec, error) { return specs, nil } -func activeInjectSpecs(specs []injectSpec, exactPassthrough func(string) bool) []injectSpec { +func activeInjectSpecs(specs []injectSpec, optOut func(string) bool) []injectSpec { active := make([]injectSpec, 0, len(specs)) for _, s := range specs { token, present := os.LookupEnv(s.envVar) if !present || token == "" { continue } - if exactPassthrough != nil && exactPassthrough(s.envVar) { + if optOut != nil && optOut(s.envVar) { continue } active = append(active, s) @@ -617,6 +618,14 @@ func activeInjectSpecs(specs []injectSpec, exactPassthrough func(string) bool) [ return active } +// injectOptOut reports whether the user explicitly provided name's sandbox +// value — exact passthrough (--env NAME) or an explicit value (--env +// NAME=value). Either is a trust decision that disables credential injection +// for that variable; wildcard passthrough does not. +func (p *SandboxPlan) injectOptOut(name string) bool { + return p.explicitEnvVars[name] || p.hasExactEnvPassthrough(name) +} + func (p *SandboxPlan) hasExactEnvPassthrough(name string) bool { if len(p.EnvPassthrough) > 0 && p.EnvPassthrough[0] == envPassthroughAll { return false @@ -624,7 +633,14 @@ func (p *SandboxPlan) hasExactEnvPassthrough(name string) bool { return slices.Contains(p.EnvPassthrough, name) } -func configHasExactEnvPassthrough(cfg *config.Config, name string) bool { +// configInjectOptOut is injectOptOut at the config level, for the degraded +// builder which checks bindings before env resolution. +func configInjectOptOut(cfg *config.Config, name string) bool { + for _, pair := range cfg.EnvSet { + if k, _, _ := strings.Cut(pair, "="); k == name { + return true + } + } if cfg.EnvPassthroughAll { return false } @@ -659,6 +675,15 @@ func authorizeInjectTarget(t policy.InjectTarget, domains *policy.DomainMatcher, return fmt.Errorf("credential injection host %q is not allowed; add --domains %s", t.Host, t.Host) } +// injectVarNames lists the source variables of specs for error messages. +func injectVarNames(specs []injectSpec) string { + names := make([]string, 0, len(specs)) + for _, s := range specs { + names = append(names, s.envVar) + } + return strings.Join(names, ", ") +} + // injectPlaceholder returns the sentinel the sandbox sees in place of a real // credential. It is constant per env var so a tool that approves a custom key // (e.g. Claude Code) approves it once. The value carries no secret weight: the @@ -683,10 +708,13 @@ func resolveInject(plan *SandboxPlan, specs []injectSpec) error { if len(specs) == 0 { return nil } - specs = activeInjectSpecs(specs, plan.hasExactEnvPassthrough) + specs = activeInjectSpecs(specs, plan.injectOptOut) if len(specs) == 0 { return nil } + if !plan.ProxyEnabled { + return fmt.Errorf("credential injection for %s requires the network proxy: allow the destination with --domains/--ips and do not use --unrestricted-net; or pass the credential into the sandbox instead (an explicit trust decision) with --env %s", injectVarNames(specs), specs[0].envVar) + } domainMatcher := policy.NewDomainMatcher(plan.AllowedDomains) ipMatcher := policy.NewIPMatcher(plan.AllowedIPs) bindings := make(map[policy.InjectTarget][]proxy.Injection, len(specs)) @@ -702,12 +730,6 @@ func resolveInject(plan *SandboxPlan, specs []injectSpec) error { } placeholders[s.envVar] = placeholder } - if len(bindings) == 0 { - return nil // nothing to inject (all source vars were absent) - } - if !plan.ProxyEnabled { - return fmt.Errorf("credential injection requires the network proxy; allow the destination with --domains/--ips and do not use --unrestricted-net") - } ca, err := proxy.NewCA() if err != nil { return fmt.Errorf("generating per-run CA: %w", err) @@ -737,13 +759,20 @@ func resolveInject(plan *SandboxPlan, specs []injectSpec) error { var caBundleEnvKeys = []string{"SSL_CERT_FILE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS"} func (p *SandboxPlan) caBundleBase(name string) string { - if base := p.EnvSet[name]; base != "" { - return base + base := p.EnvSet[name] + if base == "" { + base, _ = p.passthroughEnvValue(name) } - if base, _ := p.passthroughEnvValue(name); base != "" { - return base + if base == "" { + return "" } - return "" + // Some vars may legitimately point at a directory of certificates (e.g. + // REQUESTS_CA_BUNDLE), which cannot be extended by concatenation. + if fi, err := os.Stat(base); err == nil && fi.IsDir() { + clog.Warnf("%s points at a directory (%s), which cannot be extended with the per-run CA; using the system CA bundle as its base instead", name, base) + return "" + } + return base } func caBundleFilename(name string) string { @@ -753,17 +782,13 @@ func caBundleFilename(name string) string { return "ca-bundle-" + strings.ToLower(name) + ".pem" } -// writeCABundle writes a PEM bundle of the base roots plus the per-run CA to -// the temp dir and returns its path. Base is the trust store to extend; when -// empty it falls back to the system roots. +// writeCABundleFile writes a PEM bundle of the base roots plus the per-run CA +// to the temp dir and returns its path. Base is the trust store to extend; +// when empty it falls back to the system roots. // It fails if the base roots cannot be located or read: this bundle replaces // the sandbox's TLS trust (SSL_CERT_FILE etc.), so a bundle holding only the // per-run CA would break trust for every HTTPS destination other than the // injected hosts. -func writeCABundle(tmpDir, base string, caPEM []byte) (string, error) { - return writeCABundleFile(tmpDir, "ca-bundle.pem", base, caPEM) -} - func writeCABundleFile(tmpDir, filename, base string, caPEM []byte) (string, error) { if base == "" { base = systemCABundle() @@ -1123,6 +1148,7 @@ func applyEnvPolicy(plan *SandboxPlan, cfg *config.Config, tmpDir string) { delete(plan.EnvSet, r) } } + plan.explicitEnvVars = make(map[string]bool, len(cfg.EnvSet)) for _, pair := range cfg.EnvSet { k, v, _ := strings.Cut(pair, "=") // Expand $VAR references against the sandbox env built so far. @@ -1133,6 +1159,7 @@ func applyEnvPolicy(plan *SandboxPlan, cfg *config.Config, tmpDir string) { return "" }) plan.EnvSet[k] = v + plan.explicitEnvVars[k] = true } if cfg.EnvPassthroughAll { plan.EnvPassthrough = []string{envPassthroughAll} @@ -1492,10 +1519,10 @@ func buildDegradedPlan(cfg *config.Config, caps *Capabilities) (*SandboxPlan, er if err != nil { return nil, err } - if len(activeInjectSpecs(injects, func(name string) bool { - return configHasExactEnvPassthrough(cfg, name) - })) > 0 { - return nil, fmt.Errorf("credential injection (--inject-header) is only supported on Linux and macOS") + if active := activeInjectSpecs(injects, func(name string) bool { + return configInjectOptOut(cfg, name) + }); len(active) > 0 { + return nil, fmt.Errorf("credential injection (--inject-header) is only supported on Linux and macOS; or pass the credential into the sandbox instead (an explicit trust decision) with --env %s", active[0].envVar) } if len(cfg.AllowedDomains) > 0 { diff --git a/sandbox/proxy_handler.go b/sandbox/proxy_handler.go index e01bfae..ada53cf 100644 --- a/sandbox/proxy_handler.go +++ b/sandbox/proxy_handler.go @@ -35,15 +35,17 @@ func buildInjector(plan *SandboxPlan) *proxy.Injector { return inj } -// buildProxyHandler creates the HTTP proxy handler from the sandbox plan. -func buildProxyHandler(plan *SandboxPlan) *proxy.Handler { - return &proxy.Handler{FilterBase: buildFilterBase(plan), Injector: buildInjector(plan)} +// buildProxyHandler creates the HTTP proxy handler from the sandbox plan. The +// injector is shared with the SOCKS5 server so both egress paths use one +// upstream transport (one connection pool). +func buildProxyHandler(plan *SandboxPlan, injector *proxy.Injector) *proxy.Handler { + return &proxy.Handler{FilterBase: buildFilterBase(plan), Injector: injector} } // buildSOCKS5Server creates the SOCKS5 proxy server from the sandbox plan. -func buildSOCKS5Server(plan *SandboxPlan) *proxy.SOCKS5Server { +func buildSOCKS5Server(plan *SandboxPlan, injector *proxy.Injector) *proxy.SOCKS5Server { return &proxy.SOCKS5Server{ FilterBase: buildFilterBase(plan), - Injector: buildInjector(plan), + Injector: injector, } } From d890cf1ed1600cb59b326215a8fb2fb3a3f0d144 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Wed, 1 Jul 2026 23:49:16 +0100 Subject: [PATCH 19/27] refactor: apply cleanup review to credential injection - Keep the injection opt-out predicate at config level only: resolveInject now takes the config and parses bindings itself, removing the duplicated parse prelude from both platform builders and the plan-level predicate (explicitEnvVars field, hasExactEnvPassthrough), which also counted default passthrough vars as opt-outs contrary to the documented semantics. activeInjectSpecs resolves each token once. - Validate --inject-header in FromFlags, matching --domains and --ips. - Write one CA bundle per distinct base instead of one per env var: the common case read and wrote the same ~220 KB system bundle five times. - Add policy.CanonicalHost as the single host-canonicalization primitive, used by the target parser and the proxy binding lookup; drop the proxy-local wrapper and the derivable InjectTarget.IsIP field. - Extract shared helpers for logic duplicated in the diff: envPassesThrough (ResolveEnv, dry-run, CA-base lookup), injectDestinations (dry-run, skill file), proxyFilteringEnabled (capabilities, darwin builder), the --env error-hint suffix, and the caBundleEnvKeys list in the dry-run output. - Reuse ConnListener for the injected-connection server instead of a second single-connection listener type; share one discard logger. - Unexport policy.ValidEnvName (no external callers). Co-Authored-By: Claude Fable 5 --- config/config.go | 5 ++ config/config_test.go | 8 ++ policy/domain.go | 17 +++- policy/validate.go | 34 ++++---- policy/validate_test.go | 8 +- proxy/inject.go | 78 +++++++------------ sandbox/inject_test.go | 59 +++++++------- sandbox/plan.go | 166 ++++++++++++++++++++-------------------- sandbox/plan_darwin.go | 12 +-- sandbox/plan_linux.go | 9 +-- sandbox/skill.go | 7 +- 11 files changed, 193 insertions(+), 210 deletions(-) diff --git a/config/config.go b/config/config.go index 7ed0ea9..c6e0df2 100644 --- a/config/config.go +++ b/config/config.go @@ -83,6 +83,11 @@ func FromFlags(cmd *cobra.Command) (*Config, error) { return nil, err } } + for _, e := range injectHeader { + if _, _, err := policy.ParseInjectHeader(e); err != nil { + return nil, fmt.Errorf("--inject-header %w", err) + } + } hostLoopback, err := flags.GetBool("host-loopback") if err != nil { return nil, err diff --git a/config/config_test.go b/config/config_test.go index 6b678ec..4cadd71 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -352,6 +352,14 @@ func TestFromFlags_DomainIsIP(t *testing.T) { assert.Contains(t, err.Error(), "use --ips instead") } +func TestFromFlags_InvalidInjectHeader(t *testing.T) { + cmd := newTestCmd([]string{"--inject-header", "TOK:*.example.com"}) + _, err := FromFlags(cmd) + require.Error(t, err) + assert.Contains(t, err.Error(), "--inject-header") + assert.Contains(t, err.Error(), "exact hostname") +} + func TestMergeEnv_IPsAdditive(t *testing.T) { cmd := newTestCmd([]string{"--ips", "10.0.0.1"}) cfg, err := FromFlags(cmd) diff --git a/policy/domain.go b/policy/domain.go index e01dcee..67d86d4 100644 --- a/policy/domain.go +++ b/policy/domain.go @@ -1,6 +1,9 @@ package policy -import "strings" +import ( + "net/netip" + "strings" +) // DomainMatcher checks domain names against an allowlist with exact and // wildcard matching support. @@ -70,3 +73,15 @@ func (m *DomainMatcher) Match(domain string) bool { func NormalizeHost(s string) string { return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s)), ".") } + +// CanonicalHost returns the canonical binding form of a host: an IP literal in +// its canonical textual form, otherwise the normalized hostname. The +// injection-target parser and the proxy's binding lookup share it, so a +// binding key built at parse time always matches one built from a live +// connection's target. +func CanonicalHost(host string) string { + if addr, err := netip.ParseAddr(host); err == nil { + return addr.String() + } + return NormalizeHost(host) +} diff --git a/policy/validate.go b/policy/validate.go index ee6466a..6d2031b 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -72,11 +72,12 @@ func validateDomainPattern(d, subject string) error { // InjectTarget is one destination a credential is bound to: a hostname or IP // literal plus the TLS port (default 443). The proxy injects the credential // only for a connection matching both Host and Port, so the credential's -// destination is exact. +// destination is exact. Whether Host is an IP literal (authorized via --ips, +// not --domains) is unambiguous from its canonical form: a hostname can never +// parse as an IP. type InjectTarget struct { - Host string // normalized hostname, or canonical IP literal + Host string // canonical host (CanonicalHost form) Port string // numeric port, "443" by default - IsIP bool // Host is an IP literal (authorized via --ips, not --domains) } // ParseInjectHeader parses one credential-injection binding @@ -90,7 +91,7 @@ func ParseInjectHeader(entry string) (envVar string, targets []InjectTarget, err if !ok || envVar == "" || rest == "" { return "", nil, fmt.Errorf("must be ENV_VAR:HOST[,HOST...], got %q", entry) } - if !ValidEnvName(envVar) { + if !validEnvName(envVar) { return "", nil, fmt.Errorf("%q is not a valid environment variable name", envVar) } for item := range strings.SplitSeq(rest, ",") { @@ -114,8 +115,8 @@ func parseInjectTarget(item string) (InjectTarget, error) { } // 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 + if _, err := netip.ParseAddr(item); err == nil { + return InjectTarget{Host: CanonicalHost(item), Port: "443"}, nil } host, port := item, "443" if h, p, ok := splitInjectPort(item); ok { @@ -125,16 +126,15 @@ func parseInjectTarget(item string) (InjectTarget, error) { } host, port = h, canonical } - if addr, err := netip.ParseAddr(host); err == nil { - return InjectTarget{Host: addr.String(), Port: port, IsIP: true}, nil - } - if strings.Contains(host, "*") { - return InjectTarget{}, fmt.Errorf("injection host %q must be an exact hostname (no wildcards)", host) - } - if err := validateDomainPattern(host, "injection host"); err != nil { - return InjectTarget{}, err + if _, err := netip.ParseAddr(host); err != nil { + if strings.Contains(host, "*") { + return InjectTarget{}, fmt.Errorf("injection host %q must be an exact hostname (no wildcards)", host) + } + if err := validateDomainPattern(host, "injection host"); err != nil { + return InjectTarget{}, err + } } - return InjectTarget{Host: NormalizeHost(host), Port: port, IsIP: false}, nil + return InjectTarget{Host: CanonicalHost(host), Port: port}, nil } // splitInjectPort separates a custom port from a target, returning ok=false @@ -181,11 +181,11 @@ func canonicalPort(p string) (string, error) { return strconv.Itoa(n), nil } -// ValidEnvName reports whether name is a valid environment variable name (a C +// validEnvName reports whether name is a valid environment variable name (a C // identifier: a letter or underscore, then letters, digits, or underscores). // Credential-injection sources name the carrier var this way, so a typo is // caught at parse time instead of silently matching nothing. -func ValidEnvName(name string) bool { +func validEnvName(name string) bool { if name == "" { return false } diff --git a/policy/validate_test.go b/policy/validate_test.go index 61ba185..634fe8f 100644 --- a/policy/validate_test.go +++ b/policy/validate_test.go @@ -68,13 +68,13 @@ func TestParseInjectHeader(t *testing.T) { {"host list", "TOK:a.example.com,b.example.com:8443", "TOK", []InjectTarget{{Host: "a.example.com", Port: "443"}, {Host: "b.example.com", Port: "8443"}}, ""}, {"ipv4", "TOK:10.0.0.5", "TOK", - []InjectTarget{{Host: "10.0.0.5", Port: "443", IsIP: true}}, ""}, + []InjectTarget{{Host: "10.0.0.5", Port: "443"}}, ""}, {"ipv4 with port", "TOK:10.0.0.5:8443", "TOK", - []InjectTarget{{Host: "10.0.0.5", Port: "8443", IsIP: true}}, ""}, + []InjectTarget{{Host: "10.0.0.5", Port: "8443"}}, ""}, {"bare ipv6", "TOK:2001:db8::1", "TOK", - []InjectTarget{{Host: "2001:db8::1", Port: "443", IsIP: true}}, ""}, + []InjectTarget{{Host: "2001:db8::1", Port: "443"}}, ""}, {"bracketed ipv6 with port", "TOK:[2001:db8::1]:8443", "TOK", - []InjectTarget{{Host: "2001:db8::1", Port: "8443", IsIP: true}}, ""}, + []InjectTarget{{Host: "2001:db8::1", Port: "8443"}}, ""}, // A port with leading zeros is canonicalized, or the binding key would // never match a real connection's port. {"leading-zero port", "TOK:api.example.com:0443", "TOK", diff --git a/proxy/inject.go b/proxy/inject.go index c91b510..15e9f77 100644 --- a/proxy/inject.go +++ b/proxy/inject.go @@ -182,9 +182,11 @@ func NewInjector(ca *CA) *Injector { } // Bind adds a header injection for a destination host:port. Multiple headers -// may be bound to the same target. +// may be bound to the same target. Keys use policy.CanonicalHost so the +// binding side and the lookup for a live connection agree, including when a +// client addresses an IP directly. func (in *Injector) Bind(host, port string, inj Injection) { - key := net.JoinHostPort(normalizeHost(host), port) + key := net.JoinHostPort(policy.CanonicalHost(host), port) in.byTarget[key] = append(in.byTarget[key], inj) } @@ -193,20 +195,10 @@ func (in *Injector) binding(host, port string) ([]Injection, bool) { if in == nil { return nil, false // nil-safe so callers need no separate guard } - injs, ok := in.byTarget[net.JoinHostPort(normalizeHost(host), port)] + injs, ok := in.byTarget[net.JoinHostPort(policy.CanonicalHost(host), port)] return injs, ok } -// normalizeHost canonicalizes a host for binding keys: an IP literal to its -// canonical form, otherwise a lowercased hostname. The binding side and the -// proxy lookup must agree, including when a client addresses an IP directly. -func normalizeHost(host string) string { - if addr, err := netip.ParseAddr(host); err == nil { - return addr.String() - } - return policy.NormalizeHost(host) -} - // injectCONNECT terminates the client's TLS with a per-run leaf for host, // then forwards each decrypted request to the real upstream with the bound // credential injected. @@ -239,10 +231,10 @@ func (h *Handler) injectCONNECT(w http.ResponseWriter, host, port string, injs [ // SOCKS5 egress routes; the caller has already accepted the connection (written // the CONNECT 200 or the SOCKS5 success reply). func (in *Injector) Serve(client net.Conn, host, port string, injs []Injection) error { - // The binding matched on a normalized host (case-folded, no trailing dot, + // The binding matched on a canonical host (case-folded, no trailing dot, // canonical IP); mint the leaf and route upstream on the same form so the // cert, SNI, and Host header all agree. - host = normalizeHost(host) + host = policy.CanonicalHost(host) leaf, err := in.CA.leafFor(host) if err != nil { return fmt.Errorf("leaf: %w", err) @@ -291,55 +283,37 @@ func (in *Injector) serveInjected(client net.Conn, host, port string, injs []Inj // Flush each write immediately: API responses are often streamed (SSE) // and the old direct relay never buffered. FlushInterval: -1, - ErrorLog: log.New(io.Discard, "", 0), + ErrorLog: discardLog, } - done := make(chan struct{}) - conn := &signalOnCloseConn{Conn: client, done: done} - srv := &http.Server{Handler: rp, ErrorLog: log.New(io.Discard, "", 0)} - // Serve returns when the connection closes: the listener yields the one - // connection, then blocks until it is closed — by the server loop after the - // final request, or by the ReverseProxy at the end of an Upgrade relay. - _ = srv.Serve(&singleConnListener{conn: conn, addr: client.LocalAddr(), done: done}) + // Serve returns when the connection closes: the listener holds only this + // one connection, and closing the connection closes the listener — by the + // server loop after the final request, or by the ReverseProxy at the end + // of an Upgrade relay. + cl := NewConnListener(client.LocalAddr()) + _ = cl.Enqueue(&signalOnCloseConn{Conn: client, onClose: func() { _ = cl.Close() }}) + srv := &http.Server{Handler: rp, ErrorLog: discardLog} + _ = srv.Serve(cl) } -// signalOnCloseConn closes done when the connection is closed, whichever code -// path closes it. +// discardLog silences per-request logging from the injection server and +// reverse proxy; errors surface to the client as 502s, matching the +// passthrough paths. +var discardLog = log.New(io.Discard, "", 0) + +// signalOnCloseConn runs onClose once when the connection is closed, whichever +// code path closes it. type signalOnCloseConn struct { net.Conn - once sync.Once - done chan struct{} + once sync.Once + onClose func() } func (c *signalOnCloseConn) Close() error { err := c.Conn.Close() - c.once.Do(func() { close(c.done) }) + c.once.Do(c.onClose) return err } -// singleConnListener yields one already-accepted connection, then blocks until -// that connection is closed before reporting the listener as closed. -type singleConnListener struct { - mu sync.Mutex - conn net.Conn - addr net.Addr - done <-chan struct{} -} - -func (l *singleConnListener) Accept() (net.Conn, error) { - l.mu.Lock() - c := l.conn - l.conn = nil - l.mu.Unlock() - if c != nil { - return c, nil - } - <-l.done - return nil, net.ErrClosed -} - -func (l *singleConnListener) Close() error { return nil } -func (l *singleConnListener) Addr() net.Addr { return l.addr } - func injectedAuthority(host, port string) string { if port != "443" { return net.JoinHostPort(host, port) diff --git a/sandbox/inject_test.go b/sandbox/inject_test.go index f69d738..9d8ee49 100644 --- a/sandbox/inject_test.go +++ b/sandbox/inject_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/upsun/curb/config" "github.com/upsun/curb/policy" ) @@ -56,15 +57,19 @@ func TestInjectPlaceholder(t *testing.T) { assert.False(t, strings.HasPrefix(tok2, tok), "%q must not be a prefix of %q", tok, tok2) } +// injectCfg builds a config with one binding entry, for resolveInject tests. +func injectCfg(entries ...string) *config.Config { + return &config.Config{InjectHeader: entries} +} + func TestResolveInjectSkippedDoesNotRequireAllowedDomain(t *testing.T) { t.Setenv("SKIPPED_TOKEN", "") plan := &SandboxPlan{ TempDir: t.TempDir(), EnvSet: map[string]string{}, } - specs := []injectSpec{{envVar: "SKIPPED_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} - require.NoError(t, resolveInject(plan, specs)) + require.NoError(t, resolveInject(plan, injectCfg("SKIPPED_TOKEN:api.example.com"))) assert.Empty(t, plan.AllowedDomains) assert.Empty(t, plan.InjectBindings) assert.Nil(t, plan.CA) @@ -77,9 +82,8 @@ func TestResolveInjectRequiresAllowedDomain(t *testing.T) { EnvSet: map[string]string{}, ProxyEnabled: true, } - specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} - err := resolveInject(plan, specs) + err := resolveInject(plan, injectCfg("ACTIVE_TOKEN:api.example.com")) require.Error(t, err) assert.Contains(t, err.Error(), `credential injection host "api.example.com" is not allowed`) assert.Nil(t, plan.CA) @@ -97,9 +101,8 @@ func TestResolveInjectAllowsWildcardDomain(t *testing.T) { AllowedDomains: []string{"*.example.com"}, ProxyEnabled: true, } - specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} - require.NoError(t, resolveInject(plan, specs)) + require.NoError(t, resolveInject(plan, injectCfg("ACTIVE_TOKEN:api.example.com"))) assert.NotNil(t, plan.CA) assert.Contains(t, plan.InjectBindings, policy.InjectTarget{Host: "api.example.com", Port: "443"}) assert.Equal(t, injectPlaceholder("ACTIVE_TOKEN"), plan.EnvSet["ACTIVE_TOKEN"]) @@ -113,9 +116,10 @@ func TestResolveInjectExactPassthroughSkipsInjection(t *testing.T) { EnvPassthrough: []string{"ACTIVE_TOKEN"}, ProxyEnabled: true, } - specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} + cfg := injectCfg("ACTIVE_TOKEN:api.example.com") + cfg.EnvPassthrough = []string{"ACTIVE_TOKEN"} - require.NoError(t, resolveInject(plan, specs)) + require.NoError(t, resolveInject(plan, cfg)) assert.Empty(t, plan.InjectBindings) assert.Nil(t, plan.CA) _, set := plan.EnvSet["ACTIVE_TOKEN"] @@ -128,15 +132,15 @@ func TestResolveInjectExactPassthroughSkipsInjection(t *testing.T) { func TestResolveInjectExplicitEnvValueSkipsInjection(t *testing.T) { t.Setenv("ACTIVE_TOKEN", "host-secret") plan := &SandboxPlan{ - TempDir: t.TempDir(), - EnvSet: map[string]string{"ACTIVE_TOKEN": "user-value"}, - explicitEnvVars: map[string]bool{"ACTIVE_TOKEN": true}, - AllowedDomains: []string{"api.example.com"}, - ProxyEnabled: true, + TempDir: t.TempDir(), + EnvSet: map[string]string{"ACTIVE_TOKEN": "user-value"}, + AllowedDomains: []string{"api.example.com"}, + ProxyEnabled: true, } - specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} + cfg := injectCfg("ACTIVE_TOKEN:api.example.com") + cfg.EnvSet = []string{"ACTIVE_TOKEN=user-value"} - require.NoError(t, resolveInject(plan, specs)) + require.NoError(t, resolveInject(plan, cfg)) assert.Empty(t, plan.InjectBindings) assert.Nil(t, plan.CA) assert.Equal(t, "user-value", plan.EnvSet["ACTIVE_TOKEN"]) @@ -151,9 +155,8 @@ func TestResolveInjectWithoutProxySuggestsPassthrough(t *testing.T) { TempDir: t.TempDir(), EnvSet: map[string]string{}, } - specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} - err := resolveInject(plan, specs) + err := resolveInject(plan, injectCfg("ACTIVE_TOKEN:api.example.com")) require.Error(t, err) assert.Contains(t, err.Error(), "ACTIVE_TOKEN") assert.Contains(t, err.Error(), "--env ACTIVE_TOKEN") @@ -175,9 +178,8 @@ func TestCABundleBaseDirectoryFallsBack(t *testing.T) { AllowedDomains: []string{"api.example.com"}, ProxyEnabled: true, } - specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} - require.NoError(t, resolveInject(plan, specs)) + require.NoError(t, resolveInject(plan, injectCfg("ACTIVE_TOKEN:api.example.com"))) data, err := os.ReadFile(plan.EnvSet["REQUESTS_CA_BUNDLE"]) require.NoError(t, err) assert.Contains(t, string(data), "CERTIFICATE") @@ -195,9 +197,10 @@ func TestResolveInjectWildcardPassthroughStillInjects(t *testing.T) { AllowedDomains: []string{"api.example.com"}, ProxyEnabled: true, } - specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} + cfg := injectCfg("ACTIVE_TOKEN:api.example.com") + cfg.EnvPassthrough = []string{"ACTIVE_*"} - require.NoError(t, resolveInject(plan, specs)) + require.NoError(t, resolveInject(plan, cfg)) assert.Contains(t, plan.InjectBindings, policy.InjectTarget{Host: "api.example.com", Port: "443"}) assert.Equal(t, injectPlaceholder("ACTIVE_TOKEN"), plan.EnvSet["ACTIVE_TOKEN"]) } @@ -224,9 +227,8 @@ func TestResolveInjectExtendsPassthroughSSL_CERT_FILE(t *testing.T) { AllowedDomains: []string{"api.example.com"}, ProxyEnabled: true, } - specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} - require.NoError(t, resolveInject(plan, specs)) + require.NoError(t, resolveInject(plan, injectCfg("ACTIVE_TOKEN:api.example.com"))) bundle := plan.EnvSet["SSL_CERT_FILE"] data, err := os.ReadFile(bundle) require.NoError(t, err) @@ -251,9 +253,8 @@ func TestResolveInjectExtendsEachExistingCABundleEnv(t *testing.T) { AllowedDomains: []string{"api.example.com"}, ProxyEnabled: true, } - specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "api.example.com", Port: "443"}}}} - require.NoError(t, resolveInject(plan, specs)) + require.NoError(t, resolveInject(plan, injectCfg("ACTIVE_TOKEN:api.example.com"))) curlData, err := os.ReadFile(plan.EnvSet["CURL_CA_BUNDLE"]) require.NoError(t, err) assert.Contains(t, string(curlData), "CURLROOT") @@ -278,8 +279,8 @@ func TestResolveInjectIPTarget(t *testing.T) { EnvSet: map[string]string{}, ProxyEnabled: true, } - specs := []injectSpec{{envVar: "ACTIVE_TOKEN", targets: []policy.InjectTarget{{Host: "10.0.0.5", Port: "8443", IsIP: true}}}} - err := resolveInject(notAllowed, specs) + cfg := injectCfg("ACTIVE_TOKEN:10.0.0.5:8443") + err := resolveInject(notAllowed, cfg) require.Error(t, err) assert.Contains(t, err.Error(), `credential injection IP "10.0.0.5" is not allowed`) @@ -290,9 +291,9 @@ func TestResolveInjectIPTarget(t *testing.T) { AllowedIPs: []string{"10.0.0.0/24"}, ProxyEnabled: true, } - require.NoError(t, resolveInject(plan, specs)) + require.NoError(t, resolveInject(plan, cfg)) assert.NotNil(t, plan.CA) - assert.Contains(t, plan.InjectBindings, policy.InjectTarget{Host: "10.0.0.5", Port: "8443", IsIP: true}) + assert.Contains(t, plan.InjectBindings, policy.InjectTarget{Host: "10.0.0.5", Port: "8443"}) } func TestWriteCABundle(t *testing.T) { diff --git a/sandbox/plan.go b/sandbox/plan.go index 21cfa87..e58458b 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -100,7 +100,6 @@ type SandboxPlan struct { UserPaths []string // Paths explicitly requested by the user (--read/--write/--exec adds). EnvSet map[string]string EnvPassthrough []string - explicitEnvVars map[string]bool // Vars set via --env NAME=value (disables injection for them). DegradedLayers []DegradedLayer TempDir string SandboxHome string // Resolved HOME for the sandbox (used for tilde expansion and env fallback). @@ -193,9 +192,15 @@ func BuildPlan(cfg *config.Config, caps *Capabilities, logger *clog.Logger) (*Sa return newPlanBuilder().BuildPlan(cfg, caps, logger) } +// proxyFilteringEnabled reports whether the run filters network egress through +// the proxy. Shared by resolveCapabilities and the darwin plan builder. +func proxyFilteringEnabled(cfg *config.Config) bool { + return (len(cfg.AllowedDomains) > 0 || len(cfg.AllowedIPs) > 0 || cfg.HostLoopback) && !cfg.UnrestrictedNet +} + // resolveCapabilities validates system capabilities and selects enforcement layers. func resolveCapabilities(plan *SandboxPlan, cfg *config.Config, caps *Capabilities) error { - hasFiltering := (len(cfg.AllowedDomains) > 0 || len(cfg.AllowedIPs) > 0 || cfg.HostLoopback) && !cfg.UnrestrictedNet + hasFiltering := proxyFilteringEnabled(cfg) if caps.UserNS != nil { aaHint := "" @@ -588,6 +593,7 @@ func appendUniq(s []string, v string) []string { type injectSpec struct { envVar string targets []policy.InjectTarget + token string // real host-env value, resolved by activeInjectSpecs } // parseInjectHeader parses --inject-header "ENV_VAR:HOST[,HOST...]" entries. @@ -603,16 +609,19 @@ func parseInjectHeader(entries []string) ([]injectSpec, error) { return specs, nil } -func activeInjectSpecs(specs []injectSpec, optOut func(string) bool) []injectSpec { +// activeInjectSpecs filters specs to those whose source var is set, non-empty, +// and not opted out, resolving each spec's token on the way. +func activeInjectSpecs(specs []injectSpec, cfg *config.Config) []injectSpec { active := make([]injectSpec, 0, len(specs)) for _, s := range specs { token, present := os.LookupEnv(s.envVar) if !present || token == "" { continue } - if optOut != nil && optOut(s.envVar) { + if injectOptOut(cfg, s.envVar) { continue } + s.token = token active = append(active, s) } return active @@ -622,20 +631,7 @@ func activeInjectSpecs(specs []injectSpec, optOut func(string) bool) []injectSpe // value — exact passthrough (--env NAME) or an explicit value (--env // NAME=value). Either is a trust decision that disables credential injection // for that variable; wildcard passthrough does not. -func (p *SandboxPlan) injectOptOut(name string) bool { - return p.explicitEnvVars[name] || p.hasExactEnvPassthrough(name) -} - -func (p *SandboxPlan) hasExactEnvPassthrough(name string) bool { - if len(p.EnvPassthrough) > 0 && p.EnvPassthrough[0] == envPassthroughAll { - return false - } - return slices.Contains(p.EnvPassthrough, name) -} - -// configInjectOptOut is injectOptOut at the config level, for the degraded -// builder which checks bindings before env resolution. -func configInjectOptOut(cfg *config.Config, name string) bool { +func injectOptOut(cfg *config.Config, name string) bool { for _, pair := range cfg.EnvSet { if k, _, _ := strings.Cut(pair, "="); k == name { return true @@ -648,6 +644,10 @@ func configInjectOptOut(cfg *config.Config, name string) bool { return slices.Contains(adds, name) } +// injectEnvHint is the shared error suffix suggesting the injection-free +// alternative; takes the variable name as a format argument. +const injectEnvHint = "; or pass the credential into the sandbox instead (an explicit trust decision) with --env %s" + // displayInjectTarget formats an injection target for human output, dropping // the default :443 so the common case reads as a bare host. func displayInjectTarget(t policy.InjectTarget) string { @@ -657,14 +657,24 @@ func displayInjectTarget(t policy.InjectTarget) string { return net.JoinHostPort(t.Host, t.Port) } +// injectDestinations returns the bound destinations in display form, sorted. +// Shared by the dry-run output and the skill file. +func (p *SandboxPlan) injectDestinations() []string { + dests := make([]string, 0, len(p.InjectBindings)) + for t := range p.InjectBindings { + dests = append(dests, displayInjectTarget(t)) + } + sort.Strings(dests) + return dests +} + // authorizeInjectTarget checks that an injection target is reachable under the // network policy: an IP target against --ips, a hostname target against // --domains. A credential must never be provisioned for a destination the // sandbox cannot otherwise reach. func authorizeInjectTarget(t policy.InjectTarget, domains *policy.DomainMatcher, ips *policy.IPMatcher) error { - if t.IsIP { - addr, err := netip.ParseAddr(t.Host) - if err == nil && ips.Match(addr) { + if addr, err := netip.ParseAddr(t.Host); err == nil { + if ips.Match(addr) { return nil } return fmt.Errorf("credential injection IP %q is not allowed; add --ips %s", t.Host, t.Host) @@ -696,37 +706,38 @@ func injectPlaceholder(envVar string) string { return "curb-inject-" + envVar + "-placeholder" } -// resolveInject generates the per-run CA, resolves each bound token, and -// delivers the CA to the sandbox trust store. It runs after proxy and env -// resolution. The CA key and tokens stay in the parent; only the public CA -// (in a combined bundle) and the placeholder-free env reach the child. +// resolveInject parses the configured bindings, generates the per-run CA, +// resolves each bound token, and delivers the CA to the sandbox trust store. +// It runs after proxy and env resolution. The CA key and tokens stay in the +// parent; only the public CA (in a combined bundle) and the placeholder-free +// env reach the child. // // A source var that is unset or empty is skipped silently: injection is opt-in // per credential, and the common case (e.g. the claude profile for an OAuth // user) is that the key is simply not present. -func resolveInject(plan *SandboxPlan, specs []injectSpec) error { - if len(specs) == 0 { - return nil +func resolveInject(plan *SandboxPlan, cfg *config.Config) error { + specs, err := parseInjectHeader(cfg.InjectHeader) + if err != nil { + return err } - specs = activeInjectSpecs(specs, plan.injectOptOut) + specs = activeInjectSpecs(specs, cfg) if len(specs) == 0 { return nil } if !plan.ProxyEnabled { - return fmt.Errorf("credential injection for %s requires the network proxy: allow the destination with --domains/--ips and do not use --unrestricted-net; or pass the credential into the sandbox instead (an explicit trust decision) with --env %s", injectVarNames(specs), specs[0].envVar) + return fmt.Errorf("credential injection for %s requires the network proxy: allow the destination with --domains/--ips and do not use --unrestricted-net"+injectEnvHint, injectVarNames(specs), specs[0].envVar) } domainMatcher := policy.NewDomainMatcher(plan.AllowedDomains) ipMatcher := policy.NewIPMatcher(plan.AllowedIPs) bindings := make(map[policy.InjectTarget][]proxy.Injection, len(specs)) placeholders := make(map[string]string) for _, s := range specs { - token, _ := os.LookupEnv(s.envVar) placeholder := injectPlaceholder(s.envVar) for _, t := range s.targets { if err := authorizeInjectTarget(t, domainMatcher, ipMatcher); err != nil { return err } - bindings[t] = append(bindings[t], proxy.Injection{Placeholder: placeholder, Value: token}) + bindings[t] = append(bindings[t], proxy.Injection{Placeholder: placeholder, Value: s.token}) } placeholders[s.envVar] = placeholder } @@ -744,13 +755,21 @@ func resolveInject(plan *SandboxPlan, specs []injectSpec) error { // Trust-store delivery: each standard CA env var receives a bundle that // extends its existing value, or system roots when unset. The CA validates - // only inside this run, so it is not sensitive to the action. + // only inside this run, so it is not sensitive to the action. Vars sharing + // a base (usually all of them, unset and falling back to system roots) + // share one written bundle. + bundles := map[string]string{} // base -> written bundle path for _, k := range caBundleEnvKeys { - bundle, err := writeCABundleFile(plan.TempDir, caBundleFilename(k), plan.caBundleBase(k), ca.CertPEM()) - if err != nil { - return err + base := plan.caBundleBase(k) + bundle, ok := bundles[base] + if !ok { + bundle, err = writeCABundleFile(plan.TempDir, caBundleFilename(k), base, ca.CertPEM()) + if err != nil { + return err + } + bundles[base] = bundle + plan.ROFiles = appendUniq(plan.ROFiles, bundle) } - plan.ROFiles = appendUniq(plan.ROFiles, bundle) plan.EnvSet[k] = bundle } return nil @@ -812,18 +831,29 @@ func writeCABundleFile(tmpDir, filename, base string, caPEM []byte) (string, err } func (p *SandboxPlan) passthroughEnvValue(name string) (string, bool) { - if isInternalEnvVar(name) { + if !p.envPassesThrough(name) { return "", false } + return os.LookupEnv(name) +} + +// envPassesThrough reports whether the passthrough policy admits name. The +// single matching rule (internal-var guard, '*' sentinel, glob patterns) is +// shared by ResolveEnv, the dry-run output, and the CA-bundle base lookup so +// they cannot drift. +func (p *SandboxPlan) envPassesThrough(name string) bool { + if isInternalEnvVar(name) { + return false + } if len(p.EnvPassthrough) > 0 && p.EnvPassthrough[0] == envPassthroughAll { - return os.LookupEnv(name) + return true } for _, pat := range p.EnvPassthrough { if matched, _ := filepath.Match(pat, name); matched { - return os.LookupEnv(name) + return true } } - return "", false + return false } // systemCABundle returns the first system CA bundle found, or "". @@ -918,31 +948,13 @@ func isInternalEnvVar(name string) bool { func (p *SandboxPlan) ResolveEnv() []string { env := make(map[string]string, len(p.EnvSet)) maps.Copy(env, p.EnvSet) - if len(p.EnvPassthrough) > 0 && p.EnvPassthrough[0] == envPassthroughAll { - for _, e := range os.Environ() { - k, v, _ := strings.Cut(e, "=") - if isInternalEnvVar(k) { - continue - } - if _, ok := env[k]; !ok { - env[k] = v - } + for _, e := range os.Environ() { + k, v, _ := strings.Cut(e, "=") + if _, set := env[k]; set { + continue } - } else { - for _, e := range os.Environ() { - k, v, _ := strings.Cut(e, "=") - if isInternalEnvVar(k) { - continue - } - if _, set := env[k]; set { - continue - } - for _, pat := range p.EnvPassthrough { - if matched, _ := filepath.Match(pat, k); matched { - env[k] = v - break - } - } + if p.envPassesThrough(k) { + env[k] = v } } // HOME fallback: use the pre-resolved SandboxHome so tilde expansion @@ -1045,16 +1057,11 @@ func (p *SandboxPlan) PrintDryRun(w io.Writer) { } ln(" blocked: everything else") if len(p.InjectBindings) > 0 { - dests := make([]string, 0, len(p.InjectBindings)) - for t := range p.InjectBindings { - dests = append(dests, displayInjectTarget(t)) - } - sort.Strings(dests) ln(" inject: TLS terminated; the proxy replaces a placeholder with the real credential in request headers (the real token never enters the sandbox)") - for _, d := range dests { + for _, d := range p.injectDestinations() { pr(" %s\n", d) } - ln(" ca-trust: per-run CA bundle in env (SSL_CERT_FILE, CURL_CA_BUNDLE, GIT_SSL_CAINFO, REQUESTS_CA_BUNDLE, NODE_EXTRA_CA_CERTS)") + pr(" ca-trust: per-run CA bundle in env (%s)\n", strings.Join(caBundleEnvKeys, ", ")) } } @@ -1076,11 +1083,8 @@ func (p *SandboxPlan) PrintDryRun(w io.Writer) { var resolved []string for _, e := range os.Environ() { k, _, _ := strings.Cut(e, "=") - for _, pat := range p.EnvPassthrough { - if matched, _ := filepath.Match(pat, k); matched { - resolved = append(resolved, k) - break - } + if p.envPassesThrough(k) { + resolved = append(resolved, k) } } sort.Strings(resolved) @@ -1148,7 +1152,6 @@ func applyEnvPolicy(plan *SandboxPlan, cfg *config.Config, tmpDir string) { delete(plan.EnvSet, r) } } - plan.explicitEnvVars = make(map[string]bool, len(cfg.EnvSet)) for _, pair := range cfg.EnvSet { k, v, _ := strings.Cut(pair, "=") // Expand $VAR references against the sandbox env built so far. @@ -1159,7 +1162,6 @@ func applyEnvPolicy(plan *SandboxPlan, cfg *config.Config, tmpDir string) { return "" }) plan.EnvSet[k] = v - plan.explicitEnvVars[k] = true } if cfg.EnvPassthroughAll { plan.EnvPassthrough = []string{envPassthroughAll} @@ -1519,10 +1521,8 @@ func buildDegradedPlan(cfg *config.Config, caps *Capabilities) (*SandboxPlan, er if err != nil { return nil, err } - if active := activeInjectSpecs(injects, func(name string) bool { - return configInjectOptOut(cfg, name) - }); len(active) > 0 { - return nil, fmt.Errorf("credential injection (--inject-header) is only supported on Linux and macOS; or pass the credential into the sandbox instead (an explicit trust decision) with --env %s", active[0].envVar) + if active := activeInjectSpecs(injects, cfg); len(active) > 0 { + return nil, fmt.Errorf("credential injection (--inject-header) is only supported on Linux and macOS"+injectEnvHint, active[0].envVar) } if len(cfg.AllowedDomains) > 0 { diff --git a/sandbox/plan_darwin.go b/sandbox/plan_darwin.go index 6e1bbe0..b758ddc 100644 --- a/sandbox/plan_darwin.go +++ b/sandbox/plan_darwin.go @@ -25,13 +25,6 @@ func (darwinPlanBuilder) BuildPlan(cfg *config.Config, caps *Capabilities, logge return nil, fmt.Errorf("fatal: sandbox-exec is required on macOS: %w", caps.Seatbelt) } - // Parse credential-injection bindings early so invalid config fails before - // other plan work. Active bindings are resolved after network and env setup. - injects, err := parseInjectHeader(cfg.InjectHeader) - if err != nil { - return nil, err - } - plan := &SandboxPlan{Caps: caps, Quiet: cfg.Quiet, Logger: logger} var removals planRemovals @@ -53,8 +46,7 @@ func (darwinPlanBuilder) BuildPlan(cfg *config.Config, caps *Capabilities, logge } warnHostHomePathMismatch(cfg, sandboxHome, hostHome) - hasFiltering := (len(cfg.AllowedDomains) > 0 || len(cfg.AllowedIPs) > 0 || cfg.HostLoopback) && !cfg.UnrestrictedNet - plan.ProxyEnabled = hasFiltering + plan.ProxyEnabled = proxyFilteringEnabled(cfg) // Seatbelt enforcement. plan.UseSeatbelt = true @@ -73,7 +65,7 @@ func (darwinPlanBuilder) BuildPlan(cfg *config.Config, caps *Capabilities, logge if err := resolveEnv(plan, cfg); err != nil { return nil, err } - if err := resolveInject(plan, injects); err != nil { + if err := resolveInject(plan, cfg); err != nil { return nil, err } resolveDenials(plan, &removals) diff --git a/sandbox/plan_linux.go b/sandbox/plan_linux.go index d25f145..1f454ce 100644 --- a/sandbox/plan_linux.go +++ b/sandbox/plan_linux.go @@ -17,13 +17,6 @@ func (linuxPlanBuilder) BuildPlan(cfg *config.Config, caps *Capabilities, logger plan := &SandboxPlan{Caps: caps, Quiet: cfg.Quiet, Logger: logger} var removals planRemovals - // Parse credential-injection bindings early so invalid config fails before - // other plan work. Active bindings are resolved after network and env setup. - injects, err := parseInjectHeader(cfg.InjectHeader) - if err != nil { - return nil, err - } - // Create tmpDir first — needed for sandbox HOME fallback. tmpDir, err := createTempDir() if err != nil { @@ -58,7 +51,7 @@ func (linuxPlanBuilder) BuildPlan(cfg *config.Config, caps *Capabilities, logger if err := resolveEnv(plan, cfg); err != nil { return nil, err } - if err := resolveInject(plan, injects); err != nil { + if err := resolveInject(plan, cfg); err != nil { return nil, err } resolveDenials(plan, &removals) diff --git a/sandbox/skill.go b/sandbox/skill.go index 196adbb..3b3da43 100644 --- a/sandbox/skill.go +++ b/sandbox/skill.go @@ -69,12 +69,7 @@ description: Active sandbox constraints for this environment. Check when encount b.WriteString("- Localhost: sandbox-internal\n") } if len(plan.InjectBindings) > 0 { - dests := make([]string, 0, len(plan.InjectBindings)) - for t := range plan.InjectBindings { - dests = append(dests, displayInjectTarget(t)) - } - sort.Strings(dests) - fmt.Fprintf(&b, "- Credential injection: requests to %s have their credential substituted in transit. The relevant environment variable holds a placeholder, so use it normally; the real value is never present in this sandbox, so do not expect or look for one.\n", strings.Join(dests, ", ")) + fmt.Fprintf(&b, "- Credential injection: requests to %s have their credential substituted in transit. The relevant environment variable holds a placeholder, so use it normally; the real value is never present in this sandbox, so do not expect or look for one.\n", strings.Join(plan.injectDestinations(), ", ")) } if !plan.ProxyEnabled && len(plan.AllowedDomains) == 0 && len(plan.AllowedIPs) == 0 { b.WriteString("- Allowed: none\n") From 12c8152e0dce10c21385ce1888d2a286dd13e421 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Wed, 1 Jul 2026 23:53:17 +0100 Subject: [PATCH 20/27] fix: address review comments on CA base precedence and bracketed IPv6 - caBundleBase: an explicitly set CA var wins over passthrough, matching ResolveEnv precedence. Previously --env SSL_CERT_FILE= (explicit empty) fell through to the host value under wildcard passthrough; it now means system roots. - parseInjectTarget: accept a bracketed IPv6 literal without a port ("[2001:db8::1]"). Reject brackets in hostnames so a non-IP bracketed value fails with a character error instead of passing as a hostname. Co-Authored-By: Claude Fable 5 --- policy/validate.go | 12 ++++++++++-- policy/validate_test.go | 3 +++ sandbox/inject_test.go | 12 ++++++++++++ sandbox/plan.go | 7 +++++-- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/policy/validate.go b/policy/validate.go index 6d2031b..e8c28d4 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -50,7 +50,7 @@ func validateDomainPattern(d, subject string) error { return fmt.Errorf("%s %q contains invalid character (whitespace or control)", subject, d) } switch r { - case '/', '\\', ':', '@', '#', '?', '=': + case '/', '\\', ':', '@', '#', '?', '=', '[', ']': return fmt.Errorf("%s %q contains invalid character %q", subject, d, string(r)) } } @@ -114,10 +114,18 @@ func parseInjectTarget(item string) (InjectTarget, error) { return InjectTarget{}, fmt.Errorf("empty injection target") } // A bare IP literal (no port): ParseAddr accepts an unbracketed IPv6 - // address, which SplitHostPort below would reject. + // address, which SplitHostPort below would reject. Brackets without a + // port ("[2001:db8::1]") are legal address syntax too. if _, err := netip.ParseAddr(item); err == nil { return InjectTarget{Host: CanonicalHost(item), Port: "443"}, nil } + if inner, ok := strings.CutPrefix(item, "["); ok { + if inner, ok := strings.CutSuffix(inner, "]"); ok { + if _, err := netip.ParseAddr(inner); err == nil { + return InjectTarget{Host: CanonicalHost(inner), Port: "443"}, nil + } + } + } host, port := item, "443" if h, p, ok := splitInjectPort(item); ok { canonical, err := canonicalPort(p) diff --git a/policy/validate_test.go b/policy/validate_test.go index 634fe8f..935a353 100644 --- a/policy/validate_test.go +++ b/policy/validate_test.go @@ -75,6 +75,9 @@ func TestParseInjectHeader(t *testing.T) { []InjectTarget{{Host: "2001:db8::1", Port: "443"}}, ""}, {"bracketed ipv6 with port", "TOK:[2001:db8::1]:8443", "TOK", []InjectTarget{{Host: "2001:db8::1", Port: "8443"}}, ""}, + {"bracketed ipv6 no port", "TOK:[2001:db8::1]", "TOK", + []InjectTarget{{Host: "2001:db8::1", Port: "443"}}, ""}, + {"reject bracketed non-IP", "TOK:[example.com]", "", nil, "invalid character"}, // A port with leading zeros is canonicalized, or the binding key would // never match a real connection's port. {"leading-zero port", "TOK:api.example.com:0443", "TOK", diff --git a/sandbox/inject_test.go b/sandbox/inject_test.go index 9d8ee49..dc45046 100644 --- a/sandbox/inject_test.go +++ b/sandbox/inject_test.go @@ -162,6 +162,18 @@ func TestResolveInjectWithoutProxySuggestsPassthrough(t *testing.T) { assert.Contains(t, err.Error(), "--env ACTIVE_TOKEN") } +// TestCABundleBaseExplicitEmptyOverridesPassthrough confirms an explicitly +// cleared CA var (--env SSL_CERT_FILE=) does not fall back to the host value +// via passthrough: EnvSet wins over passthrough, as in ResolveEnv. +func TestCABundleBaseExplicitEmptyOverridesPassthrough(t *testing.T) { + t.Setenv("SSL_CERT_FILE", "/host/roots.pem") + plan := &SandboxPlan{ + EnvSet: map[string]string{"SSL_CERT_FILE": ""}, + EnvPassthrough: []string{envPassthroughAll}, + } + assert.Empty(t, plan.caBundleBase("SSL_CERT_FILE")) +} + // TestCABundleBaseDirectoryFallsBack confirms a CA env var pointing at a // directory (accepted by e.g. python-requests) does not abort the run: the // system bundle is used as the base instead. diff --git a/sandbox/plan.go b/sandbox/plan.go index e58458b..5613778 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -778,8 +778,11 @@ func resolveInject(plan *SandboxPlan, cfg *config.Config) error { var caBundleEnvKeys = []string{"SSL_CERT_FILE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS"} func (p *SandboxPlan) caBundleBase(name string) string { - base := p.EnvSet[name] - if base == "" { + // An explicitly set value wins over passthrough, as in ResolveEnv — an + // explicit empty (--env SSL_CERT_FILE=) means system roots, not the host + // value. + base, explicit := p.EnvSet[name] + if !explicit { base, _ = p.passthroughEnvValue(name) } if base == "" { From 0624439dcc79f6238c4e8f0893bf0cc453e84977 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Wed, 1 Jul 2026 23:58:37 +0100 Subject: [PATCH 21/27] fix: authorize injection targets before the proxy-requirement check The proxy-requirement check was reordered ahead of target authorization, so a binding with no allowed domains reported "requires the network proxy" instead of the specific "host is not allowed; add --domains" error (caught by TestDarwinBuildPlan_InjectHeaderActiveRequiresAllowedDomain on macOS CI). Restore authorization first; the proxy error now fires only when the destinations are allowed but unfiltered (e.g. --unrestricted-net with profile domains). Co-Authored-By: Claude Fable 5 --- sandbox/inject_test.go | 11 +++++++---- sandbox/plan.go | 9 ++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/sandbox/inject_test.go b/sandbox/inject_test.go index dc45046..7408cb4 100644 --- a/sandbox/inject_test.go +++ b/sandbox/inject_test.go @@ -147,13 +147,16 @@ func TestResolveInjectExplicitEnvValueSkipsInjection(t *testing.T) { } // TestResolveInjectWithoutProxySuggestsPassthrough confirms the planning error -// for an active binding without the proxy (e.g. --unrestricted-net) names the -// variable and the --env escape hatch. +// for an active binding whose destination is allowed but unfiltered (profile +// domains + --unrestricted-net) names the variable and the --env escape hatch. +// An unlisted destination keeps the more specific "add --domains" error, which +// authorization raises first. func TestResolveInjectWithoutProxySuggestsPassthrough(t *testing.T) { t.Setenv("ACTIVE_TOKEN", "secret") plan := &SandboxPlan{ - TempDir: t.TempDir(), - EnvSet: map[string]string{}, + TempDir: t.TempDir(), + EnvSet: map[string]string{}, + AllowedDomains: []string{"api.example.com"}, } err := resolveInject(plan, injectCfg("ACTIVE_TOKEN:api.example.com")) diff --git a/sandbox/plan.go b/sandbox/plan.go index 5613778..bc44d38 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -724,9 +724,6 @@ func resolveInject(plan *SandboxPlan, cfg *config.Config) error { if len(specs) == 0 { return nil } - if !plan.ProxyEnabled { - return fmt.Errorf("credential injection for %s requires the network proxy: allow the destination with --domains/--ips and do not use --unrestricted-net"+injectEnvHint, injectVarNames(specs), specs[0].envVar) - } domainMatcher := policy.NewDomainMatcher(plan.AllowedDomains) ipMatcher := policy.NewIPMatcher(plan.AllowedIPs) bindings := make(map[policy.InjectTarget][]proxy.Injection, len(specs)) @@ -741,6 +738,12 @@ func resolveInject(plan *SandboxPlan, cfg *config.Config) error { } placeholders[s.envVar] = placeholder } + // Authorization above already produced the specific "add --domains/--ips" + // error for an unlisted destination; reaching here without the proxy means + // the destinations are allowed but unfiltered (e.g. --unrestricted-net). + if !plan.ProxyEnabled { + return fmt.Errorf("credential injection for %s requires the network proxy: allow the destination with --domains/--ips and do not use --unrestricted-net"+injectEnvHint, injectVarNames(specs), specs[0].envVar) + } ca, err := proxy.NewCA() if err != nil { return fmt.Errorf("generating per-run CA: %w", err) From 63f74ee7c0dcd94ef6995ae6b9d3dea1de0f0465 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 2 Jul 2026 00:33:12 +0100 Subject: [PATCH 22/27] test: add end-to-end coverage for injection binding scope and opt-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TestCurb_Inject_BoundDestinationOnly: runs the real binary against two loopback HTTPS servers, one bound and one not. Confirms the bound destination receives the real credential while the unbound port on the same host receives only the placeholder over the passthrough relay, and greps /proc from inside the sandbox to confirm the parent-held secret is not reachable. This is the central "credential bound to its destination" property, exercised through the proxy rather than in a unit test. - TestCurb_Inject_EnvValueOptOut: confirms --env VAR=value disables injection at the binary level — the sandbox sees the user's value, not the placeholder. Co-Authored-By: Claude Fable 5 --- sandbox/inject_integration_test.go | 83 ++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/sandbox/inject_integration_test.go b/sandbox/inject_integration_test.go index 2632c5c..d58b9da 100644 --- a/sandbox/inject_integration_test.go +++ b/sandbox/inject_integration_test.go @@ -176,6 +176,89 @@ func TestCurb_Inject_EndToEnd(t *testing.T) { assert.Equal(t, "Bearer integration-secret", seen[0], "real credential injected on the wire") } +// TestCurb_Inject_BoundDestinationOnly is the central correctness property, +// end to end: the credential is substituted only at the bound host:port. A +// second upstream on the same host but a different port receives the literal +// placeholder — never the real credential — over the passthrough relay. The +// script also sweeps /proc from inside the sandbox to confirm the parent-held +// secret is not reachable. +func TestCurb_Inject_BoundDestinationOnly(t *testing.T) { + requireProxyNS(t) + + bound := &headerRecorder{} + other := &headerRecorder{} + cert, caPEM := upstreamTLS(t, []string{"localhost"}) + boundPort := startTLSServer(t, cert, bound.handler()) + otherPort := startTLSServer(t, cert, other.handler()) + + caFile := filepath.Join(t.TempDir(), "upstream-ca.pem") + require.NoError(t, os.WriteFile(caFile, caPEM, 0o644)) + + // The first curl hits the bound port (TLS terminated, credential + // injected); the second hits the unbound port (passthrough relay, its + // leaf verified against the extended bundle's test CA). The grep hunts + // the real secret in every environ/cmdline visible inside the sandbox. + script := fmt.Sprintf( + "curl -sf --connect-timeout 10 -H \"Authorization: Bearer $DEMO_TOKEN\" https://localhost:%s/; echo \" a=$?\"; "+ + "curl -sf --connect-timeout 10 -H \"Authorization: Bearer $DEMO_TOKEN\" https://localhost:%s/; echo \" b=$?\"; "+ + "grep -rs integration-secret /proc/*/environ /proc/*/cmdline >/dev/null 2>&1; echo \" leak=$?\"", + boundPort, otherPort) + + // The parent proxy verifies the terminated bound upstream against + // SSL_CERT_FILE (Go). curl in the sandbox verifies the relayed unbound + // upstream against CURL_CA_BUNDLE; pass it through so injection extends the + // test CA with the per-run CA, letting the sandbox trust both ports. + cmd := exec.Command(curbBin, "--write", "*", "--exec", "*", + "--host-loopback", + "--domains", "localhost", + "--inject-header", "DEMO_TOKEN:localhost:"+boundPort, + "--env", "CURL_CA_BUNDLE", + "--", "sh", "-c", script) + cmd.Env = append(envWithout("DEMO_TOKEN", "SSL_CERT_FILE", "CURL_CA_BUNDLE"), + "DEMO_TOKEN=integration-secret", + "SSL_CERT_FILE="+caFile, + "CURL_CA_BUNDLE="+caFile, + ) + + out, err := cmd.CombinedOutput() + outStr := filterCurbOutput(string(out)) + require.NoError(t, err, "sandboxed curl failed: %s", outStr) + assert.Contains(t, outStr, "a=0", "request to the bound port should succeed") + assert.Contains(t, outStr, "b=0", "request to the unbound port should be relayed") + assert.NotContains(t, outStr, "leak=0", "real secret must not be findable in /proc from inside the sandbox") + assert.NotContains(t, outStr, "integration-secret") + + seenBound := bound.got("Authorization") + require.Len(t, seenBound, 1) + assert.Equal(t, "Bearer integration-secret", seenBound[0], "bound destination receives the real credential") + + seenOther := other.got("Authorization") + require.Len(t, seenOther, 1) + assert.Equal(t, "Bearer curb-inject-DEMO_TOKEN-placeholder", seenOther[0], + "unbound destination receives the literal placeholder, never the credential") +} + +// TestCurb_Inject_EnvValueOptOut confirms an explicit --env VAR=value disables +// injection for that variable at the binary level: the sandbox sees the +// user-supplied value, not the placeholder, and the inactive binding does not +// demand the proxy or an allowed domain. +func TestCurb_Inject_EnvValueOptOut(t *testing.T) { + requireUserNS(t) + + cmd := exec.Command(curbBin, "--write", "*", "--exec", "*", + "--inject-header", "DEMO_TOKEN:localhost", + "--env", "DEMO_TOKEN=my-own-value", + "--", "sh", "-c", "echo \" tok=$DEMO_TOKEN\"") + cmd.Env = append(envWithout("DEMO_TOKEN"), "DEMO_TOKEN=host-secret") + + out, err := cmd.CombinedOutput() + outStr := filterCurbOutput(string(out)) + require.NoError(t, err, "curb failed: %s", outStr) + assert.Contains(t, outStr, "tok=my-own-value", "explicit --env value reaches the sandbox") + assert.NotContains(t, outStr, "placeholder", "no placeholder when injection is opted out") + assert.NotContains(t, outStr, "host-secret") +} + // TestCurb_Inject_HeaderAgnostic confirms the same binding works for a different // auth header (x-api-key, as Anthropic uses) with no header configured. func TestCurb_Inject_HeaderAgnostic(t *testing.T) { From 6fd928f192a46be0a0d2b58c511e568c88e43481 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 2 Jul 2026 08:40:10 +0100 Subject: [PATCH 23/27] test: make Expect: 100-continue test deterministic; close idle test conns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestInjector_ExpectContinue asserted an interim 100 while the upstream replied without reading the body, so whether the transport read the request body (triggering the 100) before the upstream's response was a race — it passed locally and failed on CI. The upstream now reads the full body before replying, so the body must cross the wire, which cannot happen until the proxy sends the client its 100. The client also reads like a real one, tolerating interim 1xx responses. clientThroughProxy now closes idle connections on cleanup so hijacked CONNECT tunnels and their per-connection injection servers do not linger across tests (relevant on macOS, default soft limit 256 fds). Co-Authored-By: Claude Fable 5 --- proxy/inject_test.go | 79 +++++++++++++++++++++++++++++--------------- 1 file changed, 53 insertions(+), 26 deletions(-) diff --git a/proxy/inject_test.go b/proxy/inject_test.go index e19539e..c30fd91 100644 --- a/proxy/inject_test.go +++ b/proxy/inject_test.go @@ -114,16 +114,19 @@ func injectTestProxy(t *testing.T, ca *CA, bindings map[string][]Injection, upst } // clientThroughProxy builds an HTTP client that routes through the curb proxy -// and trusts the per-run CA for the terminated TLS. -func clientThroughProxy(proxyURL *url.URL, ca *CA) *http.Client { +// and trusts the per-run CA for the terminated TLS. Idle connections are closed +// on cleanup so hijacked CONNECT tunnels (and their per-connection injection +// servers) do not linger across tests. +func clientThroughProxy(t *testing.T, proxyURL *url.URL, ca *CA) *http.Client { + t.Helper() pool := x509.NewCertPool() pool.AppendCertsFromPEM(ca.CertPEM()) - return &http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyURL(proxyURL), - TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, - }, + transport := &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, } + t.Cleanup(transport.CloseIdleConnections) + return &http.Client{Transport: transport} } func get(t *testing.T, client *http.Client, rawURL string) string { @@ -215,7 +218,7 @@ func TestInjector_SetsHostHeader(t *testing.T) { map[string][]Injection{"api.github.com": {{Placeholder: "GH_PH", Value: "ghs_realtoken"}}}, map[string]*authRecorder{"api.github.com": gh}, ) - client := clientThroughProxy(proxyURL, ca) + client := clientThroughProxy(t, proxyURL, ca) get(t, client, "https://api.github.com/user") require.Equal(t, 1, len(gh.hosts)) @@ -239,7 +242,7 @@ func TestInjector_ReplacesPlaceholder(t *testing.T) { map[string][]Injection{"api.github.com": {{Placeholder: "GH_PH", Value: "ghs_realtoken"}}}, map[string]*authRecorder{"api.github.com": gh}, ) - client := clientThroughProxy(proxyURL, ca) + client := clientThroughProxy(t, proxyURL, ca) body := getWithHeader(t, client, "https://api.github.com/user", "Authorization", "Bearer GH_PH") assert.Equal(t, "ok", body) @@ -263,7 +266,7 @@ func TestInjector_HeaderAgnostic(t *testing.T) { map[string][]Injection{"api.anthropic.com": {{Placeholder: "ANT_PH", Value: "sk-ant-real"}}}, map[string]*authRecorder{"api.anthropic.com": up}, ) - client := clientThroughProxy(proxyURL, ca) + client := clientThroughProxy(t, proxyURL, ca) getWithHeader(t, client, "https://api.anthropic.com/v1/models", tc.header, tc.value) require.Equal(t, 1, up.count()) @@ -290,7 +293,7 @@ func TestInjector_BindsCredentialToDestination(t *testing.T) { "api.example.com": other, }, ) - client := clientThroughProxy(proxyURL, ca) + client := clientThroughProxy(t, proxyURL, ca) // Send github's placeholder to both hosts: only github substitutes it. getWithHeader(t, client, "https://api.github.com/user", "Authorization", "Bearer GH_PH") @@ -541,33 +544,57 @@ func TestInjector_KeepAliveAfterUnreadBody(t *testing.T) { } // TestInjector_ExpectContinue confirms a client using Expect: 100-continue -// receives an interim 100 response instead of stalling until timeout. +// receives an interim 100 response instead of stalling until timeout. The +// upstream reads the full body before replying, so the body must cross the +// wire — which cannot happen until the proxy sends the client its 100, making +// the interim response deterministic rather than racing the final one. func TestInjector_ExpectContinue(t *testing.T) { ca, err := NewCA() require.NoError(t, err) - rec := newAuthRecorder(t) + rec := &authRecorder{} + rec.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec.headers = append(rec.headers, r.Header.Clone()) + rec.hosts = append(rec.hosts, r.Host) + body, _ := io.ReadAll(r.Body) + _, _ = w.Write(body) // echo, so the test confirms the body arrived + })) + t.Cleanup(rec.srv.Close) + proxyURL := injectTestProxy(t, ca, map[string][]Injection{"api.example.com": {{Placeholder: "EX_PH", Value: "real_token"}}}, map[string]*authRecorder{"api.example.com": rec}, ) tc, br := dialInjectTLS(t, proxyURL, ca, "api.example.com") + // Send headers only; withhold the body until an interim 100 arrives. _, err = io.WriteString(tc, "POST /upload HTTP/1.1\r\nHost: api.example.com\r\nContent-Length: 5\r\nExpect: 100-continue\r\n\r\n") require.NoError(t, err) - require.NoError(t, tc.SetReadDeadline(time.Now().Add(5*time.Second))) - resp, err := http.ReadResponse(br, nil) - require.NoError(t, err, "expected an interim 100 Continue") - require.Equal(t, http.StatusContinue, resp.StatusCode) - _, err = io.WriteString(tc, "hello") - require.NoError(t, err) - resp, err = http.ReadResponse(br, nil) - require.NoError(t, err) - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - require.Equal(t, http.StatusOK, resp.StatusCode) - require.Equal(t, 1, rec.count()) + // Read like a real client: consume interim 1xx responses, send the body on + // the first 100, and stop at the final response. Tolerates more than one + // interim response but requires that a 100 preceded the body. + require.NoError(t, tc.SetReadDeadline(time.Now().Add(5*time.Second))) + var saw100 bool + for { + resp, err := http.ReadResponse(br, &http.Request{Method: http.MethodPost}) + require.NoError(t, err, "expected an interim 100 Continue before the final response") + if resp.StatusCode == http.StatusContinue { + if !saw100 { + saw100 = true + _, err = io.WriteString(tc, "hello") + require.NoError(t, err) + } + continue + } + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + _ = resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "hello", string(body), "body should reach the upstream after 100") + break + } + require.True(t, saw100, "client should receive an interim 100 Continue") } // TestInjector_LeavesOtherHeadersUntouched confirms only the placeholder is @@ -581,7 +608,7 @@ func TestInjector_LeavesOtherHeadersUntouched(t *testing.T) { map[string][]Injection{"api.github.com": {{Placeholder: "GH_PH", Value: "ghs_realtoken"}}}, map[string]*authRecorder{"api.github.com": gh}, ) - client := clientThroughProxy(proxyURL, ca) + client := clientThroughProxy(t, proxyURL, ca) // No placeholder present: nothing is substituted, and the value is intact. getWithHeader(t, client, "https://api.github.com/user", "X-Custom", "untouched-value") From 2793f0b8b76c13f8686f55071b07fb8324e1c206 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 2 Jul 2026 08:58:43 +0100 Subject: [PATCH 24/27] fix: address Copilot review comments on injection opt-out and env hint - Keep explicit --env names when '*' is also given (flags or CURB_ENV), so an exact passthrough still opts a variable out of credential injection under passthrough-all. CURB_ENV values combining '*' with NAME=value pairs also no longer drop the pairs. - The proxy-requirement and platform-support errors now suggest --env for each active injection variable instead of only the first. Written by Claude Code. Co-Authored-By: Claude Opus 4.8 --- config/config.go | 16 ++++++++---- config/config_test.go | 22 ++++++++++++++++ sandbox/inject_test.go | 57 ++++++++++++++++++++++++++++++++++++++++++ sandbox/plan.go | 24 ++++++++++++------ 4 files changed, 106 insertions(+), 13 deletions(-) diff --git a/config/config.go b/config/config.go index c6e0df2..052d4a5 100644 --- a/config/config.go +++ b/config/config.go @@ -162,7 +162,9 @@ func FromFlags(cmd *cobra.Command) (*Config, error) { } if containsStar(passNames) { cfg.EnvPassthroughAll = true - cfg.EnvPassthrough = nil + // Keep explicit names: --env NAME alongside '*' is still a per-variable + // trust decision (it opts NAME out of credential injection). + cfg.EnvPassthrough = removeStar(passNames) } if containsStar(cfg.ROPaths) { cfg.ROPaths = []string{"/"} @@ -210,11 +212,10 @@ func MergeEnv(cfg *Config, cmd *cobra.Command) { envPass, envSet := classifyEnvArgs(SplitComma(val)) if containsStar(envPass) { cfg.EnvPassthroughAll = true - cfg.EnvPassthrough = nil - } else { - cfg.EnvPassthrough = append(cfg.EnvPassthrough, envPass...) - cfg.EnvSet = append(cfg.EnvSet, envSet...) + envPass = removeStar(envPass) } + cfg.EnvPassthrough = append(cfg.EnvPassthrough, envPass...) + cfg.EnvSet = append(cfg.EnvSet, envSet...) } // String values: env only if flag not explicitly set. @@ -252,6 +253,11 @@ func containsStar(ss []string) bool { return slices.Contains(ss, "*") } +// removeStar returns ss without its literal "*" elements. +func removeStar(ss []string) []string { + return slices.DeleteFunc(slices.Clone(ss), func(s string) bool { return s == "*" }) +} + func appendEnvList(existing []string, envKey string) []string { val, ok := os.LookupEnv(envKey) if !ok || val == "" { diff --git a/config/config_test.go b/config/config_test.go index 4cadd71..dacdb1a 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -107,6 +107,15 @@ func TestFromFlags_WildcardEnv(t *testing.T) { assert.Empty(t, cfg.EnvPassthrough) } +func TestFromFlags_WildcardEnvKeepsExplicitNames(t *testing.T) { + cmd := newTestCmd([]string{"--env", "FOO", "--env", "*", "--env", "BAR=baz"}) + cfg, err := FromFlags(cmd) + require.NoError(t, err) + assert.True(t, cfg.EnvPassthroughAll) + assert.Equal(t, []string{"FOO"}, cfg.EnvPassthrough) + assert.Equal(t, []string{"BAR=baz"}, cfg.EnvSet) +} + func TestFromFlags_WildcardRead(t *testing.T) { cmd := newTestCmd([]string{"--read", "*"}) cfg, err := FromFlags(cmd) @@ -225,6 +234,19 @@ func TestMergeEnv_WildcardEnv(t *testing.T) { assert.Empty(t, cfg.EnvPassthrough) } +func TestMergeEnv_WildcardEnvKeepsExplicitEntries(t *testing.T) { + cmd := newTestCmd(nil) + cfg, err := FromFlags(cmd) + require.NoError(t, err) + + t.Setenv("CURB_ENV", "FOO,*,BAR=baz") + MergeEnv(cfg, cmd) + + assert.True(t, cfg.EnvPassthroughAll) + assert.Equal(t, []string{"FOO"}, cfg.EnvPassthrough) + assert.Equal(t, []string{"BAR=baz"}, cfg.EnvSet) +} + func TestMergeEnv_WildcardEnvValueNotPassthrough(t *testing.T) { cmd := newTestCmd(nil) cfg, err := FromFlags(cmd) diff --git a/sandbox/inject_test.go b/sandbox/inject_test.go index 7408cb4..2653a33 100644 --- a/sandbox/inject_test.go +++ b/sandbox/inject_test.go @@ -126,6 +126,26 @@ func TestResolveInjectExactPassthroughSkipsInjection(t *testing.T) { assert.False(t, set) } +// TestResolveInjectExactPassthroughWithWildcardSkipsInjection confirms an +// explicit --env VAR remains an opt-out when combined with --env '*': the +// explicit name is preserved alongside EnvPassthroughAll. +func TestResolveInjectExactPassthroughWithWildcardSkipsInjection(t *testing.T) { + t.Setenv("ACTIVE_TOKEN", "secret") + plan := &SandboxPlan{ + TempDir: t.TempDir(), + EnvSet: map[string]string{}, + EnvPassthrough: []string{envPassthroughAll}, + ProxyEnabled: true, + } + cfg := injectCfg("ACTIVE_TOKEN:api.example.com") + cfg.EnvPassthroughAll = true + cfg.EnvPassthrough = []string{"ACTIVE_TOKEN"} + + require.NoError(t, resolveInject(plan, cfg)) + assert.Empty(t, plan.InjectBindings) + assert.Nil(t, plan.CA) +} + // TestResolveInjectExplicitEnvValueSkipsInjection confirms --env VAR=value is // an injection opt-out like exact passthrough: the user-supplied value reaches // the sandbox instead of being clobbered by the placeholder. @@ -165,6 +185,23 @@ func TestResolveInjectWithoutProxySuggestsPassthrough(t *testing.T) { assert.Contains(t, err.Error(), "--env ACTIVE_TOKEN") } +// TestResolveInjectWithoutProxyHintNamesEachVariable confirms the --env hint +// covers every active binding, not just the first. +func TestResolveInjectWithoutProxyHintNamesEachVariable(t *testing.T) { + t.Setenv("TOKEN_A", "secret-a") + t.Setenv("TOKEN_B", "secret-b") + plan := &SandboxPlan{ + TempDir: t.TempDir(), + EnvSet: map[string]string{}, + AllowedDomains: []string{"a.example.com", "b.example.com"}, + } + + err := resolveInject(plan, injectCfg("TOKEN_A:a.example.com", "TOKEN_B:b.example.com")) + require.Error(t, err) + assert.Contains(t, err.Error(), "--env TOKEN_A") + assert.Contains(t, err.Error(), "--env TOKEN_B") +} + // TestCABundleBaseExplicitEmptyOverridesPassthrough confirms an explicitly // cleared CA var (--env SSL_CERT_FILE=) does not fall back to the host value // via passthrough: EnvSet wins over passthrough, as in ResolveEnv. @@ -220,6 +257,26 @@ func TestResolveInjectWildcardPassthroughStillInjects(t *testing.T) { assert.Equal(t, injectPlaceholder("ACTIVE_TOKEN"), plan.EnvSet["ACTIVE_TOKEN"]) } +func TestResolveInjectPassthroughAllStillInjects(t *testing.T) { + if systemCABundle() == "" { + t.Skip("system CA bundle unavailable") + } + t.Setenv("ACTIVE_TOKEN", "secret") + plan := &SandboxPlan{ + TempDir: t.TempDir(), + EnvSet: map[string]string{}, + EnvPassthrough: []string{envPassthroughAll}, + AllowedDomains: []string{"api.example.com"}, + ProxyEnabled: true, + } + cfg := injectCfg("ACTIVE_TOKEN:api.example.com") + cfg.EnvPassthroughAll = true + + require.NoError(t, resolveInject(plan, cfg)) + assert.Contains(t, plan.InjectBindings, policy.InjectTarget{Host: "api.example.com", Port: "443"}) + assert.Equal(t, injectPlaceholder("ACTIVE_TOKEN"), plan.EnvSet["ACTIVE_TOKEN"]) +} + func TestResolveInjectExtendsPassthroughSSL_CERT_FILE(t *testing.T) { t.Setenv("ACTIVE_TOKEN", "secret") dir := t.TempDir() diff --git a/sandbox/plan.go b/sandbox/plan.go index bc44d38..bc4be1e 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -630,23 +630,31 @@ func activeInjectSpecs(specs []injectSpec, cfg *config.Config) []injectSpec { // injectOptOut reports whether the user explicitly provided name's sandbox // value — exact passthrough (--env NAME) or an explicit value (--env // NAME=value). Either is a trust decision that disables credential injection -// for that variable; wildcard passthrough does not. +// for that variable; wildcard passthrough does not, but an explicit name +// alongside --env '*' still counts (config keeps the names in EnvPassthrough). func injectOptOut(cfg *config.Config, name string) bool { for _, pair := range cfg.EnvSet { if k, _, _ := strings.Cut(pair, "="); k == name { return true } } - if cfg.EnvPassthroughAll { - return false - } adds, _, _ := config.ParseExclusions(cfg.EnvPassthrough) return slices.Contains(adds, name) } // injectEnvHint is the shared error suffix suggesting the injection-free -// alternative; takes the variable name as a format argument. -const injectEnvHint = "; or pass the credential into the sandbox instead (an explicit trust decision) with --env %s" +// alternative; takes the injectEnvFlags of the active specs as a format +// argument. +const injectEnvHint = "; or pass the credential into the sandbox instead (an explicit trust decision) with %s" + +// injectEnvFlags renders an --env flag per spec for error-message hints. +func injectEnvFlags(specs []injectSpec) string { + flags := make([]string, len(specs)) + for i, s := range specs { + flags[i] = "--env " + s.envVar + } + return strings.Join(flags, " ") +} // displayInjectTarget formats an injection target for human output, dropping // the default :443 so the common case reads as a bare host. @@ -742,7 +750,7 @@ func resolveInject(plan *SandboxPlan, cfg *config.Config) error { // error for an unlisted destination; reaching here without the proxy means // the destinations are allowed but unfiltered (e.g. --unrestricted-net). if !plan.ProxyEnabled { - return fmt.Errorf("credential injection for %s requires the network proxy: allow the destination with --domains/--ips and do not use --unrestricted-net"+injectEnvHint, injectVarNames(specs), specs[0].envVar) + return fmt.Errorf("credential injection for %s requires the network proxy: allow the destination with --domains/--ips and do not use --unrestricted-net"+injectEnvHint, injectVarNames(specs), injectEnvFlags(specs)) } ca, err := proxy.NewCA() if err != nil { @@ -1528,7 +1536,7 @@ func buildDegradedPlan(cfg *config.Config, caps *Capabilities) (*SandboxPlan, er return nil, err } if active := activeInjectSpecs(injects, cfg); len(active) > 0 { - return nil, fmt.Errorf("credential injection (--inject-header) is only supported on Linux and macOS"+injectEnvHint, active[0].envVar) + return nil, fmt.Errorf("credential injection (--inject-header) is only supported on Linux and macOS"+injectEnvHint, injectEnvFlags(active)) } if len(cfg.AllowedDomains) > 0 { From dc1a9dad245b3f369724ab7148dd41b97dfba016 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 2 Jul 2026 09:12:34 +0100 Subject: [PATCH 25/27] refactor: share --env wildcard handling between flags and CURB_ENV Move the star classification into a single applyEnvArgs method so the passthrough-all and explicit-name semantics have one source of truth, replacing the duplicated containsStar/removeStar sequence. Written by Claude Code. Co-Authored-By: Claude Opus 4.8 --- config/config.go | 42 +++++++++++++++++++----------------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/config/config.go b/config/config.go index 052d4a5..fc7a022 100644 --- a/config/config.go +++ b/config/config.go @@ -127,8 +127,6 @@ func FromFlags(cmd *cobra.Command) (*Config, error) { if err != nil { return nil, err } - // Separate --allow-env values into passthrough names and explicit name=value pairs. - passNames, setPairs := classifyEnvArgs(env) cfg := &Config{ AllowedDomains: allow, @@ -138,8 +136,6 @@ func FromFlags(cmd *cobra.Command) (*Config, error) { ROPaths: ro, RWPaths: rw, ExecAllow: execAllow, - EnvPassthrough: passNames, - EnvSet: setPairs, AllowUnixSockets: allowUnixSockets, HostLoopback: hostLoopback, LogFile: logFile, @@ -160,15 +156,10 @@ func FromFlags(cmd *cobra.Command) (*Config, error) { cfg.NoFSRestrict = true cfg.RWPaths = nil } - if containsStar(passNames) { - cfg.EnvPassthroughAll = true - // Keep explicit names: --env NAME alongside '*' is still a per-variable - // trust decision (it opts NAME out of credential injection). - cfg.EnvPassthrough = removeStar(passNames) - } if containsStar(cfg.ROPaths) { cfg.ROPaths = []string{"/"} } + cfg.applyEnvArgs(env) return cfg, nil } @@ -207,15 +198,9 @@ func MergeEnv(cfg *Config, cmd *cobra.Command) { cfg.ExecAllow = append(cfg.ExecAllow, execEnv...) } - // --env via CURB_ENV: split and classify like FromFlags. + // --env via CURB_ENV: same handling as the flag. if val, ok := os.LookupEnv("CURB_ENV"); ok { - envPass, envSet := classifyEnvArgs(SplitComma(val)) - if containsStar(envPass) { - cfg.EnvPassthroughAll = true - envPass = removeStar(envPass) - } - cfg.EnvPassthrough = append(cfg.EnvPassthrough, envPass...) - cfg.EnvSet = append(cfg.EnvSet, envSet...) + cfg.applyEnvArgs(SplitComma(val)) } // String values: env only if flag not explicitly set. @@ -248,16 +233,27 @@ func classifyEnvArgs(args []string) (passNames, setPairs []string) { return } +// applyEnvArgs merges --env-style values (from the flag or CURB_ENV) into cfg. +// A literal "*" enables passthrough-all; explicit names are kept even then — +// --env NAME alongside '*' is still a per-variable trust decision (it opts +// NAME out of credential injection). +func (cfg *Config) applyEnvArgs(args []string) { + passNames, setPairs := classifyEnvArgs(args) + for _, name := range passNames { + if name == "*" { + cfg.EnvPassthroughAll = true + continue + } + cfg.EnvPassthrough = append(cfg.EnvPassthrough, name) + } + cfg.EnvSet = append(cfg.EnvSet, setPairs...) +} + // containsStar reports whether the slice contains a literal "*" element. func containsStar(ss []string) bool { return slices.Contains(ss, "*") } -// removeStar returns ss without its literal "*" elements. -func removeStar(ss []string) []string { - return slices.DeleteFunc(slices.Clone(ss), func(s string) bool { return s == "*" }) -} - func appendEnvList(existing []string, envKey string) []string { val, ok := os.LookupEnv(envKey) if !ok || val == "" { From aa0ac763f2ec4b8d09298493253273a3b1237112 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 2 Jul 2026 09:37:43 +0100 Subject: [PATCH 26/27] refactor: simplify credential injection internals Cleanups from a /simplify review of the branch: - Move the injection planning cluster out of sandbox/plan.go into sandbox/inject.go, matching the file-per-concern layout. - Add config.Config.EnvExplicitlyProvided so the --env grammar and its wildcard invariant stay in the config package; delete injectOptOut. - Add policy.InjectTarget.String and use it for both the dry-run display (displayInjectTarget) and the upstream authority (injectedAuthority), which duplicated the drop-default-:443 rule in two packages. - Remove the leaf-renewal machinery in proxy.CA: leaves now share the CA's expiry (a per-run in-memory CA never outlives its leaves), so leafFor is a plain mutex-guarded cache. - Extract Handler.hijack for the three copies of the Hijacker prologue; share a connEstablished const. - Set placeholders directly in plan.EnvSet instead of via an intermediate map; inline the single-use passthroughEnvValue. - Drop injectVarNames: the --env flag list in the same message already names each variable. - Trim sandbox TestParseInjectHeader to the wrapper's own behavior (policy/validate_test.go owns the validation matrix); merge the two GET helpers and the recording-upstream setup in proxy/inject_test.go; add direct tests for the moved/new helpers. Written by Claude Code. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 1 + config/config.go | 16 +++ config/config_test.go | 13 ++ policy/validate.go | 13 ++ policy/validate_test.go | 7 + proxy/handler.go | 47 ++++--- proxy/inject.go | 62 ++------- proxy/inject_test.go | 90 ++++--------- sandbox/inject.go | 254 +++++++++++++++++++++++++++++++++++ sandbox/inject_test.go | 35 ++--- sandbox/plan.go | 282 --------------------------------------- sandbox/proxy_handler.go | 3 +- 12 files changed, 384 insertions(+), 439 deletions(-) create mode 100644 sandbox/inject.go diff --git a/CLAUDE.md b/CLAUDE.md index a84e586..f526105 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ go test ./sandbox/ -run TestCurb_FS_ -v # run a subset - `config/` — Config struct (FromFlags, MergeEnv), defaults (platform-split: `defaults_linux.go`, `defaults_darwin.go`), exclusion helpers, config file loading, profiles - `sandbox/plan.go` — SandboxPlan, PlanBuilder interface, shared resolve* helpers, FSEnforcer interface +- `sandbox/inject.go` — credential injection planning (resolveInject, target authorization, CA bundle delivery) - `sandbox/plan_linux.go` — linuxPlanBuilder (namespace + Landlock enforcement selection) - `sandbox/plan_darwin.go` — darwinPlanBuilder (Seatbelt enforcement, path canonicalization) - `sandbox/plan_other.go` — degradedPlanBuilder (env-only fallback) diff --git a/config/config.go b/config/config.go index fc7a022..c7c94ec 100644 --- a/config/config.go +++ b/config/config.go @@ -249,6 +249,22 @@ func (cfg *Config) applyEnvArgs(args []string) { cfg.EnvSet = append(cfg.EnvSet, setPairs...) } +// EnvExplicitlyProvided reports whether the user named the variable in an +// --env argument — exact passthrough (--env NAME) or an explicit value (--env +// NAME=value). Either is a per-variable trust decision (it opts NAME out of +// credential injection); wildcard or glob passthrough is not. Explicit names +// are kept even alongside '*' (see applyEnvArgs), so the wildcard does not +// mask them. +func (cfg *Config) EnvExplicitlyProvided(name string) bool { + for _, pair := range cfg.EnvSet { + if k, _, _ := strings.Cut(pair, "="); k == name { + return true + } + } + adds, _, _ := ParseExclusions(cfg.EnvPassthrough) + return slices.Contains(adds, name) +} + // containsStar reports whether the slice contains a literal "*" element. func containsStar(ss []string) bool { return slices.Contains(ss, "*") diff --git a/config/config_test.go b/config/config_test.go index dacdb1a..ff42827 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -116,6 +116,19 @@ func TestFromFlags_WildcardEnvKeepsExplicitNames(t *testing.T) { assert.Equal(t, []string{"BAR=baz"}, cfg.EnvSet) } +func TestEnvExplicitlyProvided(t *testing.T) { + cfg := &Config{ + EnvPassthrough: []string{"FOO", "GLOB_*", "!EXCLUDED"}, + EnvSet: []string{"BAR=baz", "EMPTY="}, + } + assert.True(t, cfg.EnvExplicitlyProvided("FOO"), "exact passthrough") + assert.True(t, cfg.EnvExplicitlyProvided("BAR"), "explicit value") + assert.True(t, cfg.EnvExplicitlyProvided("EMPTY"), "explicit empty value") + assert.False(t, cfg.EnvExplicitlyProvided("GLOB_MATCH"), "glob passthrough is not explicit") + assert.False(t, cfg.EnvExplicitlyProvided("EXCLUDED"), "exclusion is not explicit") + assert.False(t, cfg.EnvExplicitlyProvided("OTHER")) +} + func TestFromFlags_WildcardRead(t *testing.T) { cmd := newTestCmd([]string{"--read", "*"}) cfg, err := FromFlags(cmd) diff --git a/policy/validate.go b/policy/validate.go index e8c28d4..8b9338c 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -80,6 +80,19 @@ type InjectTarget struct { Port string // numeric port, "443" by default } +// String formats the target for display and as an HTTP authority, dropping +// the default :443 so the common case reads as a bare host. A bare IPv6 host +// is bracketed so the result is a valid authority. +func (t InjectTarget) String() string { + if t.Port != "443" { + return net.JoinHostPort(t.Host, t.Port) + } + if ip, err := netip.ParseAddr(t.Host); err == nil && ip.Is6() { + return "[" + t.Host + "]" + } + return t.Host +} + // ParseInjectHeader parses one credential-injection binding // "ENV_VAR:TARGET[,TARGET...]", where each TARGET is HOST[:PORT] and HOST is a // hostname or IP literal (PORT defaults to 443). The binding is var-first diff --git a/policy/validate_test.go b/policy/validate_test.go index 935a353..dd71c66 100644 --- a/policy/validate_test.go +++ b/policy/validate_test.go @@ -108,6 +108,13 @@ func TestParseInjectHeader(t *testing.T) { } } +func TestInjectTargetString(t *testing.T) { + assert.Equal(t, "api.github.com", InjectTarget{Host: "api.github.com", Port: "443"}.String()) + assert.Equal(t, "api.github.com:8443", InjectTarget{Host: "api.github.com", Port: "8443"}.String()) + assert.Equal(t, "[2001:db8::1]", InjectTarget{Host: "2001:db8::1", Port: "443"}.String()) + assert.Equal(t, "[2001:db8::1]:8443", InjectTarget{Host: "2001:db8::1", Port: "8443"}.String()) +} + func TestValidateIPs(t *testing.T) { tests := []struct { name string diff --git a/proxy/handler.go b/proxy/handler.go index 6355560..17515e9 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -20,6 +20,30 @@ type Handler struct { Injector *Injector } +// connEstablished is the response accepting a CONNECT request, written raw on +// the hijacked connection. +const connEstablished = "HTTP/1.1 200 Connection Established\r\n\r\n" + +// hijack takes over the client connection from the ResponseWriter, reporting +// failures under the given log event. The bufio.ReadWriter from Hijack is +// safely ignored on every proxy path: in the CONNECT flow no client data is +// buffered before the 200 response, and in the plain-HTTP flow the single +// request has already been fully read by net/http (pipelining through a +// forward proxy is not a real-world concern). +func (h *Handler) hijack(w http.ResponseWriter, event, target string) (net.Conn, bool) { + hijacker, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "curb: hijack unsupported", http.StatusInternalServerError) + return nil, false + } + conn, _, err := hijacker.Hijack() + if err != nil { + h.logEvent(event, target, "error", err.Error()) + return nil, false + } + return conn, true +} + // ServeHTTP implements http.Handler. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodConnect { @@ -52,17 +76,8 @@ func (h *Handler) handleCONNECT(w http.ResponseWriter, r *http.Request) { return } - // Hijack the client connection. - hijacker, ok := w.(http.Hijacker) + clientConn, ok := h.hijack(w, "proxy_connect", r.Host) if !ok { - http.Error(w, "curb: hijack unsupported", http.StatusInternalServerError) - return - } - // The bufio.ReadWriter from Hijack is safely ignored: no client data - // is buffered before the 200 response in the CONNECT flow. - clientConn, _, err := hijacker.Hijack() - if err != nil { - h.logEvent("proxy_connect", r.Host, "error", err.Error()) return } defer func() { _ = clientConn.Close() }() @@ -76,7 +91,7 @@ func (h *Handler) handleCONNECT(w http.ResponseWriter, r *http.Request) { } defer func() { _ = remote.Close() }() - if _, err := clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil { + if _, err := clientConn.Write([]byte(connEstablished)); err != nil { return } @@ -130,16 +145,8 @@ func (h *Handler) handleHTTP(w http.ResponseWriter, r *http.Request) { } // Hijack and relay the response. - hijacker, ok := w.(http.Hijacker) + clientConn, ok := h.hijack(w, "proxy_http", r.Host) if !ok { - http.Error(w, "curb: hijack unsupported", http.StatusInternalServerError) - return - } - // The bufio.ReadWriter from Hijack is safely ignored: the single - // request has already been fully read by net/http, and HTTP pipelining - // through a forward proxy is not a real-world concern. - clientConn, _, err := hijacker.Hijack() - if err != nil { return } defer func() { _ = clientConn.Close() }() diff --git a/proxy/inject.go b/proxy/inject.go index 15e9f77..9add741 100644 --- a/proxy/inject.go +++ b/proxy/inject.go @@ -15,7 +15,6 @@ import ( "net" "net/http" "net/http/httputil" - "net/netip" "strings" "sync" "time" @@ -23,11 +22,7 @@ import ( "github.com/upsun/curb/policy" ) -const ( - caValidity = 365 * 24 * time.Hour - leafValidity = 30 * 24 * time.Hour - leafRenewBefore = 24 * time.Hour -) +const caValidity = 365 * 24 * time.Hour // CA is a per-run certificate authority used to mint leaf certificates for the // hosts whose TLS the injecting proxy terminates. It is trusted only inside one @@ -37,7 +32,7 @@ type CA struct { key *ecdsa.PrivateKey certPEM []byte - mu sync.RWMutex + mu sync.Mutex leaves map[string]*tls.Certificate } @@ -79,46 +74,35 @@ func NewCA() (*CA, error) { // action's trust store (SSL_CERT_FILE / GIT_SSL_CAINFO / ...). func (ca *CA) CertPEM() []byte { return ca.certPEM } -// leafFor returns a leaf certificate for host, signed by the CA. Leaves are -// cached per host for the life of the run. Signing happens without the lock -// held, so a cache hit for one host never blocks behind a sign for another. +// leafFor returns a leaf certificate for host, signed by the CA. Leaves share +// the CA's expiry and are cached per host for the life of the run; signing is +// sub-millisecond, so holding the lock across a sign is fine. func (ca *CA) leafFor(host string) (*tls.Certificate, error) { - now := time.Now() - ca.mu.RLock() - c := ca.leaves[host] - ca.mu.RUnlock() - if c != nil && !leafNeedsRenewal(c, now) { + ca.mu.Lock() + defer ca.mu.Unlock() + if c := ca.leaves[host]; c != nil { return c, nil } leaf, err := ca.signLeaf(host) if err != nil { return nil, err } - ca.mu.Lock() - defer ca.mu.Unlock() - if existing := ca.leaves[host]; existing != nil && !leafNeedsRenewal(existing, now) { - return existing, nil // another goroutine won the race; reuse its leaf - } ca.leaves[host] = leaf return leaf, nil } -// signLeaf mints a fresh leaf certificate for host. It holds no lock. +// signLeaf mints a fresh leaf certificate for host. func (ca *CA) signLeaf(host string) (*tls.Certificate, error) { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, err } now := time.Now() - notAfter := now.Add(leafValidity) - if notAfter.After(ca.cert.NotAfter) { - notAfter = ca.cert.NotAfter - } tmpl := &x509.Certificate{ SerialNumber: big.NewInt(now.UnixNano()), Subject: pkix.Name{CommonName: host}, NotBefore: now.Add(-time.Hour), - NotAfter: notAfter, + NotAfter: ca.cert.NotAfter, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, } @@ -138,10 +122,6 @@ func (ca *CA) signLeaf(host string) (*tls.Certificate, error) { return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leafCert}, nil } -func leafNeedsRenewal(cert *tls.Certificate, now time.Time) bool { - return cert.Leaf == nil || !cert.Leaf.NotAfter.After(now.Add(leafRenewBefore)) -} - // Injection is a credential bound to a destination host: on requests the proxy // forwards to that host, it replaces every occurrence of Placeholder in the // request header values with Value, and does so for no other host. The sandbox @@ -203,19 +183,13 @@ func (in *Injector) binding(host, port string) ([]Injection, bool) { // then forwards each decrypted request to the real upstream with the bound // credential injected. func (h *Handler) injectCONNECT(w http.ResponseWriter, host, port string, injs []Injection) { - hj, ok := w.(http.Hijacker) + clientConn, ok := h.hijack(w, "proxy_inject", host) if !ok { - http.Error(w, "curb: hijack unsupported", http.StatusInternalServerError) - return - } - clientConn, _, err := hj.Hijack() - if err != nil { - h.logEvent("proxy_inject", host, "error", err.Error()) return } defer func() { _ = clientConn.Close() }() - if _, err := clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil { + if _, err := clientConn.Write([]byte(connEstablished)); err != nil { return } @@ -262,7 +236,7 @@ func (in *Injector) Serve(client net.Conn, host, port string, injs []Injection) func (in *Injector) serveInjected(client net.Conn, host, port string, injs []Injection) { // authority is the upstream the request is bound to. Drop the default // https port so the Host header matches what a direct client would send. - authority := injectedAuthority(host, port) + authority := policy.InjectTarget{Host: host, Port: port}.String() rp := &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { // Hop-by-hop headers are already removed from the outbound request, @@ -314,16 +288,6 @@ func (c *signalOnCloseConn) Close() error { return err } -func injectedAuthority(host, port string) string { - if port != "443" { - return net.JoinHostPort(host, port) - } - if ip, err := netip.ParseAddr(host); err == nil && ip.Is6() { - return "[" + host + "]" - } - return host -} - // replaceInHeaders substitutes each binding's placeholder with its real value in // every request header value. Placement is the client's choice — Authorization: // Bearer , x-api-key: , or any other header all work, so the proxy needs diff --git a/proxy/inject_test.go b/proxy/inject_test.go index c30fd91..c2d2173 100644 --- a/proxy/inject_test.go +++ b/proxy/inject_test.go @@ -29,12 +29,21 @@ type authRecorder struct { } func newAuthRecorder(t *testing.T) *authRecorder { + t.Helper() + return newAuthRecorderWith(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "ok") + }) +} + +// newAuthRecorderWith starts a recording TLS upstream with a custom handler; +// each request is recorded before the handler runs. +func newAuthRecorderWith(t *testing.T, handler http.HandlerFunc) *authRecorder { t.Helper() rec := &authRecorder{} rec.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rec.headers = append(rec.headers, r.Header.Clone()) rec.hosts = append(rec.hosts, r.Host) - _, _ = io.WriteString(w, "ok") + handler(w, r) })) t.Cleanup(rec.srv.Close) return rec @@ -129,24 +138,16 @@ func clientThroughProxy(t *testing.T, proxyURL *url.URL, ca *CA) *http.Client { return &http.Client{Transport: transport} } -func get(t *testing.T, client *http.Client, rawURL string) string { - t.Helper() - resp, err := client.Get(rawURL) - require.NoError(t, err) - defer func() { _ = resp.Body.Close() }() - require.Equal(t, http.StatusOK, resp.StatusCode) - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - return string(body) -} - -// getWithHeader issues a GET through the proxy with one header set, returning -// the upstream's response body. -func getWithHeader(t *testing.T, client *http.Client, rawURL, name, value string) string { +// get issues a GET through the proxy, optionally setting one header (given as +// a name, value pair), and returns the upstream's response body. +func get(t *testing.T, client *http.Client, rawURL string, header ...string) string { t.Helper() req, err := http.NewRequest(http.MethodGet, rawURL, nil) require.NoError(t, err) - req.Header.Set(name, value) + if len(header) > 0 { + require.Len(t, header, 2, "header must be a name, value pair") + req.Header.Set(header[0], header[1]) + } resp, err := client.Do(req) require.NoError(t, err) defer func() { _ = resp.Body.Close() }() @@ -170,23 +171,6 @@ func TestCA_CertificatesSupportLongSessions(t *testing.T) { assert.False(t, leaf.Leaf.NotAfter.After(ca.cert.NotAfter), "leaf must not outlive the CA") } -func TestCA_LeafForRenewsExpiringCachedLeaf(t *testing.T) { - ca, err := NewCA() - require.NoError(t, err) - - first, err := ca.leafFor("api.github.com") - require.NoError(t, err) - require.NotNil(t, first.Leaf) - - first.Leaf.NotAfter = time.Now().Add(leafRenewBefore / 2) - renewed, err := ca.leafFor("api.github.com") - require.NoError(t, err) - - assert.NotSame(t, first, renewed) - assert.NotEqual(t, first.Certificate[0], renewed.Certificate[0]) - assert.Greater(t, time.Until(renewed.Leaf.NotAfter), leafRenewBefore) -} - // TestInjector_BindingNormalizesHost confirms bindings match CONNECT targets // regardless of case or a trailing dot, and only on the bound port. func TestInjector_BindingNormalizesHost(t *testing.T) { @@ -225,12 +209,6 @@ func TestInjector_SetsHostHeader(t *testing.T) { assert.Equal(t, "api.github.com", gh.hosts[0]) } -func TestInjectedAuthorityBracketsDefaultPortIPv6(t *testing.T) { - assert.Equal(t, "[2001:db8::1]", injectedAuthority("2001:db8::1", "443")) - assert.Equal(t, "[2001:db8::1]:8443", injectedAuthority("2001:db8::1", "8443")) - assert.Equal(t, "api.github.com", injectedAuthority("api.github.com", "443")) -} - // TestInjector_ReplacesPlaceholder verifies the placeholder the sandbox sends is // replaced with the real credential, wherever the client placed it. func TestInjector_ReplacesPlaceholder(t *testing.T) { @@ -244,7 +222,7 @@ func TestInjector_ReplacesPlaceholder(t *testing.T) { ) client := clientThroughProxy(t, proxyURL, ca) - body := getWithHeader(t, client, "https://api.github.com/user", "Authorization", "Bearer GH_PH") + body := get(t, client, "https://api.github.com/user", "Authorization", "Bearer GH_PH") assert.Equal(t, "ok", body) require.Equal(t, 1, gh.count()) assert.Equal(t, "Bearer ghs_realtoken", gh.got("Authorization")[0]) @@ -268,7 +246,7 @@ func TestInjector_HeaderAgnostic(t *testing.T) { ) client := clientThroughProxy(t, proxyURL, ca) - getWithHeader(t, client, "https://api.anthropic.com/v1/models", tc.header, tc.value) + get(t, client, "https://api.anthropic.com/v1/models", tc.header, tc.value) require.Equal(t, 1, up.count()) assert.Equal(t, tc.want, up.got(tc.header)[0], "header %s", tc.header) } @@ -296,8 +274,8 @@ func TestInjector_BindsCredentialToDestination(t *testing.T) { client := clientThroughProxy(t, proxyURL, ca) // Send github's placeholder to both hosts: only github substitutes it. - getWithHeader(t, client, "https://api.github.com/user", "Authorization", "Bearer GH_PH") - getWithHeader(t, client, "https://api.example.com/data", "Authorization", "Bearer GH_PH") + get(t, client, "https://api.github.com/user", "Authorization", "Bearer GH_PH") + get(t, client, "https://api.example.com/data", "Authorization", "Bearer GH_PH") require.Equal(t, 1, gh.count()) require.Equal(t, 1, other.count()) @@ -450,10 +428,7 @@ func TestInjector_UpgradeWebSocket(t *testing.T) { ca, err := NewCA() require.NoError(t, err) - rec := &authRecorder{} - rec.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - rec.headers = append(rec.headers, r.Header.Clone()) - rec.hosts = append(rec.hosts, r.Host) + rec := newAuthRecorderWith(t, func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Upgrade") != "websocket" { http.Error(w, "expected upgrade", http.StatusBadRequest) return @@ -471,8 +446,7 @@ func TestInjector_UpgradeWebSocket(t *testing.T) { } _, _ = brw.WriteString("echo:" + line) _ = brw.Flush() - })) - t.Cleanup(rec.srv.Close) + }) proxyURL := injectTestProxy(t, ca, map[string][]Injection{"api.example.com": {{Placeholder: "EX_PH", Value: "real_token"}}}, @@ -503,18 +477,14 @@ func TestInjector_KeepAliveAfterUnreadBody(t *testing.T) { ca, err := NewCA() require.NoError(t, err) - rec := &authRecorder{} - rec.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - rec.headers = append(rec.headers, r.Header.Clone()) - rec.hosts = append(rec.hosts, r.Host) + rec := newAuthRecorderWith(t, func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { // Reply without reading the body. w.WriteHeader(http.StatusRequestEntityTooLarge) return } _, _ = io.WriteString(w, "ok") - })) - t.Cleanup(rec.srv.Close) + }) proxyURL := injectTestProxy(t, ca, map[string][]Injection{"api.example.com": {{Placeholder: "EX_PH", Value: "real_token"}}}, @@ -552,14 +522,10 @@ func TestInjector_ExpectContinue(t *testing.T) { ca, err := NewCA() require.NoError(t, err) - rec := &authRecorder{} - rec.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - rec.headers = append(rec.headers, r.Header.Clone()) - rec.hosts = append(rec.hosts, r.Host) + rec := newAuthRecorderWith(t, func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) _, _ = w.Write(body) // echo, so the test confirms the body arrived - })) - t.Cleanup(rec.srv.Close) + }) proxyURL := injectTestProxy(t, ca, map[string][]Injection{"api.example.com": {{Placeholder: "EX_PH", Value: "real_token"}}}, @@ -611,7 +577,7 @@ func TestInjector_LeavesOtherHeadersUntouched(t *testing.T) { client := clientThroughProxy(t, proxyURL, ca) // No placeholder present: nothing is substituted, and the value is intact. - getWithHeader(t, client, "https://api.github.com/user", "X-Custom", "untouched-value") + get(t, client, "https://api.github.com/user", "X-Custom", "untouched-value") require.Equal(t, 1, gh.count()) assert.Equal(t, "untouched-value", gh.got("X-Custom")[0]) assert.Empty(t, gh.got("Authorization")[0]) diff --git a/sandbox/inject.go b/sandbox/inject.go new file mode 100644 index 0000000..9b72d3c --- /dev/null +++ b/sandbox/inject.go @@ -0,0 +1,254 @@ +package sandbox + +import ( + "fmt" + "net/netip" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/upsun/curb/clog" + "github.com/upsun/curb/config" + "github.com/upsun/curb/policy" + "github.com/upsun/curb/proxy" +) + +// injectSpec is a parsed injection binding: the sandbox sees envVar set to a +// placeholder; the proxy replaces it with envVar's real value in requests to +// any of targets, wherever the client placed it among the request headers. +type injectSpec struct { + envVar string + targets []policy.InjectTarget + token string // real host-env value, resolved by activeInjectSpecs +} + +// parseInjectHeader parses --inject-header "ENV_VAR:HOST[,HOST...]" entries. +func parseInjectHeader(entries []string) ([]injectSpec, error) { + var specs []injectSpec + for _, e := range entries { + envVar, targets, err := policy.ParseInjectHeader(e) + if err != nil { + return nil, fmt.Errorf("--inject-header %w", err) + } + specs = append(specs, injectSpec{envVar: envVar, targets: targets}) + } + return specs, nil +} + +// activeInjectSpecs filters specs to those whose source var is set, non-empty, +// and not explicitly provided via --env (an explicit passthrough or value is a +// trust decision that disables injection for that variable), resolving each +// spec's token on the way. +func activeInjectSpecs(specs []injectSpec, cfg *config.Config) []injectSpec { + active := make([]injectSpec, 0, len(specs)) + for _, s := range specs { + token, present := os.LookupEnv(s.envVar) + if !present || token == "" { + continue + } + if cfg.EnvExplicitlyProvided(s.envVar) { + continue + } + s.token = token + active = append(active, s) + } + return active +} + +// injectEnvHint is the shared error suffix suggesting the injection-free +// alternative; takes the injectEnvFlags of the active specs as a format +// argument. +const injectEnvHint = "; or pass the credential into the sandbox instead (an explicit trust decision) with %s" + +// injectEnvFlags renders an --env flag per spec for error-message hints. +func injectEnvFlags(specs []injectSpec) string { + flags := make([]string, len(specs)) + for i, s := range specs { + flags[i] = "--env " + s.envVar + } + return strings.Join(flags, " ") +} + +// injectDestinations returns the bound destinations in display form, sorted. +// Shared by the dry-run output and the skill file. +func (p *SandboxPlan) injectDestinations() []string { + dests := make([]string, 0, len(p.InjectBindings)) + for t := range p.InjectBindings { + dests = append(dests, t.String()) + } + sort.Strings(dests) + return dests +} + +// authorizeInjectTarget checks that an injection target is reachable under the +// network policy: an IP target against --ips, a hostname target against +// --domains. A credential must never be provisioned for a destination the +// sandbox cannot otherwise reach. +func authorizeInjectTarget(t policy.InjectTarget, domains *policy.DomainMatcher, ips *policy.IPMatcher) error { + if addr, err := netip.ParseAddr(t.Host); err == nil { + if ips.Match(addr) { + return nil + } + return fmt.Errorf("credential injection IP %q is not allowed; add --ips %s", t.Host, t.Host) + } + if domains.Match(t.Host) { + return nil + } + return fmt.Errorf("credential injection host %q is not allowed; add --domains %s", t.Host, t.Host) +} + +// injectPlaceholder returns the sentinel the sandbox sees in place of a real +// credential. It is constant per env var so a tool that approves a custom key +// (e.g. Claude Code) approves it once. The value carries no secret weight: the +// proxy swaps it for the real token before the request leaves the host. +// +// A valid env var name cannot contain "-", so the surrounding "-" guards ensure +// no placeholder is a prefix of another (TOK vs TOK2) — the substring +// substitution in replaceInHeaders cannot corrupt one placeholder via another. +func injectPlaceholder(envVar string) string { + return "curb-inject-" + envVar + "-placeholder" +} + +// resolveInject parses the configured bindings, generates the per-run CA, +// resolves each bound token, and delivers the CA to the sandbox trust store. +// It runs after proxy and env resolution. The CA key and tokens stay in the +// parent; only the public CA (in a combined bundle) and the placeholder-free +// env reach the child. +// +// A source var that is unset or empty is skipped silently: injection is opt-in +// per credential, and the common case (e.g. the claude profile for an OAuth +// user) is that the key is simply not present. +func resolveInject(plan *SandboxPlan, cfg *config.Config) error { + specs, err := parseInjectHeader(cfg.InjectHeader) + if err != nil { + return err + } + specs = activeInjectSpecs(specs, cfg) + if len(specs) == 0 { + return nil + } + domainMatcher := policy.NewDomainMatcher(plan.AllowedDomains) + ipMatcher := policy.NewIPMatcher(plan.AllowedIPs) + bindings := make(map[policy.InjectTarget][]proxy.Injection, len(specs)) + for _, s := range specs { + placeholder := injectPlaceholder(s.envVar) + for _, t := range s.targets { + if err := authorizeInjectTarget(t, domainMatcher, ipMatcher); err != nil { + return err + } + bindings[t] = append(bindings[t], proxy.Injection{Placeholder: placeholder, Value: s.token}) + } + // Set the source var to its placeholder: the sandbox sees only that. + // EnvSet wins over passthrough in ResolveEnv, so the real value cannot + // leak in even under --env '*'. + plan.EnvSet[s.envVar] = placeholder + } + // Authorization above already produced the specific "add --domains/--ips" + // error for an unlisted destination; reaching here without the proxy means + // the destinations are allowed but unfiltered (e.g. --unrestricted-net). + if !plan.ProxyEnabled { + return fmt.Errorf("credential injection requires the network proxy: allow the destination with --domains/--ips and do not use --unrestricted-net"+injectEnvHint, injectEnvFlags(specs)) + } + ca, err := proxy.NewCA() + if err != nil { + return fmt.Errorf("generating per-run CA: %w", err) + } + plan.CA = ca + plan.InjectBindings = bindings + + // Trust-store delivery: each standard CA env var receives a bundle that + // extends its existing value, or system roots when unset. The CA validates + // only inside this run, so it is not sensitive to the action. Vars sharing + // a base (usually all of them, unset and falling back to system roots) + // share one written bundle. + bundles := map[string]string{} // base -> written bundle path + for _, k := range caBundleEnvKeys { + base := plan.caBundleBase(k) + bundle, ok := bundles[base] + if !ok { + bundle, err = writeCABundleFile(plan.TempDir, caBundleFilename(k), base, ca.CertPEM()) + if err != nil { + return err + } + bundles[base] = bundle + plan.ROFiles = appendUniq(plan.ROFiles, bundle) + } + plan.EnvSet[k] = bundle + } + return nil +} + +var caBundleEnvKeys = []string{"SSL_CERT_FILE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS"} + +func (p *SandboxPlan) caBundleBase(name string) string { + // An explicitly set value wins over passthrough, as in ResolveEnv — an + // explicit empty (--env SSL_CERT_FILE=) means system roots, not the host + // value. + base, explicit := p.EnvSet[name] + if !explicit && p.envPassesThrough(name) { + base = os.Getenv(name) + } + if base == "" { + return "" + } + // Some vars may legitimately point at a directory of certificates (e.g. + // REQUESTS_CA_BUNDLE), which cannot be extended by concatenation. + if fi, err := os.Stat(base); err == nil && fi.IsDir() { + clog.Warnf("%s points at a directory (%s), which cannot be extended with the per-run CA; using the system CA bundle as its base instead", name, base) + return "" + } + return base +} + +func caBundleFilename(name string) string { + if name == "SSL_CERT_FILE" { + return "ca-bundle.pem" + } + return "ca-bundle-" + strings.ToLower(name) + ".pem" +} + +// writeCABundleFile writes a PEM bundle of the base roots plus the per-run CA +// to the temp dir and returns its path. Base is the trust store to extend; +// when empty it falls back to the system roots. +// It fails if the base roots cannot be located or read: this bundle replaces +// the sandbox's TLS trust (SSL_CERT_FILE etc.), so a bundle holding only the +// per-run CA would break trust for every HTTPS destination other than the +// injected hosts. +func writeCABundleFile(tmpDir, filename, base string, caPEM []byte) (string, error) { + if base == "" { + base = systemCABundle() + } + if base == "" { + return "", fmt.Errorf("credential injection: no system CA bundle found; cannot deliver TLS trust to the sandbox without overriding it") + } + buf, err := os.ReadFile(base) + if err != nil { + return "", fmt.Errorf("credential injection: reading CA bundle %s: %w", base, err) + } + if len(buf) > 0 && buf[len(buf)-1] != '\n' { + buf = append(buf, '\n') + } + buf = append(buf, caPEM...) + path := filepath.Join(tmpDir, filename) + if err := os.WriteFile(path, buf, 0o644); err != nil { + return "", fmt.Errorf("writing CA bundle: %w", err) + } + return path, nil +} + +// systemCABundle returns the first system CA bundle found, or "". +func systemCABundle() string { + for _, p := range []string{ + "/etc/ssl/certs/ca-certificates.crt", // Debian, Ubuntu, Alpine + "/etc/pki/tls/certs/ca-bundle.crt", // Fedora, RHEL + "/etc/ssl/ca-bundle.pem", // openSUSE + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", // CentOS/RHEL 7 + "/etc/ssl/cert.pem", // macOS, some BSDs + } { + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} diff --git a/sandbox/inject_test.go b/sandbox/inject_test.go index 2653a33..cf6a0a8 100644 --- a/sandbox/inject_test.go +++ b/sandbox/inject_test.go @@ -13,35 +13,20 @@ import ( "github.com/upsun/curb/policy" ) +// TestParseInjectHeader covers what the wrapper adds over +// policy.ParseInjectHeader (which owns the validation rule matrix, see +// policy/validate_test.go): multi-entry parsing and the flag-name error prefix. func TestParseInjectHeader(t *testing.T) { - specs, err := parseInjectHeader([]string{"ANTHROPIC_API_KEY:api.anthropic.com"}) + specs, err := parseInjectHeader([]string{"ANTHROPIC_API_KEY:api.anthropic.com", "T:b.example.com:8443"}) require.NoError(t, err) - require.Len(t, specs, 1) + require.Len(t, specs, 2) assert.Equal(t, "ANTHROPIC_API_KEY", specs[0].envVar) - require.Len(t, specs[0].targets, 1) - assert.Equal(t, "api.anthropic.com", specs[0].targets[0].Host) - assert.Equal(t, "443", specs[0].targets[0].Port) - - for _, bad := range []string{ - "", "ANTHROPIC_API_KEY", ":h.com", "TOK:", // missing var or host - "1BAD:h.com", // invalid env var name (leading digit) - "bad-var:h.com", // invalid env var name (dash) - "T:*", "T:*.h.com", // wildcard hosts rejected - "TOK:not a host", // invalid host - "TOK:h.com:0", // invalid port - "TOK:h.com,", // empty target in list - } { - _, err := parseInjectHeader([]string{bad}) - assert.Error(t, err, "expected error for %q", bad) - } + assert.Equal(t, []policy.InjectTarget{{Host: "api.anthropic.com", Port: "443"}}, specs[0].targets) + assert.Equal(t, []policy.InjectTarget{{Host: "b.example.com", Port: "8443"}}, specs[1].targets) - // A list of targets with a mix of default and custom ports. - specs, err = parseInjectHeader([]string{"T:API.Anthropic.COM.,b.example.com:8443"}) - require.NoError(t, err) - require.Len(t, specs[0].targets, 2) - assert.Equal(t, "api.anthropic.com", specs[0].targets[0].Host) - assert.Equal(t, "b.example.com", specs[0].targets[1].Host) - assert.Equal(t, "8443", specs[0].targets[1].Port) + _, err = parseInjectHeader([]string{"bad-var:h.com"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--inject-header ") } func TestInjectPlaceholder(t *testing.T) { diff --git a/sandbox/plan.go b/sandbox/plan.go index bc4be1e..66aeee4 100644 --- a/sandbox/plan.go +++ b/sandbox/plan.go @@ -5,8 +5,6 @@ import ( "io" "maps" "math/rand/v2" - "net" - "net/netip" "os" "os/exec" "path/filepath" @@ -587,270 +585,6 @@ func appendUniq(s []string, v string) []string { return s } -// injectSpec is a parsed injection binding: the sandbox sees envVar set to a -// placeholder; the proxy replaces it with envVar's real value in requests to -// any of targets, wherever the client placed it among the request headers. -type injectSpec struct { - envVar string - targets []policy.InjectTarget - token string // real host-env value, resolved by activeInjectSpecs -} - -// parseInjectHeader parses --inject-header "ENV_VAR:HOST[,HOST...]" entries. -func parseInjectHeader(entries []string) ([]injectSpec, error) { - var specs []injectSpec - for _, e := range entries { - envVar, targets, err := policy.ParseInjectHeader(e) - if err != nil { - return nil, fmt.Errorf("--inject-header %w", err) - } - specs = append(specs, injectSpec{envVar: envVar, targets: targets}) - } - return specs, nil -} - -// activeInjectSpecs filters specs to those whose source var is set, non-empty, -// and not opted out, resolving each spec's token on the way. -func activeInjectSpecs(specs []injectSpec, cfg *config.Config) []injectSpec { - active := make([]injectSpec, 0, len(specs)) - for _, s := range specs { - token, present := os.LookupEnv(s.envVar) - if !present || token == "" { - continue - } - if injectOptOut(cfg, s.envVar) { - continue - } - s.token = token - active = append(active, s) - } - return active -} - -// injectOptOut reports whether the user explicitly provided name's sandbox -// value — exact passthrough (--env NAME) or an explicit value (--env -// NAME=value). Either is a trust decision that disables credential injection -// for that variable; wildcard passthrough does not, but an explicit name -// alongside --env '*' still counts (config keeps the names in EnvPassthrough). -func injectOptOut(cfg *config.Config, name string) bool { - for _, pair := range cfg.EnvSet { - if k, _, _ := strings.Cut(pair, "="); k == name { - return true - } - } - adds, _, _ := config.ParseExclusions(cfg.EnvPassthrough) - return slices.Contains(adds, name) -} - -// injectEnvHint is the shared error suffix suggesting the injection-free -// alternative; takes the injectEnvFlags of the active specs as a format -// argument. -const injectEnvHint = "; or pass the credential into the sandbox instead (an explicit trust decision) with %s" - -// injectEnvFlags renders an --env flag per spec for error-message hints. -func injectEnvFlags(specs []injectSpec) string { - flags := make([]string, len(specs)) - for i, s := range specs { - flags[i] = "--env " + s.envVar - } - return strings.Join(flags, " ") -} - -// displayInjectTarget formats an injection target for human output, dropping -// the default :443 so the common case reads as a bare host. -func displayInjectTarget(t policy.InjectTarget) string { - if t.Port == "443" { - return t.Host - } - return net.JoinHostPort(t.Host, t.Port) -} - -// injectDestinations returns the bound destinations in display form, sorted. -// Shared by the dry-run output and the skill file. -func (p *SandboxPlan) injectDestinations() []string { - dests := make([]string, 0, len(p.InjectBindings)) - for t := range p.InjectBindings { - dests = append(dests, displayInjectTarget(t)) - } - sort.Strings(dests) - return dests -} - -// authorizeInjectTarget checks that an injection target is reachable under the -// network policy: an IP target against --ips, a hostname target against -// --domains. A credential must never be provisioned for a destination the -// sandbox cannot otherwise reach. -func authorizeInjectTarget(t policy.InjectTarget, domains *policy.DomainMatcher, ips *policy.IPMatcher) error { - if addr, err := netip.ParseAddr(t.Host); err == nil { - if ips.Match(addr) { - return nil - } - return fmt.Errorf("credential injection IP %q is not allowed; add --ips %s", t.Host, t.Host) - } - if domains.Match(t.Host) { - return nil - } - return fmt.Errorf("credential injection host %q is not allowed; add --domains %s", t.Host, t.Host) -} - -// injectVarNames lists the source variables of specs for error messages. -func injectVarNames(specs []injectSpec) string { - names := make([]string, 0, len(specs)) - for _, s := range specs { - names = append(names, s.envVar) - } - return strings.Join(names, ", ") -} - -// injectPlaceholder returns the sentinel the sandbox sees in place of a real -// credential. It is constant per env var so a tool that approves a custom key -// (e.g. Claude Code) approves it once. The value carries no secret weight: the -// proxy swaps it for the real token before the request leaves the host. -// -// A valid env var name cannot contain "-", so the surrounding "-" guards ensure -// no placeholder is a prefix of another (TOK vs TOK2) — the substring -// substitution in replaceInHeaders cannot corrupt one placeholder via another. -func injectPlaceholder(envVar string) string { - return "curb-inject-" + envVar + "-placeholder" -} - -// resolveInject parses the configured bindings, generates the per-run CA, -// resolves each bound token, and delivers the CA to the sandbox trust store. -// It runs after proxy and env resolution. The CA key and tokens stay in the -// parent; only the public CA (in a combined bundle) and the placeholder-free -// env reach the child. -// -// A source var that is unset or empty is skipped silently: injection is opt-in -// per credential, and the common case (e.g. the claude profile for an OAuth -// user) is that the key is simply not present. -func resolveInject(plan *SandboxPlan, cfg *config.Config) error { - specs, err := parseInjectHeader(cfg.InjectHeader) - if err != nil { - return err - } - specs = activeInjectSpecs(specs, cfg) - if len(specs) == 0 { - return nil - } - domainMatcher := policy.NewDomainMatcher(plan.AllowedDomains) - ipMatcher := policy.NewIPMatcher(plan.AllowedIPs) - bindings := make(map[policy.InjectTarget][]proxy.Injection, len(specs)) - placeholders := make(map[string]string) - for _, s := range specs { - placeholder := injectPlaceholder(s.envVar) - for _, t := range s.targets { - if err := authorizeInjectTarget(t, domainMatcher, ipMatcher); err != nil { - return err - } - bindings[t] = append(bindings[t], proxy.Injection{Placeholder: placeholder, Value: s.token}) - } - placeholders[s.envVar] = placeholder - } - // Authorization above already produced the specific "add --domains/--ips" - // error for an unlisted destination; reaching here without the proxy means - // the destinations are allowed but unfiltered (e.g. --unrestricted-net). - if !plan.ProxyEnabled { - return fmt.Errorf("credential injection for %s requires the network proxy: allow the destination with --domains/--ips and do not use --unrestricted-net"+injectEnvHint, injectVarNames(specs), injectEnvFlags(specs)) - } - ca, err := proxy.NewCA() - if err != nil { - return fmt.Errorf("generating per-run CA: %w", err) - } - plan.CA = ca - plan.InjectBindings = bindings - - // Set the source vars to their placeholders: the sandbox sees only those. - // EnvSet wins over passthrough in ResolveEnv, so the real value cannot leak - // in even under --env '*'. - maps.Copy(plan.EnvSet, placeholders) - - // Trust-store delivery: each standard CA env var receives a bundle that - // extends its existing value, or system roots when unset. The CA validates - // only inside this run, so it is not sensitive to the action. Vars sharing - // a base (usually all of them, unset and falling back to system roots) - // share one written bundle. - bundles := map[string]string{} // base -> written bundle path - for _, k := range caBundleEnvKeys { - base := plan.caBundleBase(k) - bundle, ok := bundles[base] - if !ok { - bundle, err = writeCABundleFile(plan.TempDir, caBundleFilename(k), base, ca.CertPEM()) - if err != nil { - return err - } - bundles[base] = bundle - plan.ROFiles = appendUniq(plan.ROFiles, bundle) - } - plan.EnvSet[k] = bundle - } - return nil -} - -var caBundleEnvKeys = []string{"SSL_CERT_FILE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS"} - -func (p *SandboxPlan) caBundleBase(name string) string { - // An explicitly set value wins over passthrough, as in ResolveEnv — an - // explicit empty (--env SSL_CERT_FILE=) means system roots, not the host - // value. - base, explicit := p.EnvSet[name] - if !explicit { - base, _ = p.passthroughEnvValue(name) - } - if base == "" { - return "" - } - // Some vars may legitimately point at a directory of certificates (e.g. - // REQUESTS_CA_BUNDLE), which cannot be extended by concatenation. - if fi, err := os.Stat(base); err == nil && fi.IsDir() { - clog.Warnf("%s points at a directory (%s), which cannot be extended with the per-run CA; using the system CA bundle as its base instead", name, base) - return "" - } - return base -} - -func caBundleFilename(name string) string { - if name == "SSL_CERT_FILE" { - return "ca-bundle.pem" - } - return "ca-bundle-" + strings.ToLower(name) + ".pem" -} - -// writeCABundleFile writes a PEM bundle of the base roots plus the per-run CA -// to the temp dir and returns its path. Base is the trust store to extend; -// when empty it falls back to the system roots. -// It fails if the base roots cannot be located or read: this bundle replaces -// the sandbox's TLS trust (SSL_CERT_FILE etc.), so a bundle holding only the -// per-run CA would break trust for every HTTPS destination other than the -// injected hosts. -func writeCABundleFile(tmpDir, filename, base string, caPEM []byte) (string, error) { - if base == "" { - base = systemCABundle() - } - if base == "" { - return "", fmt.Errorf("credential injection: no system CA bundle found; cannot deliver TLS trust to the sandbox without overriding it") - } - buf, err := os.ReadFile(base) - if err != nil { - return "", fmt.Errorf("credential injection: reading CA bundle %s: %w", base, err) - } - if len(buf) > 0 && buf[len(buf)-1] != '\n' { - buf = append(buf, '\n') - } - buf = append(buf, caPEM...) - path := filepath.Join(tmpDir, filename) - if err := os.WriteFile(path, buf, 0o644); err != nil { - return "", fmt.Errorf("writing CA bundle: %w", err) - } - return path, nil -} - -func (p *SandboxPlan) passthroughEnvValue(name string) (string, bool) { - if !p.envPassesThrough(name) { - return "", false - } - return os.LookupEnv(name) -} - // envPassesThrough reports whether the passthrough policy admits name. The // single matching rule (internal-var guard, '*' sentinel, glob patterns) is // shared by ResolveEnv, the dry-run output, and the CA-bundle base lookup so @@ -870,22 +604,6 @@ func (p *SandboxPlan) envPassesThrough(name string) bool { return false } -// systemCABundle returns the first system CA bundle found, or "". -func systemCABundle() string { - for _, p := range []string{ - "/etc/ssl/certs/ca-certificates.crt", // Debian, Ubuntu, Alpine - "/etc/pki/tls/certs/ca-bundle.crt", // Fedora, RHEL - "/etc/ssl/ca-bundle.pem", // openSUSE - "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", // CentOS/RHEL 7 - "/etc/ssl/cert.pem", // macOS, some BSDs - } { - if _, err := os.Stat(p); err == nil { - return p - } - } - return "" -} - // resolveEnv applies environment policy, sets up shell init files, and writes // an SSH config for ProxyCommand routing through the SOCKS5 proxy. func resolveEnv(plan *SandboxPlan, cfg *config.Config) error { diff --git a/sandbox/proxy_handler.go b/sandbox/proxy_handler.go index ada53cf..fec242f 100644 --- a/sandbox/proxy_handler.go +++ b/sandbox/proxy_handler.go @@ -23,7 +23,8 @@ func buildFilterBase(plan *SandboxPlan) proxy.FilterBase { // bindings are configured. It is shared by the HTTP and SOCKS5 proxies so both // egress paths inject for bound hosts. func buildInjector(plan *SandboxPlan) *proxy.Injector { - if plan.CA == nil || len(plan.InjectBindings) == 0 { + // resolveInject sets CA and InjectBindings together, or neither. + if plan.CA == nil { return nil } inj := proxy.NewInjector(plan.CA) From fbb674d91d0e7557a6a2b36183b973f6693c6c2c Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Thu, 2 Jul 2026 12:25:16 +0100 Subject: [PATCH 27/27] fix: address Copilot review comments on the injection proxy - Use a random 128-bit leaf serial instead of UnixNano, which could collide across leaves signed in the same instant. - Bound the client TLS handshake in Injector.Serve with dialTimeout so a client stalling mid-handshake cannot hang the parent goroutine. The deadline is cleared after the handshake; served requests are unbounded, matching the passthrough relay. - Include the port in the plain-HTTP refusal message: bindings are host:port exact, and net.JoinHostPort formats IPv6 correctly. Written by Claude Code. Co-Authored-By: Claude Opus 4.8 --- proxy/handler.go | 2 +- proxy/inject.go | 13 ++++++++++++- proxy/inject_test.go | 8 +++++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/proxy/handler.go b/proxy/handler.go index 17515e9..de54312 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -119,7 +119,7 @@ func (h *Handler) handleHTTP(w http.ResponseWriter, r *http.Request) { // the real credential. Other ports of the same host are relayed unchanged, // per the port-exact binding contract. if _, bound := h.Injector.binding(host, port); bound { - http.Error(w, "curb: credential injection requires HTTPS for "+host, http.StatusBadGateway) + http.Error(w, "curb: credential injection requires HTTPS for "+net.JoinHostPort(host, port), http.StatusBadGateway) h.logEvent("proxy_http", r.Host, "blocked", "inject-requires-https") return } diff --git a/proxy/inject.go b/proxy/inject.go index 9add741..d46df5c 100644 --- a/proxy/inject.go +++ b/proxy/inject.go @@ -97,9 +97,15 @@ func (ca *CA) signLeaf(host string) (*tls.Certificate, error) { if err != nil { return nil, err } + // A random 128-bit serial per CA/B baseline rules; a time-derived serial + // could collide across leaves signed in the same instant. + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, err + } now := time.Now() tmpl := &x509.Certificate{ - SerialNumber: big.NewInt(now.UnixNano()), + SerialNumber: serial, Subject: pkix.Name{CommonName: host}, NotBefore: now.Add(-time.Hour), NotAfter: ca.cert.NotAfter, @@ -217,9 +223,14 @@ func (in *Injector) Serve(client net.Conn, host, port string, injs []Injection) Certificates: []tls.Certificate{*leaf}, MinVersion: tls.VersionTLS12, }) + // Bound the handshake as upstream dials are (dialTimeout), so a client that + // stalls mid-handshake cannot hang this goroutine; served requests + // afterwards have no deadline, matching the passthrough relay. + _ = client.SetDeadline(time.Now().Add(dialTimeout)) if err := tlsConn.Handshake(); err != nil { return fmt.Errorf("tls handshake: %w", err) } + _ = client.SetDeadline(time.Time{}) defer func() { _ = tlsConn.Close() }() in.serveInjected(tlsConn, host, port, injs) diff --git a/proxy/inject_test.go b/proxy/inject_test.go index c2d2173..0e4bfe7 100644 --- a/proxy/inject_test.go +++ b/proxy/inject_test.go @@ -169,6 +169,11 @@ func TestCA_CertificatesSupportLongSessions(t *testing.T) { require.NotNil(t, leaf.Leaf) assert.Greater(t, leaf.Leaf.NotAfter.Sub(now), 7*24*time.Hour) assert.False(t, leaf.Leaf.NotAfter.After(ca.cert.NotAfter), "leaf must not outlive the CA") + + // Serials are random, not time-derived: two leaves must not collide. + other, err := ca.leafFor("api.example.com") + require.NoError(t, err) + assert.NotEqual(t, leaf.Leaf.SerialNumber, other.Leaf.SerialNumber) } // TestInjector_BindingNormalizesHost confirms bindings match CONNECT targets @@ -320,7 +325,8 @@ func TestInjector_RefusesPlainHTTP(t *testing.T) { require.NoError(t, err) _ = resp.Body.Close() assert.Equal(t, http.StatusBadGateway, resp.StatusCode) - assert.Contains(t, string(body), "requires HTTPS") + // The refusal names the exact bound destination: bindings are host:port. + assert.Contains(t, string(body), fmt.Sprintf("requires HTTPS for localhost:%d", closedPort)) // Plain HTTP to the same host on an unbound port is relayed (and fails only // at dial time, since nothing is listening).