sc-site-sync/lib/common/peerServer.js
2026-10-19 01:25:16 -05:00

223 lines
9.2 KiB
JavaScript

// 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.<plugin>+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
};