fix(web): bind dual-stack so the Tailscale address the auth layer trusts is reachable (AGT-4290) - #612
Merged
Merged
Conversation
…sts is reachable (AGT-4290)
The Tailscale trust path was complete and unreachable. `isTailscaleAddress`
trusts ONLY the IPv6 ULA prefix — CGNAT is refused on purpose, because
100.64.0.0/10 is shared with carriers and proves no identity — but the server
bound '0.0.0.0', which is IPv4 only. Measured on the deployment host:
CGNAT http://100.95.200.28:3847/api/usage -> 403
ULA http://[fd7a:115c:a1e0::bc01:c823]:3847/ -> could not connect
So an operator with their peer listed exactly in OPENSWARM_TAILSCALE_PEERS was
still asked for a token on every remote request.
Binding '::' makes the ULA reachable. Node defaults to dual-stack, so IPv4
clients keep working and arrive as '::ffff:…', a form the auth layer already
expected (isLoopbackAddress lists '::ffff:127.0.0.1'; isTailscaleAddress strips
the prefix).
Widening the bind is not free, and the rest of this change is what it costs.
Require the connection to have arrived ON a Tailscale address. A ULA carries no
allocation authority — anyone can assign fd7a:115c:a1e0::… to their own
interface. While the daemon bound IPv4 only that was moot, because nothing
could reach the ULA at all; dual-stack makes it reachable over every interface,
so a LAN neighbour could self-assign an allowlisted address and be trusted. The
Origin header does not backstop this: isTrustedLocalOrigin returns true when
there is no Origin, which is every non-browser client. Checking the local end
of the socket is defence in depth, not proof — an on-link attacker who can also
route the ULA prefix defeats it. Real proof needs the Tailscale control plane,
which this process does not talk to.
Fall back to '0.0.0.0' when IPv6 is unavailable. A host with ipv6.disable=1
refuses an AF_INET6 bind, the error handler rejected anything but EADDRINUSE,
and startService rethrows — so the daemon would not have started at all. That
is strictly worse than the reach this set out to widen, and it ships to other
users through docker-compose.yml.
Normalise IPv6 spelling in the peer allowlist. Node hands us RFC 5952 on the
wire, so a plain string compare rejected an operator who expanded the address
by hand or copied it out of a URL bar with brackets. Fail-closed, but it
presents as "still asked for a token" — the exact symptom being fixed.
Fix detectTailscaleIP, which was structurally dead: it skipped every non-IPv4
interface and then asked isTailscaleAddress, which only accepts the IPv6 ULA
prefix. The two conditions cannot both hold, so it always returned undefined
and the startup banner always printed "token required" — including on a
Tailscale-only daemon where no token exists to present.
Tests: src/support/webBind.test.ts listens for real and reads back what is
reachable. Reverting '::' to '0.0.0.0' fails two of its four cases; the
previous auth-layer tests all passed with the bind reverted, so the one line
under review had shipped untested.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Link-Start
pushed a commit
to Link-Start/OpenSwarm-unohee
that referenced
this pull request
Sep 12, 2026
…, not its attempt count (Intrect-io#533) retryAtFor('superseded') scaled on the run's attempt number, so a run with eight earlier attempts of any kind waited six hours after ONE sibling PR claimed its files — and an explicit redispatch that met the same PR only pushed the retry further out (vela AGT-3597/4165/3502 → 19:27–20:07 on 2026-09-02, all yielding to the operator's own Intrect-io#612/Intrect-io#35). When that PR merges, the follow-up should run within minutes. Key the backoff on consecutive supersessions: 5 min for the first, doubling while a sibling keeps claiming the files, reset by any other outcome.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
The Tailscale trust path was complete and unreachable.
isTailscaleAddresstrusts only the IPv6 ULA prefix — CGNAT is refused on purpose, because100.64.0.0/10is shared with carriers and proves no identity — but the server bound'0.0.0.0', which is IPv4 only.Measured on the deployment host:
An operator with their peer listed exactly in
OPENSWARM_TAILSCALE_PEERSwas still asked for a token on every remote request.What changed
web.ts'::'instead of'0.0.0.0'; fall back to'0.0.0.0'when IPv6 is unavailable; fix the startup bannerwebAuth.tstailscaleNetwork.tsdetectTailscaleIPwebBind.test.tsExposure delta — read this before merging
Binding
'::'makes the daemon reachable from address families an IPv4-only bind never exposed:::1, everyfe80::link-local, every other ULA on the host, and any global IPv6 the host carries. No endpoint becomes newly unauthenticated — the auth gate is address-family agnostic and 403s correctly from a non-loopback IPv6 source — but/api/health,/,/index.htmland/issueswere already ungated and are now reachable from those families. Operators who treated IPv4 NAT as defence in depth behindOPENSWARM_WEB_TOKENlose that silently.The ULA trust itself is the sharper edge. A ULA carries no allocation authority: anyone can assign
fd7a:115c:a1e0::…to their own interface. That was moot while nothing could reach the ULA; dual-stack makes it reachable over every interface. So this PR also requires the local end of the socket to be a Tailscale address — the packet came to us through the tailnet, not to our LAN address with a forged source. The Origin header does not backstop it:isTrustedLocalOriginreturns true when there is no Origin, which is every non-browser client.That check is defence in depth, not proof. An on-link attacker who can also route the ULA prefix defeats it. Real proof needs the Tailscale control plane (node key / capability check), which this process does not talk to.
Why the fallback
A host with
ipv6.disable=1refuses anAF_INET6bind. The error handler rejected anything butEADDRINUSE, andstartServicerethrows — so the daemon would not have started at all. Strictly worse than the reach this set out to widen, and it ships to other users throughdocker-compose.yml.Verification
npx tsc --noEmitnpm run buildnpx oxlint(5 files)vitest src/support src/issues/graphql src/cli/daemonEndpoint.test.tsIPv6 TCP *:portlistener;[::1]200,127.0.0.1200'0.0.0.0'webBind.test.tsfails 2 of 4That last row is the point. The first cut of this change shipped three tests that all passed with the bind reverted — they exercised
webAuthpredicates against fabricated requests and never observed a socket. The one line under review was untested.Review
Layer-2 independent subagent review (
openswarm reviewis not run on my own changes, per the 2026-09-02 policy). It returned REVISE with five MAJORs; all five are fixed here — deaddetectTailscaleIP, the self-assignable ULA, the untested bind, unnormalised peer spelling, and the missing IPv6 fallback.Closes AGT-4290.
🤖 Generated with Claude Code