edge: fix race condition in purge listener socket creation
Use syscall.Umask to prevent a world-connectable race window when creating the unix socket. The socket file is now created at mode 0600 from the moment it exists, with concurrent ListenPurge calls serialized around the umask change to prevent interference (carlios agent spawns multiple instances concurrently). Fixes security vulnerability identified in code review.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 file changed,
+21
−0
internal/edge/purge.go+21 −0
diff --git a/internal/edge/purge.go b/internal/edge/purge.go| index 8ee360962..2f931ef79 100644 |
| --- a/internal/edge/purge.go |
| +++ b/internal/edge/purge.go |
| @@ -6,6 +6,8 @@ import ( |
| "net" |
| "net/http" |
| "os" |
| + "sync" |
| + "syscall" |
| ) |
| // PurgeListener is a per-instance, local-only channel for invalidating |
| @@ -25,6 +27,13 @@ type purgeRequest struct { |
| Query string `json:"query"` |
| } |
| +// listenMu guards the umask-wrapped net.Listen call in ListenPurge. Since |
| +// syscall.Umask is process-wide and not per-goroutine, concurrent ListenPurge |
| +// calls (e.g., in carlos agent spawning multiple instances) must serialize |
| +// around the umask change to avoid race windows where the socket is created |
| +// world-connectable before being chmod'd to 0600. |
| +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 |
| @@ -33,10 +42,22 @@ func ListenPurge(socketPath, host string, cache *Cache) (*PurgeListener, error) |
| if err := os.Remove(socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { |
| return nil, err |
| } |
| + |
| + // Create the socket with umask 0177 to prevent a world-connectable race window. |
| + // syscall.Umask is process-wide, so we serialize around it with listenMu |
| + // to protect against concurrent ListenPurge calls (e.g., in carlos agent |
| + // spawning multiple instances). |
| + listenMu.Lock() |
| + prevUmask := syscall.Umask(0177) |
| ln, err := net.Listen("unix", socketPath) |
| + syscall.Umask(prevUmask) |
| + listenMu.Unlock() |
| + |
| if err != nil { |
| return nil, err |
| } |
| + |
| + // Defensive chmod as backstop (no longer load-bearing, but kept for defense-in-depth). |
| if err := os.Chmod(socketPath, 0600); err != nil { |
| ln.Close() |
| return nil, err |