// Inbound HMAC-signed peer requests and signed responses. // // Required request headers (names per plugin, opts.headers): // ENV_ID caller's env UUID (looked up in the plugin's peers table) // TIMESTAMP ms-since-epoch; rejected outside the skew window // NONCE random per-request bytes; rejected if reused within the retention // window (replay protection, see recordNonce) // SIGNATURE hex HMAC-SHA256 over the request canonical (peerProtocol) // // Peer requests use a plugin-specific Content-Type (application/vnd.+json) // so Saltcorn's express.json() middleware leaves the stream untouched: the exact // raw bytes are read here -- capped BEFORE the signature check -- and used in the // HMAC (no JSON-canonical fragility, no re-serialization assumptions). // // Host and protocol come from Express (req.host -- Express 5 keeps the port -- and // req.protocol), which honour the app's "trust proxy" setting: the same host // Saltcorn routes the tenant by. Raw X-Forwarded-* headers are never read: with // trust proxy off (Saltcorn's default) they are client-controlled. const { NONCE_RETENTION_MS, buildCanonical, normalizeHost, verifySignature, signResponse, timestampWithinSkew } = require("./peerProtocol"); const { readCappedRequest } = require("./cappedBody"); const { HTTP_STATUS } = require("./httpStatus"); const JSON_CONTENT_TYPE = "application/json"; // Where requirePeerAuth leaves what sendSigned needs to sign the response. const PEER_CONTEXT = Symbol("peerContext"); // Create the raw replay-protection store `table`: every accepted peer request's // (env_id, nonce) is recorded so a captured signed request cannot be replayed // within the window. Raw + regenerable (losing it only reopens the small replay // window). Idempotent. const createNonceStore = async (table) => { const db = require("@saltcorn/data/db"); const schema = db.getTenantSchemaPrefix(); await db.query(` CREATE TABLE IF NOT EXISTS ${schema}${table} ( env_id TEXT NOT NULL, nonce TEXT NOT NULL, seen_ms BIGINT NOT NULL, PRIMARY KEY (env_id, nonce) ) `); await db.query(`CREATE INDEX IF NOT EXISTS ${table}_seen ON ${schema}${table} (seen_ms)`); }; // opts = { // headers { ENV_ID, TIMESTAMP, NONCE, SIGNATURE, RESPONSE_SIGNATURE } names // nonceTable the createNonceStore table of seen request nonces // maxBodyBytes largest request body accepted (413 above it) // peers { findPeerByEnvId, peerSecret, touchPeerLastSeen } (peerStore) // getEnv async () -> this instance's env ({ env_id, require_tls }) or null // } const createPeerAuth = ({ headers, nonceTable, maxBodyBytes, peers, getEnv }) => { const requiredHeaders = [headers.ENV_ID, headers.TIMESTAMP, headers.NONCE, headers.SIGNATURE].map((h) => h.toLowerCase()); // Record (env_id, nonce); false if it was already seen (replay) or on a store // error (fail closed -- better to reject than to allow a replay). Prunes // entries past the retention window first. const recordNonce = async (envId, nonce) => { const db = require("@saltcorn/data/db"); const schema = db.getTenantSchemaPrefix(); const now = Date.now(); try { await db.query(`DELETE FROM ${schema}${nonceTable} WHERE seen_ms < $1`, [now - NONCE_RETENTION_MS]); await db.query(`INSERT INTO ${schema}${nonceTable} (env_id, nonce, seen_ms) VALUES ($1, $2, $3)`, [envId, nonce, now]); return true; } catch (e) { return false; } }; // Unsigned rejection (the caller is not authenticated yet); always null. const reject = (res, status, message) => { res.status(status).json({ error: message }); return null; }; const rejectTooLarge = (res) => { res.set("Connection", "close"); return reject(res, HTTP_STATUS.PAYLOAD_TOO_LARGE, `request body exceeds ${maxBodyBytes} bytes`); }; // Send an authenticated peer's response with the response signature over the // exact bytes and this env's id. Only valid after requirePeerAuth succeeded. const sendSigned = (req, res, status, bytes, contentType) => { const ctx = req[PEER_CONTEXT]; res.status(status); res.set({ "Content-Type": contentType, [headers.ENV_ID]: ctx.myEnvId || "", [headers.RESPONSE_SIGNATURE]: signResponse(ctx.secret, { nonce: ctx.nonce, status: status, body: bytes }) }); res.end(bytes); }; const sendSignedJson = (req, res, status, obj) => { sendSigned(req, res, status, Buffer.from(JSON.stringify(obj), "utf8"), JSON_CONTENT_TYPE); }; // On success: req.body is the parsed signed JSON body ({} when empty) and the // peer row is returned; the handler answers with sendSigned / sendSignedJson. // On failure: a 4xx response is sent and null is returned. const requirePeerAuth = async (req, res) => { for (const h of requiredHeaders) { if (!req.headers[h]) { return reject(res, HTTP_STATUS.BAD_REQUEST, `missing header ${h}`); } } const declaredLength = Number(req.headers["content-length"]); if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) { return rejectTooLarge(res); } const [callerEnvId, timestamp, nonce, signature] = requiredHeaders.map((h) => req.headers[h]); if (!timestampWithinSkew(timestamp)) { return reject(res, HTTP_STATUS.UNAUTHORIZED, "timestamp out of skew window"); } const peerRow = await peers.findPeerByEnvId(callerEnvId); if (!peerRow) { return reject(res, HTTP_STATUS.UNAUTHORIZED, "unknown peer env_id"); } let secret; try { secret = await peers.peerSecret(peerRow.peer_id); } catch (e) { return reject(res, HTTP_STATUS.UNAUTHORIZED, "peer not provisioned"); } // Raw bytes (empty for GET/HEAD), capped BEFORE the signature check: only a // (non-secret) env_id is needed to get this far, so an unbounded read would // let anyone who knows one exhaust memory. The HMAC covers exactly what // arrived. let bodyRaw = Buffer.alloc(0); if (req.method !== "GET" && req.method !== "HEAD") { try { bodyRaw = await readCappedRequest(req, maxBodyBytes); } catch (e) { return reject(res, HTTP_STATUS.BAD_REQUEST, "failed to read request body"); } if (bodyRaw === null) { return rejectTooLarge(res); } } // 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. req.hostname would drop the // port the sender signed. NOTE: do NOT compare against peerRow.base_url -- // inbound that is the SENDER's address (for pull-back), not this receiver's. const canonical = buildCanonical({ timestamp: timestamp, nonce: nonce, method: req.method, path: req.originalUrl || req.url, targetHost: normalizeHost(req.host), body: bodyRaw }); if (!verifySignature(secret, canonical, signature)) { return reject(res, HTTP_STATUS.UNAUTHORIZED, "bad signature"); } // Replay guard: only after the signature is valid (so unauthenticated // callers cannot flood the nonce store). A reused (env_id, nonce) within the // window -- a captured request replayed verbatim -- is rejected here. if (!(await recordNonce(callerEnvId, nonce))) { return reject(res, HTTP_STATUS.UNAUTHORIZED, "nonce replay or store error"); } // Always replace req.body: when the signed body is empty, whatever an // upstream parser produced from an unsigned Content-Type must not be trusted. req.body = {}; if (bodyRaw.length > 0) { try { req.body = JSON.parse(bodyRaw.toString("utf8")); } catch (e) { return reject(res, HTTP_STATUS.BAD_REQUEST, "body is not valid JSON"); } } // From here on the caller is authenticated: responses are signed. const env = await getEnv(); req[PEER_CONTEXT] = { secret: secret, nonce: nonce, myEnvId: env ? env.env_id : null }; // If this env requires TLS for inbound peer traffic, reject any request that // did not arrive over HTTPS. req.protocol is "https" for a direct TLS socket, // or when a TRUSTED proxy (Express trust proxy) reports X-Forwarded-Proto. if (env && env.require_tls && req.protocol !== "https") { sendSignedJson(req, res, HTTP_STATUS.FORBIDDEN, { error: "TLS required" }); return null; } await peers.touchPeerLastSeen(peerRow.peer_id); return peerRow; }; return { requirePeerAuth, sendSigned, sendSignedJson }; }; module.exports = { createNonceStore, createPeerAuth };