223 lines
9.2 KiB
JavaScript
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
|
|
};
|