// 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 (replay padding) // 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 { buildCanonical, normalizeHost, verifySignature, timestampWithinSkew } = 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"); }; 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; } 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 };