// Verify incoming HMAC-signed peer requests. // // Required headers: // X-DD-Env-Id caller's env UUID (looked up in _dd_peers) // X-DD-Timestamp ms-since-epoch; rejected if skew > 5 min // X-DD-Nonce random per-request opaque bytes; rejected if reused within // the skew window (replay protection, see recordNonce) // X-DD-Signature hex HMAC-SHA256 over canonical string // // On success: req.dd_peer is set (peer row), req.body is the parsed JSON body, // and the function returns the peer. On failure: a 4xx response is sent and // null is returned. // // Peer requests use Content-Type: application/vnd.dev-deploy+json so Saltcorn's // express.json() middleware doesn't consume the stream upstream. That lets us // read the exact raw bytes here and use them in the HMAC -- no JSON-canonical // fragility, no re-serialization assumptions about whitespace or key order. const db = require("@saltcorn/data/db"); const { buildCanonical, normalizeHost, verifySignature, timestampWithinSkew, SKEW_TOLERANCE_MS } = require("./crypto"); const { findPeerByEnvId, peerSecret, touchPeerLastSeen } = require("./peers"); const { getEnv } = require("./env"); const REQUIRED_HEADERS = ["x-dd-env-id", "x-dd-timestamp", "x-dd-nonce", "x-dd-signature"]; const readRawBody = async (req) => { const chunks = []; for await (const chunk of req) { chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); } return Buffer.concat(chunks).toString("utf8"); }; // Record (env_id, nonce); returns false if it was already seen (replay) or on a // store error (fail closed -- better to reject than to allow a replay). Prunes // entries older than 2x the skew window first. The acceptance window is 2x SKEW // wide (timestampWithinSkew accepts ts in [now-SKEW, now+SKEW]), and seen_ms is // the RECEIVE time which can trail ts by up to SKEW when a peer's clock leads, so // a nonce must be retained for 2x SKEW past receipt to cover the whole window a // captured request stays replayable -- pruning at 1x SKEW reopened that gap. const recordNonce = async (envId, nonce) => { const schema = db.getTenantSchemaPrefix(); const now = Date.now(); try { await db.query(`DELETE FROM ${schema}_dd_seen_nonces WHERE seen_ms < $1`, [now - 2 * SKEW_TOLERANCE_MS]); await db.query( `INSERT INTO ${schema}_dd_seen_nonces (env_id, nonce, seen_ms) VALUES ($1, $2, $3)`, [envId, nonce, now] ); return true; } catch (e) { return false; } }; const requirePeerAuth = async (req, res) => { for (const h of REQUIRED_HEADERS) { if (!req.headers[h]) { res.status(400).json({ error: `missing header ${h}` }); return null; } } const callerEnvId = req.headers["x-dd-env-id"]; const timestamp = req.headers["x-dd-timestamp"]; const nonce = req.headers["x-dd-nonce"]; const signature = req.headers["x-dd-signature"]; if (!timestampWithinSkew(timestamp)) { res.status(401).json({ error: "timestamp out of skew window" }); return null; } const peerRow = await findPeerByEnvId(callerEnvId); if (!peerRow) { res.status(401).json({ error: "unknown peer env_id" }); return null; } let secret; try { secret = await peerSecret(peerRow.peer_id); } catch (e) { res.status(401).json({ error: "peer not provisioned" }); return null; } // Read raw bytes (empty for GET/HEAD). HMAC covers exactly what arrived. let bodyRaw = ""; if (req.method !== "GET" && req.method !== "HEAD") { try { bodyRaw = await readRawBody(req); } catch (e) { res.status(400).json({ error: "failed to read request body" }); return null; } } // Bind the target host (which tenant Saltcorn routed this request to) into the // signed canonical: a request signed for tenant t1 must NOT verify when // replayed against tenant t2 on the same server. Prefer X-Forwarded-Host (set // by a trusted proxy) then the Host header, normalized identically to the // outbound side (transport.js derives it from the peer base_url). NOTE: do NOT // compare against peerRow.base_url -- inbound that is the SENDER's address // (for pull-back), not this receiver's host. const fullPath = req.originalUrl || req.url; const fwdHost = req.headers["x-forwarded-host"]; const rawHost = fwdHost ? String(fwdHost).split(",")[0] : req.headers.host; const targetHost = normalizeHost(rawHost); const canonical = buildCanonical({ timestamp: timestamp, nonce: nonce, method: req.method, path: fullPath, targetHost: targetHost, body: bodyRaw }); if (!verifySignature(secret, canonical, signature)) { res.status(401).json({ error: "bad signature" }); return null; } // Replay guard: only after the signature is valid (so unauthenticated callers // cannot flood the nonce store). A reused (env_id, nonce) within the skew // window -- i.e. a captured request replayed verbatim -- is rejected here. if (!(await recordNonce(callerEnvId, nonce))) { res.status(401).json({ error: "nonce replay or store error" }); return null; } if (bodyRaw) { try { req.body = JSON.parse(bodyRaw); } catch (e) { res.status(400).json({ error: "body is not valid JSON" }); return null; } } // If this env requires TLS for inbound peer traffic, reject any request // that did not arrive over HTTPS. req.secure reflects a direct TLS socket; // x-forwarded-proto covers the case where a trusted proxy terminates TLS. const env = await getEnv(); if (env && env.require_tls) { const fwdProto = req.headers["x-forwarded-proto"]; const overTls = req.secure || (fwdProto && String(fwdProto).split(",")[0].trim() === "https"); if (!overTls) { res.status(403).json({ error: "TLS required" }); return null; } } await touchPeerLastSeen(peerRow.peer_id); req.dd_peer = peerRow; return peerRow; }; module.exports = { requirePeerAuth };