diff --git a/docs/egress.md b/docs/egress.md index c1f47ebb..2e452baf 100644 --- a/docs/egress.md +++ b/docs/egress.md @@ -303,10 +303,23 @@ config: ``` On an intercepted request to one of `hosts`, the proxy sets `header` to the -entry's value, overwriting whatever the guest sent, so the guest can hold a -placeholder. The entry rides the claim request or any `/env` write, and -rotation is the same `PATCH`, accepted on a hibernated or archived claim -without waking it. +entry's value, overwriting whatever the guest sent. With `placeholder` set, +it does so only on requests whose `header` carries exactly that value and +leaves every other request to those hosts untouched, so a tool opts in per +request while a browser on the same host gets nothing: + +```jsonc +"GH_GIT": { "value": "Basic …", "guest": false, + "inject": { "hosts": ["github.com"], "header": "Authorization", "placeholder": "Basic @GH_GIT@" } } +``` + +```sh +git config --global http.https://github.com/.extraHeader "Authorization: Basic @GH_GIT@" +``` + +The placeholder is not a secret: it stays in the guest and in `GET /env`. +The entry rides the claim request or any `/env` write, and rotation is the +same `PATCH`, accepted on a hibernated or archived claim without waking it. - **Envelope.** The operator still decides which hosts can carry a credential: every host must be covered by an `intercept: true` rule of the @@ -315,12 +328,13 @@ without waking it. plaintext forward request. - **Precedence.** The pool rule's own `secret` is set first and wins: a credential naming that header for a host the rule covers is a 400. Among - credentials for the same header, the first by entry name wins. An empty - value injects nothing. + credentials for the same header, the first by entry name whose placeholder + (if any) the request carries wins. An empty value injects nothing. - **Shape.** `hosts` holds 1 to 8 lowercase names or `*.suffix` patterns (no bare `*`, no port); `header` is any header name except `Host`, - `Content-Length` and the hop-by-hop ones. `GET /env` returns `inject` with - the value blanked. + `Content-Length` and the hop-by-hop ones; `placeholder`, when set, is at most + 256 bytes of header value without control characters or surrounding space. + `GET /env` returns `inject` with the value blanked. - **Reload.** A reload that drops the covering intercept rule stops that injection on the next request and logs which claims lost which hosts. - **Audit.** An injection is recorded as `claim:` next to the diff --git a/docs/sandboxd-api.md b/docs/sandboxd-api.md index dd4989d2..d697d5ae 100644 --- a/docs/sandboxd-api.md +++ b/docs/sandboxd-api.md @@ -73,7 +73,8 @@ Auth: `Authorization: Bearer ` (when configured). host-only value in the guest. `guest` (default `true`) delivers the entry into the guest; a `guest: false` entry never enters it and only feeds the node's egress [secrets](egress.md#claim-env-and-secrets), or, with `inject` - (`{"hosts": [...], "header": "..."}`), becomes a + (`{"hosts": [...], "header": "...", "placeholder": "..."}`, `placeholder` + optional), becomes a [claim credential](egress.md#claim-credentials) the proxy sends to those hosts. A bad entry answers 400, and an `inject` host the claim's egress does not intercept answers 400 with the claim's VM released; see @@ -729,7 +730,7 @@ values: {"env": {"MODE": {"value": "prod"}, "GW_KEY": {"value": "", "guest": false}}} ``` -An entry with `inject` comes back with its hosts and header, value blanked. +An entry with `inject` comes back with its hosts, header and placeholder, value blanked. An `inject` host the claim's pool does not intercept, or its tenant class does not allow, and a header the pool rule's own secret already sets on that host, answer 400 and store nothing. diff --git a/e2e/cmd/credsmoke/main.go b/e2e/cmd/credsmoke/main.go index eda6fb98..c8683353 100644 --- a/e2e/cmd/credsmoke/main.go +++ b/e2e/cmd/credsmoke/main.go @@ -145,6 +145,10 @@ func run(addr, token, template, echo, secret, sse string) error { if env, err = b.Env(ctx); err != nil || len(env) != 1 { return fmt.Errorf("env after refused patches %v %v, want only the original entry", env, err) } + if err = expectPlaceholder(ctx, b, echo, "ph-"+stamp); err != nil { + return err + } + fmt.Println(" a placeholder credential reached the origin only on the request that carried its placeholder") if err = b.SetEnv(ctx, map[string]sandbox.EnvVar{}); err != nil { return fmt.Errorf("clear: %w", err) } @@ -195,6 +199,27 @@ func credential(echo, value string) map[string]sandbox.EnvVar { return map[string]sandbox.EnvVar{entry: {Value: value, Guest: new(false), Inject: &sandbox.EnvInject{Hosts: []string{echo}, Header: header}}} } +func expectPlaceholder(ctx context.Context, sb *sandbox.Sandbox, echo, value string) error { + entry := &sandbox.EnvVar{Value: value, Guest: new(false), Inject: &sandbox.EnvInject{Hosts: []string{echo}, Header: "X-Ph-Key", Placeholder: "@PH_KEY@"}} + if err := sb.PatchEnv(ctx, map[string]*sandbox.EnvVar{"PH_KEY": entry}); err != nil { + return fmt.Errorf("placeholder inject: %w", err) + } + for sent, want := range map[string]bool{"": false, "@PH_KEY@": true, "@OTHER@": false} { + header := "" + if sent != "" { + header = fmt.Sprintf("-H 'X-Ph-Key: %s' ", sent) + } + out, err := sb.Exec(ctx, "sh", "-c", fmt.Sprintf("curl -sS -x %s %shttps://%s/headers", proxy, header, echo)) + if err != nil { + return fmt.Errorf("placeholder exec: %w (%s)", err, out) + } + if strings.Contains(out, value) != want { + return fmt.Errorf("guest sent X-Ph-Key %q: origin saw the credential %v, want %v:\n%s", sent, !want, want, out) + } + } + return nil +} + func expectHeader(ctx context.Context, sb *sandbox.Sandbox, echo, want, not, secret string) error { out, err := sb.Exec(ctx, "sh", "-c", fmt.Sprintf("curl -sS -x %s -H '%s: placeholder' https://%s/headers", proxy, header, echo)) if err != nil { diff --git a/sandboxd/egress/intercept_test.go b/sandboxd/egress/intercept_test.go index 8acd09fd..2f5a8ae8 100644 --- a/sandboxd/egress/intercept_test.go +++ b/sandboxd/egress/intercept_test.go @@ -291,6 +291,39 @@ func TestInterceptInjectsClaimCredentialsBelowThePoolSecret(t *testing.T) { } } +func TestInterceptInjectsAPlaceholderCredentialOnlyWhereTheGuestSentIt(t *testing.T) { + secrets := credSecrets{creds: map[string][]Credential{"example.com": { + {Name: "GIT", Header: "X-Api-Key", Value: "real", Placeholder: "@GIT@"}, + }}} + p, guestRoots, upstream := interceptProxySecrets(t, Policy{Allow: []Rule{{Host: "example.com", Intercept: true}}}, secrets, nil) + p.pools.Load().mitm.TLSClientConfig.RootCAs = trustUpstream(upstream) + front := httptest.NewServer(p) + defer front.Close() + + for sent, want := range map[string]string{"@GIT@": "real", "": "", "@OTHER@": "@OTHER@"} { + tc := connectTLS(t, front.Listener.Addr().String(), "example.com:443", "example.com", guestRoots) + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://example.com/x", nil) + if err != nil { + t.Fatalf("build request: %v", err) + } + if sent != "" { + req.Header.Set("X-Api-Key", sent) + } + if err = req.Write(tc); err != nil { + t.Fatalf("write request: %v", err) + } + resp, err := http.ReadResponse(bufio.NewReader(tc), req) + if err != nil { + t.Fatalf("read response: %v", err) + } + _ = resp.Body.Close() + _ = tc.Close() + if got := resp.Header.Get("X-Key-Seen"); got != want { + t.Errorf("guest sent %q: upstream saw %q, want %q", sent, got, want) + } + } +} + func TestInterceptStreamsAnEventStreamAsItArrives(t *testing.T) { release := make(chan struct{}) upstream := httptest.NewTLSServer(sseHandler(release)) diff --git a/sandboxd/egress/proxy.go b/sandboxd/egress/proxy.go index 21f5759a..fc2ac8cc 100644 --- a/sandboxd/egress/proxy.go +++ b/sandboxd/egress/proxy.go @@ -46,11 +46,12 @@ type TransferFunc func(ev Event, sent, received int64) // AuditFunc receives every egress decision the proxy takes. type AuditFunc func(Event) -// Credential is a claim-supplied header the proxy sets on an intercepted request. +// Credential is a claim-supplied header the proxy sets on an intercepted request; a set Placeholder must be the guest's value for that header. type Credential struct { - Name string - Header string - Value string + Name string + Header string + Value string + Placeholder string } // Secrets resolves a rule's Secret name to the header it injects, and a host to the claim's own credentials for it. @@ -396,7 +397,7 @@ func (p *Proxy) reportTransfer(ev Event, sent, received int64) { } } -// inject sets the rule's secret, then on an intercepted request each claim credential whose header is still unset; guest values are overwritten. +// inject sets the rule's secret, then on an intercepted request each claim credential whose header is still unset and whose placeholder, if any, the guest sent. func (p *Proxy) inject(rule Rule, h http.Header, host string, mitm bool) string { if p.secrets == nil { return "" @@ -412,7 +413,8 @@ func (p *Proxy) inject(rule Rule, h http.Header, host string, mitm bool) string return strings.Join(names, ",") } for _, c := range p.secrets.Credentials(strings.ToLower(host)) { - if c.Value == "" || slices.ContainsFunc(set, func(s string) bool { return strings.EqualFold(s, c.Header) }) { + if c.Value == "" || (c.Placeholder != "" && h.Get(c.Header) != c.Placeholder) || + slices.ContainsFunc(set, func(s string) bool { return strings.EqualFold(s, c.Header) }) { continue } h.Set(c.Header, c.Value) diff --git a/sandboxd/pool/env.go b/sandboxd/pool/env.go index d5d39ffd..02f78ca7 100644 --- a/sandboxd/pool/env.go +++ b/sandboxd/pool/env.go @@ -188,7 +188,7 @@ func (c claimSecrets) Header(name string) (header, value string, ok bool) { func (c claimSecrets) Credentials(host string) []egress.Credential { var out []egress.Credential for name, v := range c.sb.Injections(host) { - out = append(out, egress.Credential{Name: name, Header: v.Inject.Header, Value: v.Value}) + out = append(out, egress.Credential{Name: name, Header: v.Inject.Header, Value: v.Value, Placeholder: v.Inject.Placeholder}) } return out } diff --git a/sandboxd/pool/env_test.go b/sandboxd/pool/env_test.go index 9222e4c0..6076bfb9 100644 --- a/sandboxd/pool/env_test.go +++ b/sandboxd/pool/env_test.go @@ -575,6 +575,20 @@ func TestInjectsSurviveARestartAndAReloadThatDropsTheInterceptStopsThem(t *testi } } +func TestAPlaceholderRidesTheEnvWriteIntoTheCredential(t *testing.T) { + eng := newFakeEngine() + eng.sockRoot = sockRoot(t) + m := egressManager(t, eng, config.PoolSpec{PoolKey: testKey, Warm: 1, Egress: interceptPolicy()}) + sb := mustClaim(t, m, testKey) + patch := types.EnvPatch{"KEY": {Value: "k", Guest: hostOnly, Inject: &types.EnvInject{Hosts: []string{"api.github.com"}, Header: "X-Api-Key", Placeholder: "@KEY@"}}} + if err := m.PatchEnv(t.Context(), sb.ID, patch, ""); err != nil { + t.Fatalf("inject with a placeholder: %v", err) + } + if creds := (claimSecrets{m: m, sb: sb}).Credentials("api.github.com"); len(creds) != 1 || creds[0].Placeholder != "@KEY@" { + t.Errorf("credentials %+v, want one carrying the placeholder", creds) + } +} + func TestALostInterceptRefusesOnlyAChangedInject(t *testing.T) { eng := newFakeEngine() eng.sockRoot = sockRoot(t) diff --git a/sandboxd/types/env_test.go b/sandboxd/types/env_test.go index b202f8c4..8dfacd77 100644 --- a/sandboxd/types/env_test.go +++ b/sandboxd/types/env_test.go @@ -98,6 +98,9 @@ func TestEnvInjectValidates(t *testing.T) { inject := func(header string, hosts ...string) Env { return Env{"K": {Value: "v", Guest: new(false), Inject: &EnvInject{Hosts: hosts, Header: header}}} } + placeholder := func(p string) Env { + return Env{"K": {Value: "v", Guest: new(false), Inject: &EnvInject{Hosts: []string{"a.test"}, Header: "Authorization", Placeholder: p}}} + } tests := []struct { name string env Env @@ -111,6 +114,10 @@ func TestEnvInjectValidates(t *testing.T) { {"bare wildcard", inject("X-Key", "*"), "lowercase host name"}, {"host with a port", inject("X-Key", "a.test:443"), "lowercase host name"}, {"bad header", inject("X Key", "a.test"), "not a valid header name"}, + {"placeholder", placeholder("Bearer @GH_TOKEN@"), ""}, + {"placeholder with a newline", placeholder("@A@\r\nX-Evil: 1"), "inject placeholder"}, + {"placeholder with surrounding space", placeholder(" @A@"), "inject placeholder"}, + {"placeholder too long", placeholder(strings.Repeat("p", 257)), "inject placeholder"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -131,8 +138,12 @@ func TestEnvInjectMatchesAndCompares(t *testing.T) { } a := Env{"K": {Value: "v", Guest: new(false), Inject: inj}} b := Env{"K": {Value: "v", Guest: new(false), Inject: &EnvInject{Hosts: []string{"api.example.com"}, Header: "X-Key"}}} - if a.Equal(b) || !a.Equal(Env{"K": {Value: "v", Guest: new(false), Inject: inj}}) || !a.SameGuest(b) { - t.Error("Equal must see an inject change, and SameGuest must not") + c := Env{"K": {Value: "v", Guest: new(false), Inject: &EnvInject{Hosts: inj.Hosts, Header: "X-Key", Placeholder: "@K@"}}} + if a.Equal(b) || a.Equal(c) || !a.Equal(Env{"K": {Value: "v", Guest: new(false), Inject: inj}}) || !a.SameGuest(b) { + t.Error("Equal must see an inject or placeholder change, and SameGuest must not") + } + if fresh := c.InjectsChanged(a); len(fresh) != 1 { + t.Errorf("InjectsChanged = %v, want the entry whose placeholder changed", fresh) } if red := a.Redacted(); red["K"].Value != "" || red["K"].Inject != inj { t.Errorf("Redacted = %+v, want the value dropped and the inject kept", red["K"]) diff --git a/sandboxd/types/types.go b/sandboxd/types/types.go index 9351d746..93cbd0c1 100644 --- a/sandboxd/types/types.go +++ b/sandboxd/types/types.go @@ -71,6 +71,7 @@ const ( maxEnvValueBytes = 8 << 10 maxEnvBytes = 64 << 10 maxInjectHosts = 8 + maxPlaceholder = 256 ) var ( @@ -219,10 +220,11 @@ func (md Metadata) encodedSize() int { return size } -// EnvInject sends a host-only entry's value as Header on intercepted requests to Hosts, each exact or "*." suffix. +// EnvInject sends a host-only entry's value as Header on intercepted requests to Hosts, each exact or "*." suffix; a set Placeholder limits it to requests whose Header carries exactly that value. type EnvInject struct { - Hosts []string `json:"hosts"` - Header string `json:"header"` + Hosts []string `json:"hosts"` + Header string `json:"header"` + Placeholder string `json:"placeholder,omitempty"` } // Matches reports whether the lowercased host is one of the inject hosts. @@ -247,11 +249,14 @@ func (i *EnvInject) validate(name string) error { return fmt.Errorf("env %s: inject host %q must be a lowercase host name or *.suffix", name, h) } } + if len(i.Placeholder) > maxPlaceholder || !httpguts.ValidHeaderFieldValue(i.Placeholder) || strings.TrimSpace(i.Placeholder) != i.Placeholder { + return fmt.Errorf("env %s: inject placeholder must be at most %d bytes of header value without control characters or surrounding space", name, maxPlaceholder) + } return nil } func (i *EnvInject) size() int { - n := len(i.Header) + n := len(i.Header) + len(i.Placeholder) for _, h := range i.Hosts { n += len(h) } @@ -262,7 +267,7 @@ func (i *EnvInject) equal(other *EnvInject) bool { if i == nil || other == nil { return i == other } - return i.Header == other.Header && slices.Equal(i.Hosts, other.Hosts) + return i.Header == other.Header && i.Placeholder == other.Placeholder && slices.Equal(i.Hosts, other.Hosts) } // EnvVar is one entry of a claim's environment; a nil Guest means the entry reaches the guest. diff --git a/sdk/go/options.go b/sdk/go/options.go index a2022df9..840f00b6 100644 --- a/sdk/go/options.go +++ b/sdk/go/options.go @@ -47,10 +47,11 @@ type VolumeInfo struct { Writable bool `json:"writable,omitzero"` } -// EnvInject makes the node's egress proxy send a host-only entry's value as Header on intercepted requests to Hosts, each exact or "*." suffix. +// EnvInject makes the node's egress proxy send a host-only entry's value as Header on intercepted requests to Hosts, each exact or "*." suffix; a set Placeholder limits it to requests whose Header carries exactly that value. type EnvInject struct { - Hosts []string `json:"hosts"` - Header string `json:"header"` + Hosts []string `json:"hosts"` + Header string `json:"header"` + Placeholder string `json:"placeholder,omitempty"` } // EnvVar is one entry of a claim's env; a nil Guest delivers it into the guest, false keeps it host-side for the node's egress.