From 68bc97edb386361bfd2ceea361aeaf587e06b20d Mon Sep 17 00:00:00 2001 From: CMGS Date: Thu, 9 Jul 2026 10:44:59 +0800 Subject: [PATCH] feat(egress): host-side guarded-egress proxy (policy + forward proxy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of M6-6 guarded egress: a self-contained egress package with the allow-list policy schema + evaluator and a host-side forward proxy — CONNECT tunnelling (host allow/deny) plus absolute-form HTTP with node-side credential injection and typed 403 denials. Stdlib-only and not yet wired into the claim path; config, per-sandbox listeners, the silkd egress_dial verb, and nftables enforcement land in follow-up slices. --- sandboxd/egress/policy.go | 89 +++++++++++++++ sandboxd/egress/policy_test.go | 67 +++++++++++ sandboxd/egress/proxy.go | 195 ++++++++++++++++++++++++++++++++ sandboxd/egress/proxy_test.go | 200 +++++++++++++++++++++++++++++++++ 4 files changed, 551 insertions(+) create mode 100644 sandboxd/egress/policy.go create mode 100644 sandboxd/egress/policy_test.go create mode 100644 sandboxd/egress/proxy.go create mode 100644 sandboxd/egress/proxy_test.go diff --git a/sandboxd/egress/policy.go b/sandboxd/egress/policy.go new file mode 100644 index 0000000..37c5e95 --- /dev/null +++ b/sandboxd/egress/policy.go @@ -0,0 +1,89 @@ +// Package egress is the host-side guarded-egress data plane: a forward proxy +// a sandbox reaches as its only route out. Every request is evaluated against +// a per-sandbox policy (domain allow-list, methods) before it leaves the node, +// and a matched rule may inject a node-side credential the guest never holds. +package egress + +import ( + "fmt" + "strings" +) + +const ( + DecisionDeny Decision = iota + DecisionAllow +) + +// Decision is the policy verdict for one request. +type Decision int + +// Rule allows requests whose host matches Host — an exact name, a +// "*."-prefixed suffix wildcard ("*.example.com" matches sub.example.com but +// not the apex), or "*" for any host — and whose method is in Methods (empty +// means any). Secret names a node-side credential the proxy injects into a +// matched request; empty injects nothing. Host matching is case-insensitive. +type Rule struct { + Host string `json:"host"` + Methods []string `json:"methods,omitempty"` + Secret string `json:"secret,omitempty"` //nolint:gosec // reference name of a node-side secret, never a value +} + +func (r Rule) matches(host, method string) bool { + return r.matchHost(host) && r.matchMethod(method) +} + +func (r Rule) matchHost(host string) bool { + host = strings.ToLower(host) + switch { + case r.Host == "*": + return true + case strings.HasPrefix(r.Host, "*."): + return strings.HasSuffix(host, strings.ToLower(r.Host[1:])) + default: + return host == strings.ToLower(r.Host) + } +} + +func (r Rule) matchMethod(method string) bool { + if len(r.Methods) == 0 { + return true + } + for _, m := range r.Methods { + if strings.EqualFold(m, method) { + return true + } + } + return false +} + +// Policy is one sandbox's egress allow-list. Default-deny is implicit: a +// request matching no Allow rule is denied. +type Policy struct { + Allow []Rule `json:"allow"` +} + +// Validate rejects a rule with an empty or bare-wildcard host; an empty +// allow-list is valid (it denies everything). +func (p Policy) Validate() error { + for i, r := range p.Allow { + switch r.Host { + case "": + return fmt.Errorf("allow[%d]: host must not be empty", i) + case "*.": + return fmt.Errorf("allow[%d]: host %q needs a domain after the wildcard", i, r.Host) + } + } + return nil +} + +// Eval returns the first matching allow rule with DecisionAllow, or a zero +// rule with DecisionDeny when nothing matches. host is a bare hostname (no +// port); the proxy strips the port before calling. +func (p Policy) Eval(host, method string) (Rule, Decision) { + for _, r := range p.Allow { + if r.matches(host, method) { + return r, DecisionAllow + } + } + return Rule{}, DecisionDeny +} diff --git a/sandboxd/egress/policy_test.go b/sandboxd/egress/policy_test.go new file mode 100644 index 0000000..a32edcd --- /dev/null +++ b/sandboxd/egress/policy_test.go @@ -0,0 +1,67 @@ +package egress + +import "testing" + +func TestPolicyEval(t *testing.T) { + policy := Policy{Allow: []Rule{ + {Host: "api.github.com", Methods: []string{"GET", "POST"}, Secret: "gh"}, + {Host: "*.googleapis.com"}, + {Host: "*", Methods: []string{"GET"}}, + }} + tests := []struct { + name string + host string + method string + want Decision + secret string + }{ + {"exact host and method", "api.github.com", "GET", DecisionAllow, "gh"}, + {"exact host case-insensitive", "API.GitHub.com", "POST", DecisionAllow, "gh"}, + {"exact host wrong method falls through to catch-all deny", "api.github.com", "DELETE", DecisionDeny, ""}, + {"wildcard subdomain", "storage.googleapis.com", "PUT", DecisionAllow, ""}, + {"wildcard does not match apex", "googleapis.com", "GET", DecisionAllow, ""}, // caught by "*"/GET + {"catch-all get", "example.com", "GET", DecisionAllow, ""}, + {"catch-all denies non-get", "example.com", "POST", DecisionDeny, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rule, got := policy.Eval(tt.host, tt.method) + if got != tt.want { + t.Fatalf("Eval(%q,%q) = %v, want %v", tt.host, tt.method, got, tt.want) + } + if rule.Secret != tt.secret { + t.Errorf("Eval(%q,%q) secret = %q, want %q", tt.host, tt.method, rule.Secret, tt.secret) + } + }) + } +} + +func TestWildcardApexIsNotMatched(t *testing.T) { + policy := Policy{Allow: []Rule{{Host: "*.example.com"}}} + if _, d := policy.Eval("example.com", "GET"); d != DecisionDeny { + t.Errorf("apex example.com matched *.example.com, want deny") + } + if _, d := policy.Eval("a.example.com", "GET"); d != DecisionAllow { + t.Errorf("a.example.com did not match *.example.com, want allow") + } +} + +func TestPolicyValidate(t *testing.T) { + tests := []struct { + name string + policy Policy + wantErr bool + }{ + {"empty allow-list is valid", Policy{}, false}, + {"good rules", Policy{Allow: []Rule{{Host: "api.github.com"}, {Host: "*.dev"}, {Host: "*"}}}, false}, + {"empty host", Policy{Allow: []Rule{{Host: ""}}}, true}, + {"bare wildcard", Policy{Allow: []Rule{{Host: "*."}}}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.policy.Validate(); (err != nil) != tt.wantErr { + t.Errorf("Validate() err = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/sandboxd/egress/proxy.go b/sandboxd/egress/proxy.go new file mode 100644 index 0000000..56d84e9 --- /dev/null +++ b/sandboxd/egress/proxy.go @@ -0,0 +1,195 @@ +package egress + +import ( + "context" + "fmt" + "io" + "maps" + "net" + "net/http" +) + +// hopHeaders are proxy-scoped request headers the forward hop must strip +// rather than pass to the origin. +var hopHeaders = []string{"Proxy-Connection", "Proxy-Authorization"} + +// DialFunc opens the upstream connection for a permitted request. The proxy +// stays transport-agnostic: the node wires the real egress path (a bridge +// dial, or a checked resolver) behind it. +type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error) + +// Secrets resolves a rule's Secret name to the header it injects. The value +// lives node-side and the proxy is its only holder, so a matched request +// carries the credential's effect without the guest ever seeing it. +type Secrets interface { + Header(name string) (header, value string, ok bool) +} + +// Event is one audited egress attempt, keyed by sandbox and tenant. Injected +// names the credential added to a permitted request, never its value. +type Event struct { + Sandbox string + Tenant string + Method string + Host string + Decision Decision + Injected string +} + +// Proxy is one sandbox's forward proxy: an http.Handler serving CONNECT +// tunnels and absolute-form HTTP, gated by Policy and audited per request. +// One Proxy carries one sandbox's identity, so the connection a listener +// hands it needs no in-band token. +type Proxy struct { + sandbox string + tenant string + policy Policy + secrets Secrets + audit func(Event) + dial DialFunc + tr *http.Transport +} + +// New builds a Proxy for one sandbox. secrets and audit may be nil (no +// injection, no audit). dial must be set. +func New(sandbox, tenant string, policy Policy, secrets Secrets, dial DialFunc, audit func(Event)) *Proxy { + return &Proxy{ + sandbox: sandbox, + tenant: tenant, + policy: policy, + secrets: secrets, + audit: audit, + dial: dial, + tr: &http.Transport{DialContext: dial}, + } +} + +func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodConnect { + p.serveConnect(w, r) + return + } + p.serveForward(w, r) +} + +// serveConnect gates an HTTPS/opaque tunnel by host only — the payload is +// end-to-end TLS the proxy never terminates, so a rule's Secret cannot ride +// here (credential injection lives on the plaintext forward path). Allow +// hijacks and splices to the origin; deny answers a typed 403. +func (p *Proxy) serveConnect(w http.ResponseWriter, r *http.Request) { + host := hostOnly(r.Host) + _, decision := p.policy.Eval(host, r.Method) + p.record(Event{Method: r.Method, Host: host, Decision: decision}) + if decision == DecisionDeny { + denied(w, host) + return + } + upstream, err := p.dial(r.Context(), "tcp", r.Host) + if err != nil { + http.Error(w, "egress: upstream unreachable", http.StatusBadGateway) + return + } + defer func() { _ = upstream.Close() }() + client, _, err := http.NewResponseController(w).Hijack() + if err != nil { + http.Error(w, "egress: connection cannot be hijacked", http.StatusInternalServerError) + return + } + defer func() { _ = client.Close() }() + if _, err := io.WriteString(client, "HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil { + return + } + splice(client, upstream) +} + +// serveForward gates an absolute-form plaintext request by host and method, +// injects the rule's credential, and relays it to the origin. +func (p *Proxy) serveForward(w http.ResponseWriter, r *http.Request) { + if !r.URL.IsAbs() { + http.Error(w, "egress: proxy requires an absolute-form request URI", http.StatusBadRequest) + return + } + host := hostOnly(r.URL.Host) + rule, decision := p.policy.Eval(host, r.Method) + if decision == DecisionDeny { + p.record(Event{Method: r.Method, Host: host, Decision: decision}) + denied(w, host) + return + } + + out := r.Clone(r.Context()) + out.RequestURI = "" + for _, h := range hopHeaders { + out.Header.Del(h) + } + injected := p.inject(rule, out.Header) + p.record(Event{Method: r.Method, Host: host, Decision: decision, Injected: injected}) + + resp, err := p.tr.RoundTrip(out) + if err != nil { + http.Error(w, "egress: upstream unreachable", http.StatusBadGateway) + return + } + defer func() { _ = resp.Body.Close() }() + maps.Copy(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) +} + +// inject sets the header the rule's Secret resolves to, overwriting any +// guest-supplied value, and returns the secret name it applied (empty when +// the rule names none or the store cannot resolve it). +func (p *Proxy) inject(rule Rule, h http.Header) string { + if rule.Secret == "" || p.secrets == nil { + return "" + } + header, value, ok := p.secrets.Header(rule.Secret) + if !ok { + return "" + } + h.Set(header, value) + return rule.Secret +} + +func (p *Proxy) record(ev Event) { + if p.audit == nil { + return + } + ev.Sandbox, ev.Tenant = p.sandbox, p.tenant + p.audit(ev) +} + +// splice copies bytes both ways until either side ends, then returns; each +// direction's end half-closes the peer so its EOF propagates. +func splice(a, b net.Conn) { + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = io.Copy(a, b) + closeWrite(a) + }() + _, _ = io.Copy(b, a) + closeWrite(b) + <-done +} + +func closeWrite(conn net.Conn) { + if cw, ok := conn.(interface{ CloseWrite() error }); ok { + _ = cw.CloseWrite() + } +} + +// denied answers a policy rejection with a typed 403 the guest's client +// surfaces as a distinct failure rather than a hang. +func denied(w http.ResponseWriter, host string) { + http.Error(w, fmt.Sprintf("egress denied: %s", host), http.StatusForbidden) +} + +// hostOnly strips a port from an authority, tolerating a bare host and IPv6 +// literals. +func hostOnly(authority string) string { + if host, _, err := net.SplitHostPort(authority); err == nil { + return host + } + return authority +} diff --git a/sandboxd/egress/proxy_test.go b/sandboxd/egress/proxy_test.go new file mode 100644 index 0000000..364e547 --- /dev/null +++ b/sandboxd/egress/proxy_test.go @@ -0,0 +1,200 @@ +package egress + +import ( + "bufio" + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" +) + +type fakeSecrets map[string][2]string + +func (f fakeSecrets) Header(name string) (string, string, bool) { + hv, ok := f[name] + return hv[0], hv[1], ok +} + +// fixedDial ignores the requested address and connects to target, so a test +// policy can name any host while the bytes land on a local backend. +func fixedDial(target string) DialFunc { + return func(ctx context.Context, network, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, target) + } +} + +func TestForwardAllowInjectsSecretAndOverwritesGuestHeader(t *testing.T) { + var gotAuth string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + _, _ = io.WriteString(w, "hello") + })) + defer upstream.Close() + + policy := Policy{Allow: []Rule{{Host: "api.internal", Secret: "gh"}}} + secrets := fakeSecrets{"gh": {"Authorization", "Bearer SECRET"}} + events := make(chan Event, 4) + p := New("sb_1", "acme", policy, secrets, fixedDial(upstream.Listener.Addr().String()), func(ev Event) { events <- ev }) + front := httptest.NewServer(p) + defer front.Close() + + client := proxyClient(t, front.URL) + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://api.internal/x", nil) + if err != nil { + t.Fatalf("build request: %v", err) + } + req.Header.Set("Authorization", "Bearer GUEST") + resp, err := client.Do(req) + if err != nil { + t.Fatalf("proxied GET: %v", err) + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK || string(body) != "hello" { + t.Fatalf("got %d %q, want 200 hello", resp.StatusCode, body) + } + if gotAuth != "Bearer SECRET" { + t.Errorf("upstream saw Authorization %q, want the injected secret (guest value overwritten)", gotAuth) + } + ev := recvEvent(t, events) + if ev.Decision != DecisionAllow || ev.Injected != "gh" || ev.Sandbox != "sb_1" || ev.Tenant != "acme" { + t.Errorf("audit event = %+v, want allow/gh/sb_1/acme", ev) + } +} + +func TestForwardDeniedIsTyped(t *testing.T) { + policy := Policy{Allow: []Rule{{Host: "api.internal"}}} + events := make(chan Event, 4) + p := New("sb_1", "acme", policy, nil, fixedDial("127.0.0.1:1"), func(ev Event) { events <- ev }) + front := httptest.NewServer(p) + defer front.Close() + + resp, err := proxyClient(t, front.URL).Get("http://blocked.internal/") + if err != nil { + t.Fatalf("proxied GET: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("denied request got %d, want 403", resp.StatusCode) + } + if ev := recvEvent(t, events); ev.Decision != DecisionDeny || ev.Host != "blocked.internal" { + t.Errorf("audit event = %+v, want deny/blocked.internal", ev) + } +} + +func TestConnectAllowTunnels(t *testing.T) { + echo := echoServer(t) + policy := Policy{Allow: []Rule{{Host: "echo.internal"}}} + events := make(chan Event, 4) + p := New("sb_1", "acme", policy, nil, fixedDial(echo), func(ev Event) { events <- ev }) + front := httptest.NewServer(p) + defer front.Close() + + conn := dialConnect(t, front.Listener.Addr().String(), "echo.internal:443") + defer func() { _ = conn.Close() }() + br := bufio.NewReader(conn) + if status := readStatus(t, br); status != "HTTP/1.1 200 Connection Established" { + t.Fatalf("CONNECT status = %q, want 200 Connection Established", status) + } + if _, err := io.WriteString(conn, "ping"); err != nil { + t.Fatalf("write through tunnel: %v", err) + } + got := make([]byte, 4) + if _, err := io.ReadFull(br, got); err != nil { + t.Fatalf("read echo: %v", err) + } + if string(got) != "ping" { + t.Errorf("tunnel echoed %q, want ping", got) + } + if ev := recvEvent(t, events); ev.Decision != DecisionAllow { + t.Errorf("audit event = %+v, want allow", ev) + } +} + +func TestConnectDeniedIsTyped(t *testing.T) { + policy := Policy{Allow: []Rule{{Host: "echo.internal"}}} + p := New("sb_1", "acme", policy, nil, fixedDial("127.0.0.1:1"), nil) + front := httptest.NewServer(p) + defer front.Close() + + conn := dialConnect(t, front.Listener.Addr().String(), "blocked.internal:443") + defer func() { _ = conn.Close() }() + if status := readStatus(t, bufio.NewReader(conn)); status != "HTTP/1.1 403 Forbidden" { + t.Fatalf("denied CONNECT status = %q, want 403 Forbidden", status) + } +} + +func proxyClient(t *testing.T, proxyURL string) *http.Client { + t.Helper() + u, err := url.Parse(proxyURL) + if err != nil { + t.Fatalf("parse proxy url: %v", err) + } + return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(u)}} +} + +func dialConnect(t *testing.T, proxyAddr, target string) net.Conn { + t.Helper() + conn, err := net.Dial("tcp", proxyAddr) + if err != nil { + t.Fatalf("dial proxy: %v", err) + } + if _, err := io.WriteString(conn, "CONNECT "+target+" HTTP/1.1\r\nHost: "+target+"\r\n\r\n"); err != nil { + t.Fatalf("write CONNECT: %v", err) + } + return conn +} + +func readStatus(t *testing.T, br *bufio.Reader) string { + t.Helper() + line, err := br.ReadString('\n') + if err != nil { + t.Fatalf("read status line: %v", err) + } + for { + next, err := br.ReadString('\n') + if err != nil || next == "\r\n" || next == "\n" { + break + } + } + if len(line) >= 2 { + line = line[:len(line)-2] + } + return line +} + +// echoServer starts a one-shot TCP echo backend and returns its address. +func echoServer(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen echo: %v", err) + } + t.Cleanup(func() { _ = ln.Close() }) + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + _, _ = io.Copy(conn, conn) + }() + return ln.Addr().String() +} + +func recvEvent(t *testing.T, events <-chan Event) Event { + t.Helper() + select { + case ev := <-events: + return ev + case <-time.After(2 * time.Second): + t.Fatalf("no audit event recorded") + return Event{} + } +}