carlos / platform Public

merge: edge cache follow-ups — the header class swept, a real Close race, and an honest deferral (#12)

edge cache-before-wake follow-ups: split Connection, honest purge-isolation comment, PurgeListener.Close hardening
Paul Campbell c8038ae6256df3807ce854f919fa3c252a848826 parent c0823deparent 269169f
4 files changed, +176 −7
  • internal/edge/edge.go +5 −2
  • internal/edge/edge_test.go +20 −0
  • internal/edge/purge.go +75 −5
  • internal/edge/purge_test.go +76 −0
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index ed9ec5a03..3e9f2f30e 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -330,9 +330,12 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// isUpgradeRequest reports whether r is a protocol-upgrade request (e.g. a
// WebSocket handshake) per the Connection header's token list (RFC 7230
// §6.7: comma-separated, case-insensitive — "Upgrade", "keep-alive, Upgrade",
-// etc. all qualify).
+// etc. all qualify). Connection is list-valued (RFC 9110 §5.3), so this reads
+// every line via joinHeader rather than r.Header.Get, which returns only the
+// first — the same class of bug joinHeader's doc comment describes for Vary
+// and Cache-Control, here on the request's Connection header instead.
func isUpgradeRequest(r *http.Request) bool {
- for _, token := range strings.Split(r.Header.Get("Connection"), ",") {
+ for _, token := range strings.Split(joinHeader(r.Header, "Connection"), ",") {
if strings.EqualFold(strings.TrimSpace(token), "Upgrade") {
return true
}
diff --git a/internal/edge/edge_test.go b/internal/edge/edge_test.go
index 492479c7e..08a1f2fd3 100644
--- a/internal/edge/edge_test.go
+++ b/internal/edge/edge_test.go
@@ -1143,6 +1143,26 @@ func TestServeHTTPUpgradeRequestBypassesRecording(t *testing.T) {
e.ServeHTTP(rec, req)
}
+// TestIsUpgradeRequestDetectsConnectionSplitAcrossHeaderLines is the last of
+// the four Header.Get-on-a-list-valued-header bugs found on this branch
+// (see joinHeader's doc comment): Connection is legitimately list-valued
+// (RFC 9110 §5.3), and a client that sends "Connection: keep-alive" and
+// "Connection: Upgrade" as two separate header lines is just as much an
+// upgrade request as one that sends them comma-joined on one line — but
+// http.Header.Get only ever returns the first line, so isUpgradeRequest
+// would silently treat the split form as an ordinary request and let it
+// fall through to the cache/recordingResponseWriter path instead of the
+// untouched-passthrough branch.
+func TestIsUpgradeRequestDetectsConnectionSplitAcrossHeaderLines(t *testing.T) {
+ req := httptest.NewRequest("GET", "http://ws.example.com/socket", nil)
+ req.Header.Add("Connection", "keep-alive")
+ req.Header.Add("Connection", "Upgrade")
+
+ if !isUpgradeRequest(req) {
+ t.Fatal("Connection split across two header lines must still be detected as an upgrade request")
+ }
+}
+
// activatorFunc adapts a plain function to the Activator interface, for
// tests that want custom wake-counting behavior without a full fake type.
type activatorFunc func(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool
diff --git a/internal/edge/purge.go b/internal/edge/purge.go
index 2f931ef79..471876ac0 100644
--- a/internal/edge/purge.go
+++ b/internal/edge/purge.go
@@ -35,9 +35,44 @@ type purgeRequest struct {
var listenMu sync.Mutex
// ListenPurge binds socketPath (removing any stale file left behind by a
-// crashed previous instance) and serves purge requests scoped to host —
-// host is fixed here, never taken from the request, so a compromised or
-// buggy instance can only ever purge its own cache entries.
+// crashed previous instance) and serves purge requests scoped to host.
+//
+// What that scoping actually guarantees: host is fixed here, at
+// construction, and never taken from the request — purgeRequest carries
+// only Path and Query, never a host — so no request body, however
+// malicious or malformed, can make this listener purge any host other than
+// the one it was built for. That closes a confused-deputy purge: an
+// attacker (or a bug) cannot use one instance's control channel to reach
+// into another host's cache entries THROUGH this listener.
+//
+// What it does NOT guarantee: that only the instance this listener was
+// created for can ever reach it. Every exec-backed instance on a box runs
+// as the SAME uid as the agent itself (they're its children, spawned by
+// plain exec.Command with no uid change — see backend_exec.go), and the
+// control socket path is deterministic (rt.Socket + ".control" —
+// backend_exec.go), so a compromised or buggy exec-backed sibling can
+// compute another host's control socket path and dial it directly, then
+// issue it a purge for ITS host (never a third host — the guarantee above
+// still holds inside that listener). The 0600 socket mode (see the umask
+// dance below) keeps other UNIX users on the box out; it does nothing
+// against a same-uid sibling, because same-uid processes can always open
+// each other's 0600 sockets. Worst case from that gap: a misbehaving
+// instance forces a sibling to re-fetch from ITS OWN backend on its next
+// request — bounded by the same TTL ceiling (DefaultCacheMaxTTL) that
+// already caps a lost purge, and it can never expose one host's cached
+// response body to another, since each listener only ever touches the one
+// host's cache keys it was built for.
+//
+// Unit-backed tenants (carlos-app@<host>.service, backend_unit.go) do NOT
+// share this gap, or any control socket at all, because they don't share a
+// uid with anything: DynamicUser=yes mints a fresh, unpredictable uid on
+// every unit start, with no SupplementaryGroups — never the agent's uid,
+// never another tenant's. That's the opposite problem from the one above:
+// a DynamicUser tenant can't reach a same-uid sibling's control socket
+// because it can't reach ANY control socket, including one built for
+// itself. Unit-backed tenants have no purge listener wired up yet as a
+// result (see backend_unit.go); their cached routes rely on the TTL
+// ceiling alone until that gets its own design.
func ListenPurge(socketPath, host string, cache *Cache) (*PurgeListener, error) {
if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
@@ -76,10 +111,45 @@ func ListenPurge(socketPath, host string, cache *Cache) (*PurgeListener, error)
srv := &http.Server{Handler: mux}
p := &PurgeListener{ln: ln, srv: srv}
- go srv.Serve(ln) //nolint:errcheck // Close() below triggers the expected ErrServerClosed
+ // Close (below) closes p.ln directly, not through srv — so the error
+ // Serve returns here is whatever a closed-listener Accept produces, not
+ // necessarily http.ErrServerClosed. Either way it's expected and unused.
+ go srv.Serve(ln) //nolint:errcheck
return p, nil
}
+// Close stops accepting purge requests, removes the socket file, and tears
+// down any connection already accepted before this call — in that order,
+// and both steps matter independently:
+//
+// 1. p.ln.Close() first, deterministically unlinking the socket file and
+// ending Accept regardless of whether the Serve goroutine ListenPurge
+// started has ever been scheduled. p.srv.Close() alone can't be trusted
+// for this: net/http's Server only closes listeners its own Serve call
+// has already registered (in an internal map populated by Serve itself,
+// on entry — see trackListener in net/http/server.go), and a Close
+// arriving before that goroutine runs loses the race and finds nothing
+// registered — Serve then proceeds to Accept on a listener nobody asked
+// it to stop, and the socket file is never unlinked. Closing the raw
+// net.Listener sidesteps the tracking map entirely.
+// 2. p.srv.Close() second, to tear down connections ALREADY accepted
+// before step 1 — closing only the listener stops new Accepts but does
+// nothing to a connection already handed off to its own handler
+// goroutine, which keeps right on serving requests over a still-open
+// keep-alive connection. A client that pooled such a connection could
+// otherwise keep issuing purges indefinitely after Close returned.
+// Calling srv.Close() after p.ln is already closed is harmless: closing
+// an already-closed (or never-registered) listener a second time is a
+// no-op error internally, swallowed here, while every connection
+// srv independently tracks in its own connection map — unrelated to
+// listener registration — still gets closed as intended.
+//
+// The returned error is p.ln's — the unlink guarantee callers actually
+// depend on; srv.Close()'s error, if any, is dropped. Calling Close a
+// second time (nobody does today) returns "use of closed network
+// connection" from the second p.ln.Close().
func (p *PurgeListener) Close() error {
- return p.srv.Close()
+ err := p.ln.Close()
+ p.srv.Close()
+ return err
}
diff --git a/internal/edge/purge_test.go b/internal/edge/purge_test.go
index 6d126fa2f..f3b39c6f3 100644
--- a/internal/edge/purge_test.go
+++ b/internal/edge/purge_test.go
@@ -95,6 +95,82 @@ func TestListenPurgeSocketFileIsOwnerOnly(t *testing.T) {
}
}
+// Close, called immediately after ListenPurge returns (before the Serve
+// goroutine it started has necessarily run at all), must still unlink the
+// socket file. PurgeListener.Close used to delegate to http.Server.Close,
+// which only closes listeners its own Serve call has already registered in
+// its internal tracking map (net/http's trackListener, set inside Serve
+// itself) — a real race, since Serve runs in a goroutine ListenPurge doesn't
+// wait on. Losing that race silently no-ops Close: the goroutine's later
+// Serve(ln) call then Accepts on a listener nobody ever asked to stop, and
+// the socket file is never removed. A caller like UnitBackend.Start/Stop
+// that opens and closes a purge listener back-to-back around a single unit
+// start hits this every time, not just occasionally.
+func TestListenPurgeCloseImmediatelyAfterOpenRemovesSocketFile(t *testing.T) {
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "instance.sock")
+ pl, err := ListenPurge(sock, "a.example", NewCache(10, 1<<20))
+ if err != nil {
+ t.Fatalf("ListenPurge: %v", err)
+ }
+
+ if err := pl.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ if _, err := os.Stat(sock); err == nil {
+ t.Fatal("expected the socket file removed immediately after Close, even called before the Serve goroutine could have run")
+ }
+}
+
+// Close must tear down connections already accepted before it runs, not
+// just stop new ones. Closing the raw listener alone (the fix for the
+// unlink race above) only stops future Accept calls — a connection the
+// server already handed off to its own handler goroutine keeps right on
+// serving requests, since nothing told that goroutine to stop. A client
+// whose transport pooled a keep-alive connection from a purge issued before
+// Close can therefore keep issuing purges after Close returns, defeating
+// the whole point of closing the listener.
+func TestListenPurgeCloseRefusesRequestsOnAlreadyEstablishedConnections(t *testing.T) {
+ dir := t.TempDir()
+ sock := filepath.Join(dir, "instance.sock")
+ cache := NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)
+ cache.Put(CacheKey{Host: "a.example", Method: "GET", Path: "/event"}, "", &CachedResponse{
+ StatusCode: 200, Header: http.Header{}, Body: []byte("hi"), StoredAt: time.Now(), TTL: time.Minute,
+ })
+
+ pl, err := ListenPurge(sock, "a.example", cache)
+ if err != nil {
+ t.Fatalf("ListenPurge: %v", err)
+ }
+
+ client := dialPurgeSocket(t, sock)
+ body, _ := json.Marshal(map[string]string{"path": "/event", "query": ""})
+
+ // Establish (and, via keep-alive, pool) a connection with one successful
+ // purge before Close.
+ resp, err := client.Post("http://unix/purge", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatalf("first purge POST: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("first purge status = %d, want 200", resp.StatusCode)
+ }
+
+ if err := pl.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ // The SAME client — free to reuse its pooled connection from the first
+ // request — must not be able to complete a second purge after Close.
+ resp2, err := client.Post("http://unix/purge", "application/json", bytes.NewReader(body))
+ if err == nil {
+ resp2.Body.Close()
+ t.Fatal("expected a purge over a connection established before Close to fail once Close has run, got success")
+ }
+}
+
func TestListenPurgeRemovesStaleSocketFile(t *testing.T) {
dir := t.TempDir()
sock := filepath.Join(dir, "instance.sock")