Skip to content

Loopback bind bounds remote reach, not local — and the device gate fails open on an absent header #33

Description

@simonallfrey

Not a privilege-escalation report. §6 already says the herdr socket equals arbitrary code execution, and herdr advertises agent socket access as a feature. The narrower point: herdr's socket and Collie's port have different reachable sets, and §6 doesn't distinguish them.

herdr's socket is a filesystem object — permissions bound callers to the owning uid. Collie binds 127.0.0.1:8787, reachable by every uid in the netns. §6 calls the loopback bind load-bearing; it is, against remote callers. Locally it bounds nothing, and the doc reads as though it does.

Concrete case. I run review agents on untrusted diffs under a separate uid — sudo -u agent-review claude inside the pane. That uid can't open my herdr socket, so a prompt injection in a diff can't reach my other panes. It can open 127.0.0.1:8787, so with Collie running it can:

curl -X POST http://127.0.0.1:8787/api/pane/<id>/keys \
  -H 'content-type: application/json' -d '{"keys":["..."]}'

— and type into any pane in the herd. checkAccess passes (Host is loopback, so the no-Origin write path is allowed; COLLIE_PUBLIC_HOSTS is empty by default so the host allowlist never runs). deviceAuth returns authorized: true on the absent header. Installing Collie silently removes the uid boundary, which is the containment an operator running agents is most likely to have reached for.

The fix already exists in the code. deviceAuth() fails open:

if (!device) return { enforced: true, device: null, authorized: true };

Rationale given: an absent header is the on-host loopback operator. That's the same "local means trusted" assumption.

Deny writes on an absent header when COLLIE_DEVICE_HEADER is set, and treat allowlist entries as secrets rather than device names, and this becomes a bearer token — reaching the port stops being sufficient to write. Few lines, no new machinery, no Tailscale dependency, so it covers Variant C too.

Same change closes a doc/code gap: §6 says the owner login is asserted and other tailnet users rejected, but checkAccess only rejects a mismatching Tailscale-User-Login and passes an absent one. Fine under tailscale serve, which always injects it; not fine behind a proxy that stops.

If unix-socket listen was on the roadmap: Tailscale restricted Serve's unix-socket proxy targets to root in TS-2026-005, and collie-ctl.sh runs serve as the operator. That route costs root now.

If the fail-closed change isn't wanted, a sentence in §6 covers most of it: the loopback bind bounds remote reach only, and agents under a separate uid are not isolated from the bridge. Happy to PR the deviceAuth change.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions