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
89 changes: 89 additions & 0 deletions sandboxd/egress/policy.go
Original file line number Diff line number Diff line change
@@ -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
}
67 changes: 67 additions & 0 deletions sandboxd/egress/policy_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
195 changes: 195 additions & 0 deletions sandboxd/egress/proxy.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading