Skip to content

Take the daemon socket out of /tmp, and check who owns it - #64

Merged
YCistak merged 2 commits into
masterfrom
fix/socket-trust
Aug 13, 2026
Merged

YCistak merged 2 commits into
masterfrom
fix/socket-trust

Conversation

@YCistak

@YCistak YCistak commented Aug 13, 2026

Copy link
Copy Markdown
Owner

/tmp/pylon.sock was chosen because /tmp exists on every Unix. It is also the one directory every other user on the machine can write to — and the daemon authenticates nobody. Whatever reaches that socket can run secret set, do, and listen, which opens the microphone.

Nothing checked who owned the socket either. On a shared machine that is enough:

  1. Another local user binds /tmp/pylon.sock first — before Pylon's first run, or after the daemon exits and removes the name on shutdown.
  2. The victim runs pylon secret set gemini <api-key>. It arrives at the attacker's socket in plaintext.
  3. The victim's daemon cannot take the name back: the sticky bit stops it removing a file it does not own, so the squat holds.

Single-user machines were never exposed. This is a shared-machine problem, and it was a real one.

The fix, in four parts — the first alone would not be proof

  • The socket moves to a directory only its owner may write to: $XDG_RUNTIME_DIR/pylon/ on Linux (/run/user/<uid>, made 0700 by the login session), /tmp/pylon-<uid>/ where there is no such variable. The shape tmux and gpg-agent use, for this reason.
  • secureDir refuses a directory that belongs to someone else and tightens one that is merely loose. MkdirAll succeeds on a directory that already exists, whoever made it — which is exactly the case worth catching, since the fallback name is predictable.
  • The socket is chmod 0600, not whatever the umask left. Connecting to a Unix socket needs write permission on it, so its mode is the last gate; 022 vs 002 should not be what decides.
  • Clients check the owner before dialling. The daemon's protections are no help if the thing answering is not the daemon, and the first thing down that wire may be a secret.

pylon-ui carries its own copy of the path by design (separate module, cannot import internal/); it moves in step.

Verification

Live, after restarting the daemon:

$ ls -la /run/user/1000/pylon/
drwx------  2 asaf asaf   80 .
srw-------  1 asaf asaf    0 pylon.sock

$ ls /tmp/pylon.sock
ls: cannot access '/tmp/pylon.sock': No such file or directory

$ pylon status
running (pid 74794, uptime 17s), 0 pending task(s)

Was srwxr-xr-x in a world-writable directory; is now srw------- in a private one.

The refusal was checked against a socket that really belongs to someone else — /run/dbus/system_bus_socket, root-owned and srw-rw-rw-, so connecting would have worked:

refusing to talk to /run/dbus/system_bus_socket: it belongs to another user

Tests cover the private directory, tightening a loose one, idempotent restarts, the socket's mode after Listen, ownership accepted for our own socket and refused for another user's, that the refusal happens before the dial, and that the default path is never directly inside a world-writable directory. -race clean.

Upgrading

Restart the daemonsystemctl --user restart pylon, or close and reopen the window. One still listening on the old path will not be found and the CLI will report it as not running. PYLON_SOCKET and paths.socket override the default exactly as before.

Third and last PR from the security review, after #62 (dependencies) and #63 (input hardening).

🤖 Generated with Claude Code

/tmp/pylon.sock was chosen because /tmp exists on every Unix. It is also the
one directory every other user on the machine can write to, and the daemon
authenticates nobody — whatever reaches that socket can run `secret set`,
`do`, and `listen`, which opens the microphone.

Nothing checked who owned the socket either. On a shared machine that is
enough: bind /tmp/pylon.sock first — before Pylon's first run, or after the
daemon exits and removes it — and the victim's `pylon secret set gemini
<api-key>` arrives in plaintext. Their daemon cannot even take the name back,
because the sticky bit stops it removing a file it does not own.

Three changes, because the first alone is not proof:

  - The socket moves to a directory only its owner may write to:
    $XDG_RUNTIME_DIR/pylon/ on Linux, /tmp/pylon-<uid>/ where there is none.
    That is the shape tmux and gpg-agent use, for this reason.
  - secureDir refuses to run in a directory that belongs to someone else and
    tightens one that is merely loose. MkdirAll succeeds on an existing
    directory whoever made it, which is the case worth catching, since the
    fallback name is predictable.
  - The socket is chmod 0600, not whatever the umask left — connecting to a
    Unix socket needs write permission on it, so that mode is the last gate,
    and 022 vs 002 should not be what decides.
  - Clients check the socket's owner before dialling. The daemon's own
    protections are no help if the thing answering is not the daemon, and the
    first thing down that wire may be a secret.

pylon-ui carries its own copy of the path by design; it moves in step.

Verified live: the socket is now srw------- inside a drwx------ directory,
/tmp/pylon.sock is gone, and the CLI, the daemon and a raw client all work.
The refusal was checked against /run/dbus/system_bus_socket — root-owned and
srw-rw-rw-, so connecting would have succeeded — and Send rejected it before
dialling.

Upgrading needs a daemon restart: one still listening on the old path will not
be found. PYLON_SOCKET and paths.socket override exactly as before.
t.TempDir() on macOS produced

  /var/folders/df/.../TestSecureSocketClosesItToOthers58095275/001/pylon.sock

which is 104 bytes — exactly the cap on a Unix socket path there — so bind
failed with "invalid argument" for a reason that had nothing to do with what
the tests check.

The project has hit this before and already solved it twice: daemon_test.go
and pylon-ui/app_test.go both make their own short temp root. Same helper here,
with the same reasoning written down. Only the three tests that actually bind
need it; the rest just stat directories, where path length is irrelevant.
@YCistak
YCistak merged commit 7553512 into master Aug 13, 2026
6 checks passed
@YCistak
YCistak deleted the fix/socket-trust branch August 13, 2026 20:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant