Node hijack: POST /cluster/heartbeat allows any authed user to overwrite primary node's api_url #106

Closed
opened 2026-05-26 18:19:01 -04:00 by zgaetano · 1 comment
Owner

Fixed in 04ce096. Migration 019 adds api_tokens.bound_hostname. Auth middleware exposes it as req.tokenBoundHostname. POST /cluster/heartbeat rejects with 403 when the body hostname doesn't match the bound hostname; tokens without a binding can only post heartbeats if the caller is an admin user. Token-create endpoint accepts bound_hostname for issuing node-agent credentials.

Fixed in 04ce096. Migration 019 adds `api_tokens.bound_hostname`. Auth middleware exposes it as `req.tokenBoundHostname`. `POST /cluster/heartbeat` rejects with 403 when the body hostname doesn't match the bound hostname; tokens without a binding can only post heartbeats if the caller is an admin user. Token-create endpoint accepts `bound_hostname` for issuing node-agent credentials.
Author
Owner

Fix Plan — #106 Node hijack via POST /cluster/heartbeat

Root cause: cluster.js:102-147 is behind requireAuth (any authed user, including editor). Accepts arbitrary hostname + api_url. Uses ON CONFLICT (hostname) DO UPDATE — any user can overwrite any node row. Downstream, resolveNodeTarget() in recorders.js:51 reads hijacked api_url and forwards recorder traffic to attacker host.

Fix:

  1. Move /heartbeat off JWT auth to internal-only with cluster secret:
// cluster.js
router.post("/heartbeat", (req, res) => {
  if (req.headers["x-cluster-secret"] !== process.env.CLUSTER_SECRET)
    return res.status(403).json({ error: "Forbidden" });
  // ... existing logic unchanged
});
  1. Set CLUSTER_SECRET in .env on mam-api and all node-agents.

  2. Update node-agent heartbeat call to send x-cluster-secret header.

  3. Ignore non-whitelisted fields — only accept hostname, api_url, gpu_count, bmd_count, capacity_score, version.

Files: src/routes/cluster.js, services/node-agent/index.js, .env
Effort: ~2h
**Priority: P0 — security

## Fix Plan — #106 Node hijack via POST /cluster/heartbeat **Root cause:** `cluster.js:102-147` is behind `requireAuth` (any authed user, including editor). Accepts arbitrary `hostname` + `api_url`. Uses `ON CONFLICT (hostname) DO UPDATE` — any user can overwrite any node row. Downstream, `resolveNodeTarget()` in `recorders.js:51` reads hijacked `api_url` and forwards recorder traffic to attacker host. **Fix:** 1. **Move `/heartbeat` off JWT auth** to internal-only with cluster secret: ```js // cluster.js router.post("/heartbeat", (req, res) => { if (req.headers["x-cluster-secret"] !== process.env.CLUSTER_SECRET) return res.status(403).json({ error: "Forbidden" }); // ... existing logic unchanged }); ``` 2. **Set `CLUSTER_SECRET`** in `.env` on mam-api and all node-agents. 3. **Update node-agent** heartbeat call to send `x-cluster-secret` header. 4. **Ignore non-whitelisted fields** — only accept `hostname`, `api_url`, `gpu_count`, `bmd_count`, `capacity_score`, `version`. **Files:** `src/routes/cluster.js`, `services/node-agent/index.js`, `.env` **Effort:** ~2h **Priority: P0 — security
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: WildDragonLLC/dragonflight#106
No description provided.