sc-dev-deploy/lib/peerAuth.js
2026-06-01 16:43:43 -05:00

141 lines
4.7 KiB
JavaScript

// 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
};