141 lines
4.7 KiB
JavaScript
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
|
|
};
|