Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions docs/egress.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:<entry name>` next to the
Expand Down
5 changes: 3 additions & 2 deletions docs/sandboxd-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ Auth: `Authorization: Bearer <api_token>` (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
Expand Down Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions e2e/cmd/credsmoke/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions sandboxd/egress/intercept_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
14 changes: 8 additions & 6 deletions sandboxd/egress/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 ""
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion sandboxd/pool/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
14 changes: 14 additions & 0 deletions sandboxd/pool/env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 13 additions & 2 deletions sandboxd/types/env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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"])
Expand Down
15 changes: 10 additions & 5 deletions sandboxd/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const (
maxEnvValueBytes = 8 << 10
maxEnvBytes = 64 << 10
maxInjectHosts = 8
maxPlaceholder = 256
)

var (
Expand Down Expand Up @@ -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.
Expand All @@ -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)
}
Expand All @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions sdk/go/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading