Skip to content

fix(bridge): refuse writes when the device header is absent - #28

Merged
AltanS merged 1 commit into
AltanS:mainfrom
Optic00:fix/device-auth-fail-closed
Jul 26, 2026
Merged

fix(bridge): refuse writes when the device header is absent#28
AltanS merged 1 commit into
AltanS:mainfrom
Optic00:fix/device-auth-fail-closed

Conversation

@Optic00

@Optic00 Optic00 commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

The problem

deviceAuth() treats a request that carries no device header as an authorised on-host operator:

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

The comment above the branch documents the intent as "an absent header is the on-host loopback
operator", and that intent is reasonable. The request's origin is never checked, though, so the
branch grants full write access to any caller that reaches the bridge without the header.

The README already documents the consequence, in the Variant B warning: an absent header means full
access, so on plain tailscale serve the gate is a no-op. This PR closes that half.

It matters most under COLLIE_SKIP_SERVE=1, where COLLIE_TRUSTED_USER is inert (the bridge
already warns about exactly that at startup) and the device header is the only authorisation left.

Why checking the peer address is not the fix

This is worth recording, because it is the obvious first move and it does not work.

Every supported front door is a proxy co-located with the bridge. collie-ctl.sh maps
tailscale serve to 127.0.0.1:PORT, and the documented Variant B and C proxies use a loopback
proxy_pass. A header-less remote request therefore still arrives with a loopback peer. The peer
address cannot separate the operator on the host from a remote client whose proxy failed to inject
the header, so gating on it would look like a fix while changing nothing for either documented
deployment.

What this does instead

Setting COLLIE_DEVICE_HEADER is the operator asserting that their proxy sets the header on every
request. A request without one did not come through that proxy, and is now treated the same as a
device that is not on the allowlist.

device header configured header on request result
no anything not enforced, authorised (unchanged)
yes absent read-only (was: authorised)
yes allowlisted authorised, attributed (unchanged)
yes not allowlisted read-only (unchanged)
yes unknown sentinel read-only (unchanged)

Read-only is the whole scope of this gate, deliberately. guard() consults deviceAuth() only for
writes, so a header-less caller still reads panes. That is the existing design, and this change does
not touch it. What changes is that a missing header no longer counts as the operator.

The absent-header case deliberately gets no loopback exemption, for the reason above. Driving a pane
from the host is one flag away: send an allowlisted id yourself, against the loopback bridge.

guard() is now exported, so the wiring can be tested rather than only deviceAuth() in isolation.

What this does not fix

The Variant B warning has a second half, and it stays: tailscale serve forwards a client-supplied
X-Device-Id untouched, so a client that sets an allowlisted id itself is still trusted. Only a
proxy that overrides the header closes that, exactly as the README already requires. I reworded
the warning to say just that rather than deleting it.

A deployment that does not set COLLIE_DEVICE_HEADER is completely unaffected.

Tests

Seven new cases, and bun run typecheck clean.

Two on deviceAuth() directly: the absent header refused, and a loopback Host explicitly not
buying an exemption, so the tempting fix above cannot creep back in unnoticed.

Five on guard(), pinning the read/write asymmetry that the gate stands or falls on: a write with
no header and a write from a non-allowlisted device both 403, a write from an allowlisted device
proceeds, a read with no header still proceeds rather than being rejected, and with the feature off
a write with no header proceeds.

Docs

README updated in three places: the security bullet, the Variant B section, and the reverse-proxy
note. The Variant B section now spells out what read-only covers, that the bridge's own loopback URL
becomes read-only because it bypasses the proxy, and how to drive a pane from the host by hand.

deviceAuth() treated a request that carries no device header as an authorised
on-host operator:

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

The comment above the branch documents the intent as "an absent header is the
on-host loopback operator", but the request's origin was never checked, so the
branch granted full write access to any caller that reached the bridge without
the header. README.md already documented the consequence, in the Variant B
warning: an absent header means full access, so the gate is a no-op.

Configuring COLLIE_DEVICE_HEADER is the operator asserting that their proxy
sets the header on every request. A request without one did not come through
that proxy, and is now treated exactly like a device that is not on the
allowlist: read-only.

Read-only is the accurate word, not "rejected". guard() consults deviceAuth
only for writes, so a header-less caller still reads panes, as an unlisted
device always could. That is the existing design and this change does not touch
it; what changes is that a missing header no longer counts as the operator. The
README now says so explicitly, including that pane text is therefore not behind
this gate.

There is deliberately no loopback exemption, which is worth recording because
it looks like the obvious way to keep the on-host convenience. Every supported
front door is a proxy co-located with the bridge: collie-ctl.sh maps tailscale
serve to 127.0.0.1:PORT, and the documented Variant B and C proxies use a
loopback proxy_pass. A header-less remote request therefore arrives with a
loopback peer just like a local one, so neither the peer address nor the Host
header separates the operator on the host from a remote client whose proxy
failed to inject the header. An exemption would have re-opened the very path
this closes.

The cost is that the bridge's own loopback URL is read-only while device auth
is on, since it bypasses the proxy that would inject the header. The README
states that, and gives the by-hand alternative: curl the loopback bridge with
an allowlisted id, not the public URL, where the proxy's mandatory override
would replace it.

guard() is exported so the wiring is tested, not just deviceAuth in isolation:
the gate lives in the write/read asymmetry there, and deviceAuth being correct
on its own would not catch that regressing. Both halves are asserted.

This closes the absent-header half of the documented hole. The other half, a
client setting the header itself on plain tailscale serve, is unchanged and
still needs a proxy that overrides it; the README warning now says just that.

A deployment that does not set COLLIE_DEVICE_HEADER is unaffected.
@AltanS

AltanS commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Merged and shipped in 0.15.0. Thanks, this was the most valuable thing in the queue.

Your reasoning about why the peer address is not the fix is correct, and it is the part that made the change obviously right. I verified the hole was live rather than theoretical: on a deployment where a proxy injects the header, requests through the front door were attributed correctly, while requests sent straight to the bridge's own tailnet URL came back authorized: true with no device. That URL cannot be taken down because it is the proxy's own upstream, so the bridge was the only place the distinction could be made. Your one line closes it.

Two notes.

The PR description does not match the diff. It describes COLLIE_DEVICE_ALLOW_NO_HEADER, requestIP() peer plumbing and isLoopbackAddress, none of which are in the branch, and bridge/config.ts is not touched. The committed version is the simpler one and the README matches the code, so nothing shipped broken, but I reviewed the diff rather than the prose. Worth keeping them in sync next time.

The tests are the best part. Pinning the read/write asymmetry and explicitly refusing a loopback-Host exemption stops the obvious future regression. 318 backend tests pass on merged main.

Flagged as a breaking change in the changelog, since anyone with the header configured and no injector in front loses write access on upgrade.

@Optic00

Optic00 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

You're right. The peer-address version came first, and I wrote the description for it. Review killed
that design for the reason the surviving text itself gives, so I rewrote the branch down to the
one-line fix and failed to rewrite the description. That left the description contradicting both
itself and the diff.

Sorry. Reconciling those accounts cost you review time for no good reason. I've rewritten the
description to match what was merged.

#29 has the same problem: the description says the region comparison collapses runs of whitespace,
but the code only strips trailing whitespace. I'll correct that description with the next update.

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.

2 participants