carlos / platform Public

Let the listener own the socket file, and refuse an unnamed caller

Paul Campbell pushed by paul@keymail.dev 87ba0307fa2bca8e204e63d29e6f2d18dc8ddb36 parent e049d99
2 files changed, +65 −12
  • internal/agentsock/agentsock.go +30 −6
  • internal/agentsock/agentsock_test.go +35 −6
diff --git a/internal/agentsock/agentsock.go b/internal/agentsock/agentsock.go
index 3c091e561..4158ee387 100644
--- a/internal/agentsock/agentsock.go
+++ b/internal/agentsock/agentsock.go
@@ -131,6 +131,13 @@ func (s *Server) guard(fn func(http.ResponseWriter, *http.Request, string)) http
// replaced with fresh random bytes, and the comparison then simply cannot
// succeed. Refusal is a property of the compare, not of a branch above it.
//
+// A caller that names no host at all is refused here, not left to WorkerToken.
+// Asking a collaborator what "" means is the same mistake in a different
+// place: this package would be safe only for as long as the activator's map
+// happens to hold no "" key, which is not a property this package can see or
+// keep. An unnamed caller has nothing to authorize, so it never reaches the
+// lookup — and gets the same 401 as every other refusal.
+//
// The compare itself is constant-time, and both failure paths — no live token,
// wrong live token — run the same code, so the structure carries no timing
// signal beyond the length leak inherent to any comparison.
@@ -138,7 +145,10 @@ func (s *Server) authorize(r *http.Request) (string, bool) {
host := r.Header.Get(hostHeader)
presented := bearer(r.Header.Get("Authorization"))
- want := s.w.WorkerToken(host)
+ want := ""
+ if host != "" {
+ want = s.w.WorkerToken(host)
+ }
if want == "" {
want = unmatchable()
}
@@ -264,7 +274,8 @@ func displayable(s string) bool {
// use" — so a stale file is removed before binding. A file somebody is still
// LISTENING on is a different thing entirely: unlinking that would orphan a
// live agent, whose children would go on talking to a process nothing else can
-// reach. So we probe first, and refuse rather than steal.
+// reach. So we probe first, and refuse rather than steal — and, for the same
+// reason, this function unlinks nothing it did not create (see below).
func (s *Server) Serve(ctx context.Context, path string) error {
if listening(path) {
return fmt.Errorf("agentsock: %s is already served by a live listener", path)
@@ -276,12 +287,25 @@ func (s *Server) Serve(ctx context.Context, path string) error {
if err != nil {
return fmt.Errorf("agentsock: listen on %s: %w", path, err)
}
- // Go unlinks a unix socket it created when the listener closes; this is
- // the belt to that braces, so no exit path can leave the file behind.
- defer func() { _ = os.Remove(path) }()
+ // Cleanup is the LISTENER's job and nothing else's. Go unlinks a unix
+ // socket it created when the listener closes, and that unlink happens
+ // inside Close — indivisibly, from the point of view of a successor
+ // trying to bind the same path.
+ //
+ // A deferred os.Remove(path) here would not be. Between the listener's
+ // own unlink and the deferred call, a successor agent can have bound the
+ // path, and we would then unlink ITS socket: the successor serves on an
+ // inode nothing can reach, its children never arrive, and the box has the
+ // same split-brain the probe above exists to refuse to cause. Guarding
+ // one door and leaving the other open is worse than guarding neither,
+ // because it reads as guarded. So there is no unconditional remove, and
+ // every return below closes the listener instead.
+ if ul, ok := ln.(*net.UnixListener); ok {
+ ul.SetUnlinkOnClose(true) // the default; pinned because it is load-bearing
+ }
if err := os.Chmod(path, socketMode); err != nil {
- ln.Close()
+ ln.Close() // unlinks the socket we just created, and only that one
return fmt.Errorf("agentsock: chmod %s: %w", path, err)
}
diff --git a/internal/agentsock/agentsock_test.go b/internal/agentsock/agentsock_test.go
index 24e7633c8..ea46b0fe0 100644
--- a/internal/agentsock/agentsock_test.go
+++ b/internal/agentsock/agentsock_test.go
@@ -176,6 +176,29 @@ func TestAgentSockEmptyStoredTokenIsNotABypass(t *testing.T) {
f.assertNoEffects(t, "empty stored token")
}
+// A caller that names no host is refused HERE, not by the accident that the
+// activator's map happens to hold no "" key. This fake holds one deliberately:
+// if the refusal were delegated to WorkerToken, an anonymous caller presenting
+// this token would be authorized — and then act on host "", whatever that
+// turns out to mean downstream.
+func TestAgentSockAnonymousCallerIsRefusedHere(t *testing.T) {
+ f := &fakeWorkers{tokens: map[string]string{"": "sekrit", "bot.x.internal": "good"}}
+ h := New(f).Handler()
+ for _, path := range allVerbs {
+ body := ""
+ switch path {
+ case "/v1/ready":
+ body = `{"version":"v1"}`
+ case "/v1/next-due":
+ body = `{"at":1756}`
+ }
+ if rec := post(h, path, "", "sekrit", body); rec.Code != http.StatusUnauthorized {
+ t.Fatalf("%s with no host header: code = %d, want 401 — an unnamed caller has nothing to authorize", path, rec.Code)
+ }
+ }
+ f.assertNoEffects(t, "anonymous caller")
+}
+
func TestAgentSockTokenIsScopedToOneHost(t *testing.T) {
// The security property: possession of A's token says nothing about B.
f := &fakeWorkers{tokens: map[string]string{"a.x.internal": "tok-a", "b.x.internal": "tok-b"}}
@@ -493,12 +516,6 @@ func TestAgentSockOverTheRealSocket(t *testing.T) {
defer func() { cancel(); <-done }()
waitFor(t, "socket to appear", func() bool { _, err := os.Stat(path); return err == nil })
- if fi, err := os.Stat(path); err != nil {
- t.Fatal(err)
- } else if perm := fi.Mode().Perm(); perm&0o007 != 0 {
- t.Fatalf("socket mode %o is reachable by other — the token is the boundary, but the mode should not widen the audience", perm)
- }
-
cl := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
@@ -527,6 +544,18 @@ func TestAgentSockOverTheRealSocket(t *testing.T) {
if code := call("bad"); code != http.StatusUnauthorized {
t.Fatalf("live bad token: %d", code)
}
+
+ // The mode is checked only AFTER a request has been answered, and the
+ // ordering is the whole point: Serve chmods before it starts serving, so
+ // a completed response is proof the chmod has happened. Polling on the
+ // file merely EXISTING returns inside the listen→chmod window, where an
+ // unlucky read sees whatever the umask left (0755 under the common 022)
+ // and fails a correct implementation.
+ if fi, err := os.Stat(path); err != nil {
+ t.Fatal(err)
+ } else if perm := fi.Mode().Perm(); perm&0o007 != 0 {
+ t.Fatalf("socket mode %o is reachable by other — the token is the boundary, but the mode should not widen the audience", perm)
+ }
contacts, _, _, _ := f.effects()
if contacts != 1 {
t.Fatalf("contacts = %d, want exactly the authorized one", contacts)