colonizer
install
docs Remote access review

#Remote access: security review

This is the security review #536 asks for before the remote-access relay is deployed for the first time. It was carried out on 2026-09-26 against three revisions, and every line reference below points at them:

Method: code reading split by threat area — the relay edge, the frame protocol and tunnel, and the cockpit boundary — plus Node proof-of-concept runs against the shipped relay modules, and an independent re-check of every filed finding by a second reviewer.

Outcome: the cockpit's own fences hold for tunnelled traffic. No tunnelled request skips host_guard, nothing reaches loopback services the cockpit does not itself serve, and the Origin fence from #375 is pinned for the tunnel host rather than widened. The id, cookie and pairing design at the relay holds too. Five findings were filed as separate security issues; three of them (R1–R3) should block deployment.

#Status on main (re-checked 2026-09-27)

The tunnel client has since merged (#558, 61d4d8e), and the server was split into modules (#577): what the review cites in main.rs is now host_guard in crates/colonizer/src/server.rs:37-90 and the router in server.rs:226-236. remote.rs on main is the reviewed code with its line numbers moved by a few lines at most. The relay source is unchanged since 5325415 (only the pinned wrangler version moved, #560). The relay is still not deployed.

Every finding below is still open on main:

IdStill true?What was checked on main
R1YesstripHopByHop still throws on the client's [name, value] array (services/relay/src/protocol.js:46-49, reproduced with Node); the client still sends arrays (remote.rs:797-802). No relay test uses that shape.
R2Yesreset_identity still registers a fresh install and never retires the old one (remote.rs:245-263); the relay still has no endpoint that deletes an install (worker.js:48-59).
R3YesThe relay still forwards colonizer_token (auth.js:199-209 strips only its own session cookie); nothing rotates the API token; http_base still accepts ws:// for any host (remote.rs:319-327).
R4YesstripHopByHop still passes set-cookie … Domain= through; set_cookie_header still has no Secure (auth.rs:134-136).
R5Yesbodies and ws_in still use unbounded channels (remote.rs:704, :833); the tunnel is still dialled with tungstenite's default config (remote.rs:442-445).
L1–L5YessafeNext (auth.js:81-85), the optional limiter (worker.js:62, :111), bare create_dir_all (remote.rs:169, :275), the sec-websocket-* strip (remote.rs:1048) and the via field without a tunnel marker are all unchanged.

Since then (re-checked 2026-10-03). R1 is fixed: stripHopByHop now reads [name, value] pairs and keeps repeated set-cookie, with an end-to-end test that runs the real Rust client through the real relay (#618, PR #659). R2 is narrowed but not closed: Reset link now sends the old install a signed DELETE …/owner before replacing the key, so the old link loses its owner (reset_identity in remote.rs, #599, PR #663), but the old install stays registered at the relay, which still has no endpoint that deletes one. R3–R5 are unchanged: http_base still accepts ws:// for any host, the relay still passes Domain= through, the cockpit cookie still has no Secure (set_cookie_header in auth.rs), and ws_in and bodies are still unbounded channels.

Since then (re-checked 2026-10-05). R2, R3, R4 and R5 are fixed; each fix has tests that fail without it, and the whole link now runs end to end against the real relay code (the_whole_link_round_trips_through_the_real_relay in remote.rs, over services/relay/scripts/local-relay.mjs). The deployed Worker predates these fixes until it is redeployed.

IdStatusFix
R1Fixed#659, as above.
R2Fixed (a787f94)The relay has a signed DELETE /api/installs/<id> that deletes the install, its owner and its pairings and closes its tunnel 4404. Reset link calls it with the old key before replacing it, and if the relay cannot be told it answers 502 and keeps the old link, so a reset never leaves the old install live. Only an unreadable old key is replaced regardless, since nothing can sign for it.
R3Fixed (7853719, and the link-credential commit of #1030)COLONIZER_REMOTE_URL takes plaintext ws:// only for a loopback relay. This machine's install token is never accepted through the tunnel — bearer, cookie or ?token= — so it never needs to cross the relay. The owner's browser elsewhere signs in with a link credential (clk_…) instead: owner-scoped, stored hashed, valid only on tunnelled requests, handed over like a phone pairing (a single-use invite opened on the link, six digits confirmed locally, then the credential set as that browser's cookie on the link's origin). Reset link rotates every link credential at once, ending their requests and sockets through the same revocation phones use, and one device can be signed out alone.
R4Fixed (8574073)The relay drops every Domain attribute from a forwarded set-cookie and drops a cookie named like its own __Host- cookies; the tunnel client adds Secure to every Set-Cookie it sends back through the tunnel.
R5Fixed (9e64ff4)The tunnel socket takes no message or frame over 1 MiB; body frames wait in a 16-frame queue (the reader pauses) and are dropped once a stream has its body; a chunk is size-checked before it is decoded; websocket frames wait in a 64-frame queue, and a socket that falls behind is closed 1008 and its stream freed within 5 s. The relay closes a passthrough 1009 on a browser message over 128 KiB.

How R3 was closed. The maintainer chose the link-scoped owner credential over the other options (pairing remote browsers as phones, which would have lost owner powers through the link; rotating the install token on reset; or accepting the relay's trust level in writing). What the relay can still see is a link credential, never the install token, and only until the next Reset link. Paired phones (cph_…) still work through the link as before: they are per-device and revocable, and remain phones.

No issue with any of the five R titles is in the public tracker as of 2026-09-27; if they were filed privately (as security advisories), this document cannot see them.

One more gap, found on the re-check and not a security finding: the mothership had no code for the relay's signed pairing endpoints. The cockpit called GET /api/remote/pairing and POST /api/remote/pairing/confirm (web/src/api.ts), but neither was a mothership route, so no install could bind an owner and the relay forwarded no one. #599 closes it: the mothership now serves the pairing routes, and confirming a code is refused through the tunnel (remote-tunnel.md). remote-tunnel.md lists every place the two halves depart from the pinned tunnel contract.

#Pairing with the pair code alone (#1086, 2026-10-06)

#1086 drops the relay's GitHub sign-in as a precondition. A new install's link now forwards a browser without any relay session when the request carries one of three things, and only these:

The relay recognises the shapes (services/relay/src/passthrough.js) but trusts none of them; the mothership's host_guard and phone.rs decide every one. Everything else gets the relay's own "Pair this device" page and never reaches the tunnel. The GitHub gate stays available as the require_github setting (local-only PUT /api/remote/require-github, relayed as a signed PUT /api/installs/<id>/settings): off for new installs, kept on for installs registered before the setting, and the owner binding and recovery work as before while it is on. A bound owner's GitHub session is forwarded in either mode.

R6, accepted by design: on the default setting, the mothership's own auth is the only gate on the invite and credential paths. Before #1086 the relay's owner check was a second layer in front of them (see The two boundary checks: "a second gate, not a substitute"). What carries those paths now:

ProtectionWhere
The invite is 256 random bits, stored only as a SHA-256 hash, single-use (spent on its first presentation, even a wrong one), valid five minutes, at most four open at once, and a link invite opens nothing off the tunnel.phone.rs Book::mint_kind, open_from
Opening an invite hands over nothing: it binds a pairing to that one browser and shows six digits, which only the local cockpit can confirm (403 through the link).server.rs host_guard; remote.rs confirm_device, local_only
Every failed pairing step (a dead invite, a claim that finds nothing, a wrong code) counts against the mothership's own limit, ten a minute across all callers.phone.rs Book::limited
Link and phone credentials are 256-bit, stored hashed, compared in constant time, revocable one by one (in-flight requests and sockets end at once), and all rotated by Reset link.remote.rs authenticate_link, revoke_link, rotate_links; phone.rs
The relay throttles the pass-through per install and per client in D1: every invite open counts (10 per client, 30 per install, per 10 min), and so does every forwarded credential the mothership rejected (20 per client, 200 per install, per 10 min). Past a limit it answers 429 and forwards nothing. The mothership reports a rejection with x-colonizer-credential: rejected (or a websocket 4401), which the relay strips.services/relay/src/throttle.js, worker.js installHost, tunnel.js #countRejected

Threat-model changes against the verdicts below:

ThreatVerdict with the pair code aloneWhy
Guessing an invite or a credential over the internetHolds256-bit secrets, and the relay's throttle makes even a spread-out guesser stop after a few hundred tries per 10 minutes per install.
A leaked invite (a QR code photographed, a link pasted in the wrong chat)Narrow, acceptedWhoever opens it first, within five minutes, gets the six digits. They still reach nothing unless the operator types their digits into the local cockpit, and the operator types the digits their own device shows; an operator who did not just open the link has no digits to type. With the GitHub gate on, they would also have needed the owner's GitHub account. Installs that want that second factor keep require_github on.
A stolen link credential cookieHolds, as beforeHttpOnly; Secure; SameSite=Strict, host-only on the link's origin; revoking the device or Reset link ends it at once. Before #1086 a thief also needed the owner's relay session; now the credential alone works through the link until revoked.
WebSocket hijackHoldsA cross-site handshake carries no SameSite=Strict colonizer_token, and the cockpit still requires Origin to equal https://<tunnel host> for a cookie-authenticated upgrade.
Install-id enumerationHoldsUnknown installs answer 404, known ones the pair page's 401: an oracle, but useless against 100-bit ids.
DoS of one install's pass-throughNew, accepted (L6)Anyone who knows the host can fill its per-install throttle (30 invite opens, or 200 rejected credentials, per 10 minutes) and pause pairing and the pass-through for everyone on that install until the window ends. The limits are generous, a bound owner's GitHub session is not throttled, and Reset link moves the install to a new host.
Logging and storage leaksHoldsThe throttle stores a key per install and per client, where the client is an HMAC of the IP under SESSION_SECRET, truncated — never the address — and a row lives only until its window ends. The worker still logs nothing; the DO logs only the templated line.
A relay compromiseUnchangedThe relay already saw link credentials in cleartext (R3's accepted residue); the GitHub layer never stood between a compromised relay and the cockpit.

The relay e2e (the_whole_link_round_trips_through_the_real_relay) now pairs a fresh browser through ?pair= and the six digits with no GitHub session, loads the cockpit and its websocket on the link credential alone, sends a forged and a revoked credential to the pair page, then switches the GitHub gate on and runs the owner flow behind it. The relay's own negative tests are in services/relay/test/pairing.test.mjs.

#Threat-model verdicts

ThreatVerdictWhere it is held, or the finding
Install-id guessing and enumerationHoldsIds are 20 base32 characters — 100 bits from crypto.getRandomValues (worker.js:89-104) — and register and dial are per-IP rate limited (wrangler.toml:38-48; worker.js:62-64, :111-113). Unknown, registered, bound and online installs do answer differently (404 / 302 to /_auth / 403 / 502 / proxied), but that oracle is useless against 100-bit ids. Registration growth is L2 below.
A stolen session cookieHolds, one caveat__Host-colonizer_session: Secure, HttpOnly, SameSite=Lax, no Domain, HMAC-SHA256 sealed, 7 days (auth.js:11-21); bound to one install and its current GitHub owner and re-checked against D1 on every request, so unbinding revokes it at once (auth.js:186-195; worker.js:223); stripped before forwarding to the cockpit (auth.js:197-209; worker.js:224-226); fails closed without SESSION_SECRET (auth.js:53-61). Caveat: logout only clears the browser copy (auth.js:180-182) — revoking one stolen cookie means unbinding the owner.
A relay compromiseFinding R3 (and R2)The relay sees every request and response in cleartext, including the cockpit's api-token, and can act as the owner. The hello signature — Ed25519 over nonce, install id and timestamp, a fresh 32-byte nonce per dial, ±300 s skew (tunnel.js:70, :106-107) — stops anyone from impersonating an install to the relay, but nothing authenticates the relay to the cockpit beyond WebPKI TLS.
Request smuggling through the frame protocolHoldsRelay stream ids are a monotone counter, and unknown or late ids are ignored (tunnel.js:43, :154-155, :186, :246); the client refuses duplicate ids without touching the live stream (remote.rs:704-730). clean_headers strips hop-by-hop headers, Host and sec-websocket-*, and drops values containing CR, LF or NUL (remote.rs:1030-1055); Host is always overwritten with the registered tunnel host (remote.rs:789); the body is rebuilt from frames in-process, so Content-Length and Transfer-Encoding are inert; //evil.com/x parses as a path with no authority, and absolute-form fails plausible_path (remote.rs:583). Nothing is ever dialled out — every request is answered by router.oneshot. R1 is an interop crash in this layer, not a smuggling vector.
WebSocket hijackHoldsA cross-site handshake does not carry the SameSite=Lax relay session cookie and gets 401 at the relay (worker.js:222-235); a tunnelled cookie-authenticated upgrade still needs Origin to equal https://<tunnel host> exactly at the cockpit (main.rs:966-978 at a518798); the relay forwards Origin untouched. Subprotocol negotiation is broken (L4).
Origin-fence bypass (#375's Origin-absent class)HoldsFor tunnelled requests Origin must equal https://<host> exactly; absent, null, http://, a sibling install's origin and a port mismatch all fail (main.rs:967-969; tests at remote.rs:1353-1396). The local fence is not widened: the tunnel host is admitted only with the in-process Tunnelled extension and the switch on (main.rs:939-950), so a DNS-rebinding page sending Host: <id>.my.colonizer.dev to 127.0.0.1:7878 gets 403. With remote access off, tunnelled requests get 503 and the host is refused locally.
DoS of one installFindings R5 and R1's slot leakOtherwise held at the relay by MAX_STREAMS 32, MAX_PENDING 16, a 120-burst / 20 rps bucket per install, chunk caps, 8 MiB body backpressure and head/idle/hello timers (tunnel.js:59-86, :171-197, :239-248, :375-385; protocol.js:4-15), and at the client by MAX_STREAMS, a 10 MiB MAX_BODY, a 60 s BODY_WAIT and a bounded 256-frame outbound queue (remote.rs:59-81, :534-536, :756-777).
DoS of the relay itselfHoldsDurable Objects are only allocated for known ids, behind the dial limiter and MAX_PENDING with 10 s hello timeouts (worker.js:110-126; tunnel.js:59-86); apex JSON bodies are capped at 1 KiB by bytes (worker.js:243-264); Ed25519 verification runs once per hello. Notes: every request to an unknown subdomain costs one D1 read with no limiter (worker.js:219), and registration rows accumulate (L2).
Key theft from <config>/remote/Finding R2; L3The key file is written 0600 (util.rs:328-339) and no endpoint or log returns key material; registration sends only the public key.
Logging leaksHoldsThe relay logs one line per stream: method, a templated path (query dropped, id-shaped segments replaced by :id), status, bytes, ms (tunnel.js:368-370; protocol.js:73-77); the worker logs nothing; error pages name missing variables, not values (auth.js:61). The client's activity entries record kind, via and target only, never query strings or bodies (remote.rs:331-340; activity.rs:646-661). Minor: see L5.
The pairing-code raceHoldsCodes are 6 uniform digits with a 10-minute expiry (auth.js:15, :89-99). Confirming requires the mothership's Ed25519 signature, so a browser can neither brute-force nor forge a confirm (worker.js:54-56, :130-142). The bind is UPDATE … WHERE owner_github_id IS NULL inside one D1 batch, so two concurrent confirms bind exactly one owner and the loser gets 409 (worker.js:176-184). One dependency on the cockpit side: the confirm screen must show each pending code's github_login prominently (worker.js:155), because an owner who confirms an attacker's pending pairing binds the attacker. Signed endpoints accept a 300 s replay window without a nonce (worker.js:28, :136); the mutations they guard are idempotent or first-wins, so the impact is negligible.

#The two boundary checks #536 asks for

A tunnelled request cannot skip the cockpit's own auth. HTTP goes through serve_req → router.oneshot on the full router, where host_guard is layered after web_router is merged (main.rs:1634-1637; remote.rs:796). WebSockets go through an in-memory axum::serve of the same router with the Tunnelled extension layered outside it (remote.rs:605-607). Tunnelled is constructed only in remote.rs (:605, :795) and cannot come from a frame or a header; Authenticated / Via are inserted by host_guard itself, never read from headers; nothing in the crate uses ConnectInfo, so no handler trusts a peer address. The relay's GitHub sign-in is a second gate, not a substitute: the browser still needs the cockpit token.

A tunnelled request cannot reach loopback-only services the cockpit doesn't serve. The client opens TCP only to the relay — register and dial (remote.rs:288-300, :414-447); everything else is in-process. Two pre-existing settings reachable by any token holder do accept loopback URLs — the notify webhook (notify.rs:449-474) and a provider's base_url (providers.rs:512-527) — but the loopback services they could reach authenticate on their own (the gateway wants a per-colony token, gateway/mod.rs:626-641; headscale sits behind a 0700 socket and API keys, mesh.rs:245-296), so a remote token holder gains nothing a local one lacks. Blocking loopback and link-local targets there is hardening independent of remote access (sandbox-network.md covers the colony-side fence).

#Findings filed as separate security issues

The colony could file at most five issues; each finding was confirmed by code reading and re-checked independently. The titles are exact, so the issues can be found:

IdSeverityIssueWhere
R1High"Remote access: relay throws on the tunnel client's array-shaped res headers, hanging tunnelled responses and leaking stream slots"remote.rs:800-807 at a518798; protocol.js:46-51; tunnel.js:158-161
R2High"Remote access: reset_identity leaves the old install live at the relay, so a leaked &lt;config&gt;/remote/key keeps receiving the owner's traffic"remote.rs:245-263; worker.js:47-59
R3High"Remote access: the cockpit api-token crosses the relay in cleartext, survives every reset, and COLONIZER_REMOTE_URL accepts plaintext ws://"worker.js:224-230; main.rs:961-966, :1001-1014 at a518798; auth.rs:45-59; remote.rs:319-327
R4Medium"Remote access: relay forwards tunnelled Set-Cookie with Domain=, letting one install toss cookies onto sibling installs; cockpit cookie lacks Secure"protocol.js:23-66; auth.rs:135-137
R5Medium"Remote access: tunnel client queues relay-to-cockpit frames without bound (ws_in, bodies), so a relay or remote browser can exhaust mothership memory"remote.rs:484, :493, :445-451, :756-766

How each one works:

#Lower-severity notes, not filed

Each was confirmed by code reading; L1 also by a proof-of-concept run.

#Before the relay is deployed