Skip to content

fix(web): bind dual-stack so the Tailscale address the auth layer trusts is reachable (AGT-4290) - #612

Merged
unohee merged 1 commit into
mainfrom
fix/dual-stack-bind
Sep 10, 2026
Merged

fix(web): bind dual-stack so the Tailscale address the auth layer trusts is reachable (AGT-4290)#612
unohee merged 1 commit into
mainfrom
fix/dual-stack-bind

Conversation

@unohee

@unohee unohee commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

The defect

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
host socket: LISTEN 0.0.0.0:3847   (no IPv6 listener)

An operator with their peer listed exactly in OPENSWARM_TAILSCALE_PEERS was still asked for a token on every remote request.

What changed

web.ts bind '::' instead of '0.0.0.0'; fall back to '0.0.0.0' when IPv6 is unavailable; fix the startup banner
webAuth.ts require the connection to have arrived on a Tailscale address
tailscaleNetwork.ts normalise IPv6 spelling in the peer allowlist; fix detectTailscaleIP
webBind.test.ts new — listens for real and reads back what is reachable

Exposure delta — read this before merging

Binding '::' makes the daemon reachable from address families an IPv4-only bind never exposed: ::1, every fe80:: 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.html and /issues were already ungated and are now reachable from those families. Operators who treated IPv4 NAT as defence in depth behind OPENSWARM_WEB_TOKEN lose 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: isTrustedLocalOrigin returns 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=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. Strictly worse than the reach this set out to widen, and it ships to other users through docker-compose.yml.

Verification

Gate Result
npx tsc --noEmit exit 0
npm run build exit 0
npx oxlint (5 files) 0 warnings, 0 errors
vitest src/support src/issues/graphql src/cli/daemonEndpoint.test.ts exit 0 — 71 files, 807 passed
live socket one IPv6 TCP *:port listener; [::1] 200, 127.0.0.1 200
bind reverted to '0.0.0.0' webBind.test.ts fails 2 of 4

That last row is the point. The first cut of this change shipped three tests that all passed with the bind reverted — they exercised webAuth predicates against fabricated requests and never observed a socket. The one line under review was untested.

Review

Layer-2 independent subagent review (openswarm review is not run on my own changes, per the 2026-09-02 policy). It returned REVISE with five MAJORs; all five are fixed here — dead detectTailscaleIP, the self-assignable ULA, the untested bind, unnormalised peer spelling, and the missing IPv6 fallback.

Closes AGT-4290.

🤖 Generated with Claude Code

…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>
@unohee
unohee merged commit 726d5dd into main Sep 10, 2026
8 checks passed
@unohee
unohee deleted the fix/dual-stack-bind branch September 10, 2026 06:59
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.
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