Security review — 2026-08-23
A sweep over every API route, every authentication path and every open connection across the three installs (the main cluster, edge-01, edge-02). Everything below was measured against the running systems, not read off the source; where a claim is a measurement, the measurement is quoted.
Two things were found and fixed the same day. One of them was live and exploitable from the internet.
1. CRITICAL, fixed — machine paths were open to the world on both public installs#
What. Every route is gated centrally by middleware.ts, whose matcher covers everything Next
does not serve statically. Individual routes therefore do not enforce auth themselves — which is
the right design, since auth cannot be forgotten in a new route. It also means the whole attack
surface is two predicates in lib/auth-core.ts: isPublicPath and isMachinePath.
isMachinePath covers the connector-facing routes. The middleware treats them like this:
if (isMachinePath(path) && !enforceMachineAuth()) { … return NextResponse.next(); }So unless CONDUIT_ENFORCE_MACHINE_AUTH=1 is set, those paths have no authentication at all.
That switch exists for a good reason — turning panel login on must not 401 live connector
heartbeats that predate the feature — but it is opt-in, and it was never turned on where it mattered
most.
Measured. The main cluster had it set. Both boxes with a public IP did not:
| install | CONDUIT_ENFORCE_MACHINE_AUTH | :3001 exposure |
|---|---|---|
| main cluster (core-01/02/03) | 1 | LAN only, behind the VIP + Cloudflare |
| edge-01 (Hetzner) | unset | open to the internet |
| edge-02 (24fire) | unset | open to the internet |
From an ordinary internet connection, with no credentials of any kind:
GET http://<public-ip>:3001/api/bans → 200 {"ok":true,"punishments":[]}
GET http://<public-ip>:3001/api/friends → 200 {"ok":true,"stats":{…}}
GET http://<public-ip>:3001/api/parties → 200 {"ok":true,"parties":[]}
GET http://<public-ip>:3001/api/connector/servers → 200 {"active":true,"servers":[…]}
GET http://<public-ip>:3001/api/metrics/prometheus→ 200 # HELP conduit_service_up …
The one that turns this from a data leak into a control-plane hole:
POST /api/connector/action {"kind":"__invalid__"} → 409 {"error":"no connector active"}
409 is not 401. The request passed authentication completely and reached business logic, which
declined only because that particular box had no connector attached at that moment. edge-02 does
run live connectors (network-proxy-204, network-lobby-205, reported "active":true), so the
same call there reaches the player action queue — move, message, broadcast, kick. That was
not demonstrated further, for obvious reasons.
Fix. Before flipping anything, the middleware's own warnTokenlessMachineHit log was checked on
both boxes over three days. The only tokenless hits recorded were the probes above, minutes old —
zero legitimate traffic, which proves the real connectors were already sending their token and that
enforcing would break nothing. CONDUIT_ENFORCE_MACHINE_AUTH=1 was then set on both and the panels
restarted.
Verified after. All four probes now return 401 {"error":"unauthenticated"} from outside, and
querying with a valid connector token from inside still returns "active":true with the live
servers. Hole closed, connectors unaffected.
Still outstanding, and needs the owner: :3001 and Proxmox :8006 remain open to the entire
internet on both boxes. Enforcing machine auth removes the unauthenticated hole; it does not reduce
the exposed surface. These should be firewalled to known addresses, or put behind the same
Cloudflare path the main panel uses.
2. Fixed — wg show dump would have handed the panel every node's private key#
Covered in FEATURES §4.23a. The mesh traffic counters need wg show <iface> dump, whose first line
is the interface and begins with the private key. The command drops that line on the node
(tail -n +2) so it never crosses the ssh boundary, and parseWgDump refuses to read a four-field
interface line as a peer even if one arrives anyway. There is a test that plants a fake private key
in the input and asserts it is not returned as a peer.
3. Guard added — the unauthenticated surface is now frozen#
lib/auth-surface.test.ts pins both predicates: every deliberately public path, every machine path,
and a list of control-plane routes (/api/tasks, /api/files, /api/update, /api/wireguard, …)
that must be neither. It also checks that neither list can be widened by prefix — /api/statuses
must not inherit /api/status, /api/bans/all must not inherit /api/bans — and freezes the size
of both lists, so adding an unauthenticated entry point is an argued change in a diff rather than
something noticed later from outside.
4. Reviewed and found sound#
- No route relies on its own auth check and gets it wrong. A scan flagged 99 of 228 route files as having no in-file auth, which was a false alarm from the scanner, not a finding: the middleware matcher covers them all. Worth recording because the same scan will mislead the next person.
- Role gates. Admin-only paths, viewer read-only enforcement and account self-service are all applied centrally, and every denied attempt is written to the operator audit log with user, role, method, path and IP — denials are recorded, not just successes.
- Mutations are audited. Every allowed mutating request is recorded through one hook rather than 78 call sites.
- Connector token separation. The connector's token is no longer the panel machine token. That split matters exactly here: the connector credential is stored inside every game container, and before the split, reading one of those files gave panel admin.
/api/hooks/<token>is public by design — the token in the URL is the credential. Acceptable, but it means hook URLs are secrets and belong in the same class as tokens.- ctagent heartbeats are public to the middleware but verify a per-container derived token
in-route against the vmid the caller claims, which is stronger than the middleware could manage
(deriving it needs
node:crypto, unavailable on the edge runtime).
5. Hardened 2026-09-04#
- The session signing key is no longer the machine bearer. Without
CONDUIT_SESSION_SECRETthe key is derived from the agent token with HMAC and cannot be run back into it; an explicit secret is used byte for byte as before. Default installs get one forced re-login on this release. - Every secret compare is constant-time: the machine token, the session and sudo signatures,
the connector bearer and the fleet services bearer, through one edge-safe
safeEqual. - Cookies carry
Securebehind TLS (the proxy'sx-forwarded-protoor an https URL) and stay storable on a plain-http LAN panel. - SSO links an account only on an email the provider has verified. Google's
email_verified, Discord'sverified, GitHub's verified-emails list; an unverified match is refused with the provider named, so a profile email anyone can type no longer hands over the matching account. - A role can only grant what its editor holds.
users.rolesused to be admin in all but name. - The admin-only surface is held by a test against the manifest, now that the old path lists
are gone; raw database rows (
/api/db) went back to the admin, where the old rule had them. - Fleet grants are listed and revocable, with when each was last presented; leftovers read "never used".
6. Fleet credentials, reviewed 2026-09-05#
What crosses between joined clusters, and how the reader is authenticated:
| Crosses | Where it lives | Who reads it | Channel | Reader authenticated by |
|---|---|---|---|---|
| Logical partition (groups, tasks, labels, blueprints, schedules, automation rules, identity projection) | store keys | any linked peer | node agent GET /v1/state?keys= (allowlist LOGICAL_KEYS) or panel GET /api/fleet/partition | reach token from the mesh, or grant secret + cap |
Reach agent token + scoped Proxmox token + cluster CA (fleet.reach[]) | /etc/conduit/agent-peers.json and /etc/conduit/reach/<estate>.json on every node, mode 600 | the peer whose mesh /24 holds the source address | node agent GET /v1/reach | source address inside that peer's mesh segment (the anti-spoof rule in the conduit nft table is what makes the address a credential) |
Grant secret (fleet.grants[]) | our store; the peer holds the plaintext | the peer it was issued to | bearer on every /api/fleet/* call | constant-time compare |
Peer token (fleet.peers[].token) | our store | we present it | bearer on every tick to peer.url | the far panel |
| WireGuard public keys, endpoints | wireguard | peer with the mesh cap | GET /api/fleet/mesh | grant + cap |
Nothing under network (Cloudflare token, Origin CA key, TLS private key, forwarding secret, database
passwords, SSO client secrets) crosses through any declared path. But a cluster let "work together"
holds a reach token, and the node agent runs POST /v1/exec without a vmid for that token as root on
the host, which reads the store file. That is the parity the owner chose; the Secrets page now says
which clusters can read everything on it, and the register lists every fleet credential by full path
(fleet.peers[].token and fleet.grants[].secret had no row; the four reach rows resolved to the
wrong field or to nothing). Fixed the same day: a shell injection in the relayed config routes (a
quote in a file path reached a root shell; safeRel + sq, and a tree-wide splice ratchet), the
relay's query guard (percent-encoded quotes), a bare peer address defaulting to https:// with an
"in the clear" chip on an explicit http:// peer, reach pairs re-minted every 30 days with a one-day
overlap, per-peer revoke guards, a detach that withdraws reach at once and drops the replica and the
remote mesh entry, and a bundle refused when its issuer is not the cluster it was read from.
Still open, and the owner's call: edge-01 and edge-02 were cloned from one install, so they share the agent token and the session secret; until one side rotates both, the grant boundary between them is nominal.
Roadmap — what to build next, in order#
- Make the enforcement flag non-optional. Done 2026-09-04:
enforceMachineAuth()is true unlessCONDUIT_ENFORCE_MACHINE_AUTHis explicitly0,falseoroff; the deploy script writes=1; an install that opted out sees an amber "machine auth opted out" on/secrets. The escape hatch kept the existing variable rather than a new one, so no install has to learn a second name. A default that fails open was the bug, not the flag. - Surface it in the panel. Partly done:
/secretsreports the enforcement posture. Whether :3001 answers from outside and whether the panel is behind a proxy are still unsaid. - A doctor check, and a hub-side one.
conduit doctorshould fail on tokenless machine paths. The hub already receives a telemetry ping from every install; a cluster reporting itself exposed could be told so. - Per-endpoint API tokens (P6, already queued). One machine token that means full access is a blast radius, not a credential. Scope tokens to route groups with an expiry.
- Rate limiting on unauthenticated routes.
/api/auth/login,/api/hooks/*and the pack downloads have no ceiling. The hub already has a rate limiter worth reusing. - Egress and firewall posture as a first-class page. Which ports each node exposes to the internet, checked from outside rather than asserted from inside — the external reachability probe already exists for game ports and would extend to management ports.
- Secret rotation with a paper trail. Rotating the agent, connector or session secret is currently a manual, scary operation. It should be a wizard that stages, flips and verifies, the way the forwarding-secret parity flip was done by hand in August.