sc-dev-deploy/lib/peerAuth.js
2026-06-24 20:22:21 -05:00

172 lines
6 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; 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 the skew window first: a timestamp that old is already
// rejected by timestampWithinSkew, so its nonce can never be a valid replay.
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 - 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
};