edge: fix the uid model in purge.go's isolation comment
The previous rewrite (b3b5151) said unit-backed tenants "share that uid too once DynamicUser drops the same carlos identity" — backwards. DynamicUser=yes mints a fresh, unpredictable uid on every unit start (carlos-app@.service: "apps share no identity with each other or the platform"); it never holds the agent's "carlos" uid. Corrected model: exec-backed instances run as the agent's own uid (plain exec.Command, no uid change) — same-uid siblings CAN dial each other's control sockets, which is the real, already-documented gap. Unit-backed (DynamicUser) tenants can't reach ANY control socket, including one built for themselves, which is also why they have no purge listener wired up (see the sibling revert of 7b6584d). The confused-deputy-purge guarantee and the TTL-bound blast radius from the original rewrite were verified accurate and are unchanged.
1 file changed,
+26
−16
internal/edge/purge.go+26 −16
diff --git a/internal/edge/purge.go b/internal/edge/purge.go| index 8de60d8dc..1f232f055 100644 |
| --- a/internal/edge/purge.go |
| +++ b/internal/edge/purge.go |
| @@ -47,22 +47,32 @@ var listenMu sync.Mutex |
| // |
| // 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. |
| +// 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 |