edge: purge.go's isolation comment overstates what host-fixing buys
ListenPurge's doc comment said a compromised instance "can only ever purge its own cache entries" — true of what the REQUEST BODY can name (purgeRequest carries no host, so per-payload host-fixing does close a confused-deputy purge), but not true of what the SOCKET can reach. All exec-backed instances run as the same uid as the agent, and unit- backed tenants converge on that uid too once DynamicUser settles; the control socket path is deterministic (rt.Socket + ".control"), so a compromised or buggy sibling can dial another host's control socket directly and purge THAT listener's own host. The 0600 socket mode guards against other UNIX users on the box, not same-uid siblings, who can always open each other's 0600 sockets. Rewrites the comment to state both halves precisely: the confused- deputy guarantee that does hold, and the sibling-reachability gap that doesn't — plus its actual blast radius (a forced re-fetch, bounded by the same TTL ceiling that already caps a lost purge; never a leaked response body across hosts). No behavior change.
1 file changed,
+28
−3
internal/edge/purge.go+28 −3
diff --git a/internal/edge/purge.go b/internal/edge/purge.go| index 2f931ef79..4817763e7 100644 |
| --- a/internal/edge/purge.go |
| +++ b/internal/edge/purge.go |
| @@ -35,9 +35,34 @@ 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 (they're its children — see |
| +// backend_exec.go), and unit-backed tenants share that uid too once |
| +// DynamicUser drops the same "carlos" identity's write access under |
| +// /run/carlos; the control socket path is deterministic |
| +// (rt.Socket + ".control" — backend_exec.go, backend_unit.go), so a |
| +// compromised or buggy sibling instance 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. |
| func ListenPurge(socketPath, host string, cache *Cache) (*PurgeListener, error) { |
| if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { |
| return nil, err |