ConduitDocs

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:

installCONDUIT_ENFORCE_MACHINE_AUTH:3001 exposure
main cluster (core-01/02/03)1LAN only, behind the VIP + Cloudflare
edge-01 (Hetzner)unsetopen to the internet
edge-02 (24fire)unsetopen 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_SECRET the 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 Secure behind TLS (the proxy's x-forwarded-proto or 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's verified, 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.roles used 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:

CrossesWhere it livesWho reads itChannelReader authenticated by
Logical partition (groups, tasks, labels, blueprints, schedules, automation rules, identity projection)store keysany linked peernode agent GET /v1/state?keys= (allowlist LOGICAL_KEYS) or panel GET /api/fleet/partitionreach 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 600the peer whose mesh /24 holds the source addressnode agent GET /v1/reachsource 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 plaintextthe peer it was issued tobearer on every /api/fleet/* callconstant-time compare
Peer token (fleet.peers[].token)our storewe present itbearer on every tick to peer.urlthe far panel
WireGuard public keys, endpointswireguardpeer with the mesh capGET /api/fleet/meshgrant + 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#

  1. Make the enforcement flag non-optional. Done 2026-09-04: enforceMachineAuth() is true unless CONDUIT_ENFORCE_MACHINE_AUTH is explicitly 0, false or off; 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.
  2. Surface it in the panel. Partly done: /secrets reports the enforcement posture. Whether :3001 answers from outside and whether the panel is behind a proxy are still unsaid.
  3. A doctor check, and a hub-side one. conduit doctor should fail on tokenless machine paths. The hub already receives a telemetry ping from every install; a cluster reporting itself exposed could be told so.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
Edit on GitHubdocs/security-review.md 7 min readUpdated