diff --git a/CLAUDE.md b/CLAUDE.md index 2abe523..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) @@ -38,8 +39,10 @@ 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-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/README.md b/README.md index 39a423a..362965b 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 destination: + +``` +GH_TOKEN=ghp_xxx curb --domains api.github.com --inject-header 'GH_TOKEN:api.github.com' -- gh api user +``` + ## CLI reference ### Network @@ -162,6 +169,7 @@ 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-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/help.go b/cmd/help.go index 3faf66c..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", "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 32e2bbb..d038b8b 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 --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 { @@ -329,6 +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.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 d4b1117..c7c94ec 100644 --- a/config/config.go +++ b/config/config.go @@ -21,6 +21,7 @@ type Config struct { EnvSet []string EnvPassthroughAll bool AllowedIPs []string + InjectHeader []string UnrestrictedNet bool NoFSRestrict bool NoExecRestrict bool @@ -32,7 +33,7 @@ type Config struct { Quiet bool DryRun bool Auto bool - ConfigFilePaths []string + ConfigFilePaths []string Command []string } @@ -68,6 +69,10 @@ func FromFlags(cmd *cobra.Command) (*Config, error) { if err != nil { return nil, err } + injectHeader, err := flags.GetStringArray("inject-header") + if err != nil { + return nil, err + } if len(allow) > 0 { if err := policy.ValidateDomains(allow); err != nil { return nil, err @@ -78,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 @@ -117,18 +127,15 @@ 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, AllowedIPs: ips, + InjectHeader: injectHeader, UnrestrictedNet: unrestrictedNet, ROPaths: ro, RWPaths: rw, ExecAllow: execAllow, - EnvPassthrough: passNames, - EnvSet: setPairs, AllowUnixSockets: allowUnixSockets, HostLoopback: hostLoopback, LogFile: logFile, @@ -137,7 +144,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. @@ -149,13 +156,10 @@ func FromFlags(cmd *cobra.Command) (*Config, error) { cfg.NoFSRestrict = true cfg.RWPaths = nil } - if containsStar(passNames) { - cfg.EnvPassthroughAll = true - cfg.EnvPassthrough = nil - } if containsStar(cfg.ROPaths) { cfg.ROPaths = []string{"/"} } + cfg.applyEnvArgs(env) return cfg, nil } @@ -169,6 +173,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 = appendEnvValue(cfg.InjectHeader, "CURB_INJECT_HEADER") roEnv := appendEnvList(nil, "CURB_READ") if containsStar(roEnv) { @@ -193,16 +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 - cfg.EnvPassthrough = nil - } else { - 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. @@ -235,6 +233,38 @@ 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...) +} + +// 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, "*") @@ -248,6 +278,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 @@ -273,4 +311,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..ff42827 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -22,6 +22,7 @@ func newTestCmd(args []string) *cobra.Command { f.StringSlice("exec", nil, "") f.StringSlice("env", nil, "") f.StringSlice("ips", nil, "") + f.StringArray("inject-header", nil, "") f.Bool("unrestricted-net", false, "") f.Bool("allow-unix-sockets", false, "") f.Bool("host-loopback", false, "") @@ -106,6 +107,28 @@ 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 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) @@ -124,6 +147,25 @@ func TestMergeEnv_ListsAdditive(t *testing.T) { assert.Equal(t, []string{"b.com", "a.com"}, cfg.AllowedDomains) } +func TestMergeEnv_InjectHeaderAdditive(t *testing.T) { + cmd := newTestCmd([]string{"--inject-header", "B:b.com"}) + cfg, err := FromFlags(cmd) + require.NoError(t, err) + + t.Setenv("CURB_INJECT_HEADER", "A:a.com,c.com") + MergeEnv(cfg, cmd) + + 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) { cmd := newTestCmd(nil) cfg, err := FromFlags(cmd) @@ -205,6 +247,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) @@ -332,6 +387,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/config/configfile.go b/config/configfile.go index 945cb61..13f91a6 100644 --- a/config/configfile.go +++ b/config/configfile.go @@ -19,6 +19,7 @@ type ConfigFile struct { Profiles []string `yaml:"profiles"` Domains []string `yaml:"domains"` IPs []string `yaml:"ips"` + InjectHeader []string `yaml:"inject-header"` Read pathList `yaml:"read"` Write pathList `yaml:"write"` Exec pathList `yaml:"exec"` @@ -109,6 +110,11 @@ func (cf *ConfigFile) validate() error { return err } } + for i, e := range cf.InjectHeader { + if _, _, err := policy.ParseInjectHeader(e); err != nil { + return fmt.Errorf("inject-header[%d]: %w", i, err) + } + } for _, pair := range [...]struct { field string list pathList @@ -152,6 +158,7 @@ func FindConfigFile() string { func mergeConfigLists(cfg *Config, cf *ConfigFile) { cfg.AllowedDomains = append(cf.Domains, cfg.AllowedDomains...) cfg.AllowedIPs = append(cf.IPs, cfg.AllowedIPs...) + 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..627624b 100644 --- a/config/configfile_test.go +++ b/config/configfile_test.go @@ -152,6 +152,60 @@ func TestLoadConfigFile_NotFound(t *testing.T) { require.Error(t, err) } +func TestLoadConfigFile_InjectHeader(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".curb.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +inject-header: + - 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) +} + +func TestLoadConfigFile_InjectHeaderMissingHost(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".curb.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +inject-header: + - ANTHROPIC_API_KEY +`), 0o644)) + + _, err := LoadConfigFile(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "inject-header") +} + +func TestLoadConfigFile_InjectHeaderWildcard(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".curb.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +inject-header: + - "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) { + dir := t.TempDir() + path := filepath.Join(dir, ".curb.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +inject-header: + - "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(), "not a valid environment variable name") +} + func TestFindConfigFile_InCWD(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, ".curb.yaml") @@ -199,22 +253,24 @@ 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-header", "H:cli.com"}) 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"}, + InjectHeader: []string{"K:file.com"}, + 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{"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) @@ -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/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/config/profile_test.go b/config/profile_test.go index 261e404..fa58d6b 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_ClaudeInjectsApiKey(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 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 injection). + 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..f3ade62 100644 --- a/config/profiles/claude.yaml +++ b/config/profiles/claude.yaml @@ -44,11 +44,17 @@ exec: - /bin - /usr/local/bin - /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: - HOME - SHELL - CLAUDE_CODE_TMPDIR=$TMPDIR - - ANTHROPIC_API_KEY - ANTHROPIC_BASE_URL - CLAUDE_CODE_MAX_TOKENS - GIT_AUTHOR_NAME diff --git a/docs/comparison-zerobox.md b/docs/comparison-zerobox.md index 332a3e1..1d81580 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-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 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-header` 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,46 @@ 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-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. `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`. + +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. +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 +trust — for curb, only on the injected hosts. ## Process isolation @@ -220,8 +251,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-header`) | +| 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 | @@ -247,21 +278,18 @@ 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 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 +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 +`/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..05d7bfb 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-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 @@ -185,6 +185,137 @@ By default, curb sets: And passes through: `PATH`, `TERM`, `COLORTERM`, `NO_COLOR`, `LANG`, `LC_ALL`, `LC_CTYPE`, `TZ`, `USER`, `LOGNAME`, `SHELL`. +## Credential injection + +`--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 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 +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 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. + +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. + +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: +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`) +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. On first run Claude Code may prompt once to approve the +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 an +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 +(`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. 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 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 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. 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 +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: + +```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 +``` + ## HOME and tilde expansion ### How HOME is determined diff --git a/policy/domain.go b/policy/domain.go index df37090..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. @@ -21,7 +24,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 +44,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 +65,23 @@ 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)), ".") } + +// 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 a826cb7..8b9338c 100644 --- a/policy/validate.go +++ b/policy/validate.go @@ -2,7 +2,9 @@ package policy import ( "fmt" + "net" "net/netip" + "strconv" "strings" "unicode" ) @@ -19,46 +21,206 @@ 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)) + case '/', '\\', ':', '@', '#', '?', '=', '[', ']': + 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) } } return nil } +// 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. 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 // canonical host (CanonicalHost form) + 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 +// 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. 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) + if err != nil { + return InjectTarget{}, fmt.Errorf("injection target %q: %w", item, err) + } + host, port = h, canonical + } + 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: CanonicalHost(host), Port: port}, nil +} + +// 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 + } + i := strings.LastIndexByte(item, ':') + if i < 0 || !isAllDigits(item[i+1:]) { + return "", "", false + } + return item[:i], item[i+1:], true +} + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// 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 strconv.Itoa(n), nil +} + +// 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 + } + for i, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '_': + case i > 0 && r >= '0' && r <= '9': + 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/policy/validate_test.go b/policy/validate_test.go index 67ec16f..dd71c66 100644 --- a/policy/validate_test.go +++ b/policy/validate_test.go @@ -51,6 +51,70 @@ func TestValidateDomains(t *testing.T) { } } +func TestParseInjectHeader(t *testing.T) { + tests := []struct { + name string + entry string + wantEnv string + wantTargets []InjectTarget + wantErr string + }{ + {"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"}}, ""}, + {"ipv4 with port", "TOK:10.0.0.5:8443", "TOK", + []InjectTarget{{Host: "10.0.0.5", Port: "8443"}}, ""}, + {"bare ipv6", "TOK:2001:db8::1", "TOK", + []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", + []InjectTarget{{Host: "api.example.com", Port: "443"}}, ""}, + + {"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) { + env, targets, err := ParseInjectHeader(tt.entry) + if tt.wantErr == "" { + require.NoError(t, err) + assert.Equal(t, tt.wantEnv, env) + assert.Equal(t, tt.wantTargets, targets) + } else { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } + }) + } +} + +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 bdd6cb4..de54312 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -9,13 +9,39 @@ 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 +} + +// 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. @@ -43,17 +69,15 @@ func (h *Handler) handleCONNECT(w http.ResponseWriter, r *http.Request) { return } - // Hijack the client connection. - hijacker, ok := w.(http.Hijacker) - if !ok { - http.Error(w, "curb: hijack unsupported", http.StatusInternalServerError) + // 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, port); ok { + h.injectCONNECT(w, host, port, injs) 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()) + + clientConn, ok := h.hijack(w, "proxy_connect", r.Host) + if !ok { return } defer func() { _ = clientConn.Close() }() @@ -67,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 } @@ -90,6 +114,16 @@ func (h *Handler) handleHTTP(w http.ResponseWriter, r *http.Request) { return } + // 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 "+net.JoinHostPort(host, port), 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) @@ -100,20 +134,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. @@ -124,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() }() @@ -143,6 +156,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 new file mode 100644 index 0000000..d46df5c --- /dev/null +++ b/proxy/inject.go @@ -0,0 +1,315 @@ +package proxy + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "io" + "log" + "math/big" + "net" + "net/http" + "net/http/httputil" + "strings" + "sync" + "time" + + "github.com/upsun/curb/policy" +) + +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 +// 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(caValidity), + 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 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) { + 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.leaves[host] = leaf + return leaf, nil +} + +// 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 + } + // 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: serial, + Subject: pkix.Name{CommonName: host}, + NotBefore: now.Add(-time.Hour), + NotAfter: ca.cert.NotAfter, + 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 + } + leafCert, err := x509.ParseCertificate(der) + if err != nil { + return nil, err + } + return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leafCert}, nil +} + +// 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 { + Placeholder string + Value string +} + +// 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 + // real origin over a fresh TLS connection. Defaults to system-roots TLS; + // tests override it to reach local servers. + Upstream http.RoundTripper + + byTarget map[string][]Injection // key: net.JoinHostPort(host, port) +} + +// 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{ + DialContext: (&net.Dialer{Timeout: dialTimeout}).DialContext, + TLSHandshakeTimeout: dialTimeout, + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + }, + byTarget: map[string][]Injection{}, + } +} + +// Bind adds a header injection for a destination host:port. Multiple headers +// 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(policy.CanonicalHost(host), port) + in.byTarget[key] = append(in.byTarget[key], inj) +} + +// 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.byTarget[net.JoinHostPort(policy.CanonicalHost(host), port)] + 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) { + clientConn, ok := h.hijack(w, "proxy_inject", host) + if !ok { + return + } + defer func() { _ = clientConn.Close() }() + + if _, err := clientConn.Write([]byte(connEstablished)); err != nil { + return + } + + 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 { + // 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 = policy.CanonicalHost(host) + leaf, err := in.CA.leafFor(host) + if err != nil { + return fmt.Errorf("leaf: %w", err) + } + tlsConn := tls.Server(client, &tls.Config{ + 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) + return nil +} + +// 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) { + // 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 := 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, + // 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: discardLog, + } + // 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) +} + +// 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 + onClose func() +} + +func (c *signalOnCloseConn) Close() error { + err := c.Conn.Close() + c.once.Do(c.onClose) + return err +} + +// 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 { + v = strings.ReplaceAll(v, inj.Placeholder, inj.Value) + } + values[i] = v + } + } +} diff --git a/proxy/inject_test.go b/proxy/inject_test.go new file mode 100644 index 0000000..0e4bfe7 --- /dev/null +++ b/proxy/inject_test.go @@ -0,0 +1,590 @@ +package proxy + +import ( + "bufio" + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "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 + hosts []string +} + +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) + handler(w, r) + })) + 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 +} + +// 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 { + allowed[host] = true + dialMap[net.JoinHostPort(host, "443")] = rec + } + + injector := NewInjector(ca) + for host, injs := range bindings { + for _, inj := range injs { + injector.Bind(host, "443", 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 + }, + } + 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, + } + 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. 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()) + transport := &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + } + t.Cleanup(transport.CloseIdleConnections) + return &http.Client{Transport: transport} +} + +// 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) + 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() }() + require.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + 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") + + // 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 +// regardless of case or a trailing dot, and only on the bound port. +func TestInjector_BindingNormalizesHost(t *testing.T) { + 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, "443") + require.True(t, ok, "expected binding to match %q", host) + assert.Equal(t, "real", injs[0].Value) + } + + // 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) +} + +// 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][]Injection{"api.github.com": {{Placeholder: "GH_PH", Value: "ghs_realtoken"}}}, + map[string]*authRecorder{"api.github.com": gh}, + ) + client := clientThroughProxy(t, 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_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][]Injection{"api.github.com": {{Placeholder: "GH_PH", Value: "ghs_realtoken"}}}, + map[string]*authRecorder{"api.github.com": gh}, + ) + client := clientThroughProxy(t, proxyURL, ca) + + 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]) +} + +// 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(t, proxyURL, ca) + + 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) + } +} + +// TestInjector_BindsCredentialToDestination is the central correctness +// 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) + + gh := newAuthRecorder(t) + other := newAuthRecorder(t) + proxyURL := injectTestProxy(t, ca, + 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, + "api.example.com": other, + }, + ) + client := clientThroughProxy(t, proxyURL, ca) + + // Send github's placeholder to both hosts: only github substitutes it. + 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()) + assert.Equal(t, "Bearer ghs_realtoken", gh.got("Authorization")[0]) + // 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_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) + + // 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 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)}} + + // 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) + // 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). + 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 +// 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]) +} + +// 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 := newAuthRecorderWith(t, func(w http.ResponseWriter, r *http.Request) { + 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() + }) + + 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 := 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") + }) + + 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. 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 := 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 + }) + + 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) + + // 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 +// 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][]Injection{"api.github.com": {{Placeholder: "GH_PH", Value: "ghs_realtoken"}}}, + map[string]*authRecorder{"api.github.com": gh}, + ) + client := clientThroughProxy(t, proxyURL, ca) + + // No placeholder present: nothing is substituted, and the value is intact. + 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/proxy/socks5.go b/proxy/socks5.go index b28a6a1..62c5806 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,21 @@ func (s *SOCKS5Server) HandleConn(conn net.Conn) { return } - // 4. Dial the destination. + // 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, port); 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 +130,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/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/capabilities_linux_test.go b/sandbox/capabilities_linux_test.go index edcfe7d..57cc0ff 100644 --- a/sandbox/capabilities_linux_test.go +++ b/sandbox/capabilities_linux_test.go @@ -219,6 +219,35 @@ 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{ + AllowedDomains: []string{"api.example.com"}, + InjectHeader: []string{"CURB_DRYRUN_TOKEN:api.example.com"}, + } + + 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") + 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.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_integration_test.go b/sandbox/inject_integration_test.go new file mode 100644 index 0000000..d58b9da --- /dev/null +++ b/sandbox/inject_integration_test.go @@ -0,0 +1,296 @@ +//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_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{} + 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) + // $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( + "echo \" ph=$DEMO_TOKEN\"; "+ + "curl -sf --connect-timeout 10 -H \"Authorization: Bearer $DEMO_TOKEN\" %s; echo \" a=$?\"", + url) + + cmd := exec.Command(curbBin, "--write", "*", "--exec", "*", + "--host-loopback", + "--domains", "localhost", + "--inject-header", "DEMO_TOKEN:localhost:"+port, + "--", "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", "request should succeed (sandbox trusts the per-run CA)") + 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") + require.Len(t, seen, 1) + 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) { + 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: $DEMO_KEY\" %s; echo \" a=$?\"", url) + + cmd := exec.Command(curbBin, "--write", "*", "--exec", "*", + "--host-loopback", + "--domains", "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", + "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 placeholder replaced with real value") +} diff --git a/sandbox/inject_test.go b/sandbox/inject_test.go new file mode 100644 index 0000000..cf6a0a8 --- /dev/null +++ b/sandbox/inject_test.go @@ -0,0 +1,393 @@ +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/upsun/curb/config" + "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", "T:b.example.com:8443"}) + require.NoError(t, err) + require.Len(t, specs, 2) + assert.Equal(t, "ANTHROPIC_API_KEY", specs[0].envVar) + 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) + + _, err = parseInjectHeader([]string{"bad-var:h.com"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--inject-header ") +} + +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") + + // 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) +} + +// 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{}, + } + + 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) +} + +func TestResolveInjectRequiresAllowedDomain(t *testing.T) { + t.Setenv("ACTIVE_TOKEN", "secret") + plan := &SandboxPlan{ + TempDir: t.TempDir(), + EnvSet: map[string]string{}, + ProxyEnabled: true, + } + + 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) + 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, + } + + 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"]) +} + +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, + } + cfg := injectCfg("ACTIVE_TOKEN:api.example.com") + cfg.EnvPassthrough = []string{"ACTIVE_TOKEN"} + + require.NoError(t, resolveInject(plan, cfg)) + assert.Empty(t, plan.InjectBindings) + assert.Nil(t, plan.CA) + _, set := plan.EnvSet["ACTIVE_TOKEN"] + 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. +func TestResolveInjectExplicitEnvValueSkipsInjection(t *testing.T) { + t.Setenv("ACTIVE_TOKEN", "host-secret") + plan := &SandboxPlan{ + TempDir: t.TempDir(), + EnvSet: map[string]string{"ACTIVE_TOKEN": "user-value"}, + AllowedDomains: []string{"api.example.com"}, + ProxyEnabled: true, + } + cfg := injectCfg("ACTIVE_TOKEN:api.example.com") + cfg.EnvSet = []string{"ACTIVE_TOKEN=user-value"} + + 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"]) +} + +// TestResolveInjectWithoutProxySuggestsPassthrough confirms the planning error +// 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{}, + AllowedDomains: []string{"api.example.com"}, + } + + 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") +} + +// 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. +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. +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, + } + + 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") +} + +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, + } + cfg := injectCfg("ACTIVE_TOKEN:api.example.com") + cfg.EnvPassthrough = []string{"ACTIVE_*"} + + 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 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() + 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, + } + + 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) + assert.Contains(t, string(data), "CUSTOMROOT") + }) + } +} + +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, + } + + 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") + + 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") + } + 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, + } + 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`) + + // 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, cfg)) + assert.NotNil(t, plan.CA) + assert.Contains(t, plan.InjectBindings, policy.InjectTarget{Host: "10.0.0.5", Port: "8443"}) +} + +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 := writeCABundleFile(dir, "ca-bundle.pem", "", caPEM) + require.Error(t, err) + return + } + + path, err := writeCABundleFile(dir, "ca-bundle.pem", "", 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 appended after the system roots. + 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 := writeCABundleFile(dir, "ca-bundle.pem", 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/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 9054034..66aeee4 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 destinations; InjectBindings maps a + // destination to the credential headers the proxy attaches to it. + CA *proxy.CA + InjectBindings map[policy.InjectTarget][]proxy.Injection } // LandlockPaths returns the path sets for Landlock rule construction. @@ -183,9 +190,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 := "" @@ -572,6 +585,25 @@ func appendUniq(s []string, v string) []string { return s } +// 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 true + } + for _, pat := range p.EnvPassthrough { + if matched, _ := filepath.Match(pat, name); matched { + return true + } + } + return false +} + // 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 { @@ -648,31 +680,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 @@ -774,6 +788,13 @@ func (p *SandboxPlan) PrintDryRun(w io.Writer) { ln(" localhost: sandbox-internal (NO_PROXY)") } ln(" blocked: everything else") + if len(p.InjectBindings) > 0 { + 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 p.injectDestinations() { + pr(" %s\n", d) + } + pr(" ca-trust: per-run CA bundle in env (%s)\n", strings.Join(caBundleEnvKeys, ", ")) + } } // Environment. @@ -794,11 +815,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) @@ -896,11 +914,26 @@ 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 + 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 + plan.EnvSet["all_proxy"] = socksURL + } plan.EnvSet[SOCKSAddrEnvKey] = socksAddr } } @@ -1216,6 +1249,14 @@ func resolveSymlinks(paths []string) []string { func buildDegradedPlan(cfg *config.Config, caps *Capabilities) (*SandboxPlan, error) { plan := &SandboxPlan{Caps: caps} + injects, err := parseInjectHeader(cfg.InjectHeader) + if err != nil { + 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, injectEnvFlags(active)) + } + 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..b758ddc 100644 --- a/sandbox/plan_darwin.go +++ b/sandbox/plan_darwin.go @@ -46,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.UnrestrictedNet - plan.ProxyEnabled = hasFiltering + plan.ProxyEnabled = proxyFilteringEnabled(cfg) // Seatbelt enforcement. plan.UseSeatbelt = true @@ -66,6 +65,9 @@ func (darwinPlanBuilder) BuildPlan(cfg *config.Config, caps *Capabilities, logge if err := resolveEnv(plan, cfg); err != nil { return nil, err } + if err := resolveInject(plan, cfg); 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..fcdc35f 100644 --- a/sandbox/plan_darwin_test.go +++ b/sandbox/plan_darwin_test.go @@ -9,6 +9,8 @@ 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) { @@ -82,3 +84,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, policy.InjectTarget{Host: "api.example.com", Port: "443"}) + 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 90c588f..1f454ce 100644 --- a/sandbox/plan_linux.go +++ b/sandbox/plan_linux.go @@ -51,6 +51,9 @@ func (linuxPlanBuilder) BuildPlan(cfg *config.Config, caps *Capabilities, logger if err := resolveEnv(plan, cfg); err != nil { return nil, err } + if err := resolveInject(plan, cfg); 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..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) { @@ -189,6 +215,44 @@ func TestBuildPlan_NoLandlock_NoFSRestrict(t *testing.T) { defer plan.Cleanup() } +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") + + 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) { cfg := &config.Config{NoExecRestrict: true} plan, err := BuildPlan(cfg, minCaps(), nil) @@ -996,7 +1060,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..fec242f 100644 --- a/sandbox/proxy_handler.go +++ b/sandbox/proxy_handler.go @@ -19,16 +19,34 @@ func buildFilterBase(plan *SandboxPlan) proxy.FilterBase { return fb } -// buildProxyHandler creates the HTTP proxy handler from the sandbox plan. -func buildProxyHandler(plan *SandboxPlan) *proxy.Handler { - return &proxy.Handler{ - FilterBase: buildFilterBase(plan), +// 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 { + // resolveInject sets CA and InjectBindings together, or neither. + if plan.CA == nil { + return nil + } + inj := proxy.NewInjector(plan.CA) + for target, injections := range plan.InjectBindings { + for _, injection := range injections { + inj.Bind(target.Host, target.Port, injection) + } } + return inj +} + +// 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: injector, } } diff --git a/sandbox/skill.go b/sandbox/skill.go index 3aed539..3b3da43 100644 --- a/sandbox/skill.go +++ b/sandbox/skill.go @@ -68,6 +68,9 @@ 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 { + 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") }