Replace raw staff key in URLs with signed, org-bound tokens
The Engagement Report tab wrote STAFF_REPORT_KEY into the iframe src for every user with "access CiviCRM", without checking contact permissions. Anyone could copy it from the DOM and read any org's report and files, bypassing Civi ACLs; the key never expired and landed in access logs. - Extension (0.4.0): Tab.php checks Contact_Permission::allow(VIEW) and mints sig = base64url(HMAC-SHA256(key, "webform-mw.staff-report.v1: <org>:<exp>")) with an 8h exp. The secret no longer leaves the server. - App: lib/staff-token.mjs verifies the token (constant-time, org must match, rejects expired or >12h tokens). /staff/report, /api/staff/report and /api/staff/file share staffAuthQuery(); client links carry exp/sig. - Legacy ?key= is still accepted (constant-time compare, logs a warning) so the app can deploy before the extension. Remove it once every Civi install runs extension 0.4.0. - PHP/Node signature compatibility is pinned by a PHP-generated vector.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Signed, org-bound, expiring tokens for the internal staff report.
|
||||
*
|
||||
* The CiviCRM extension's Engagement Report tab mints a token per render:
|
||||
*
|
||||
* sig = base64url( HMAC-SHA256( STAFF_REPORT_KEY,
|
||||
* "webform-mw.staff-report.v1:<org>:<exp>" ) )
|
||||
*
|
||||
* and links to /staff/report?org=<org>&exp=<exp>&sig=<sig>. The raw shared
|
||||
* secret never leaves the two servers, and a captured URL only unlocks one
|
||||
* org until it expires — unlike the legacy `?key=<secret>` form, which
|
||||
* unlocked every org forever.
|
||||
*
|
||||
* The PHP side (civi-extension/.../Page/Tab.php) must build the message
|
||||
* string byte-for-byte the same way. The fixed test vector in
|
||||
* staff-token.test.mjs was produced by the PHP implementation.
|
||||
*
|
||||
* Written as JS+JSDoc rather than TS so Node's built-in --test runner can
|
||||
* import this file directly — matches lib/mime.mjs.
|
||||
*/
|
||||
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Longest validity window we accept, measured from now. The extension
|
||||
* issues 8-hour tokens; the extra headroom absorbs clock skew between the
|
||||
* Civi server and Amplify without letting anyone mint long-lived links.
|
||||
*/
|
||||
export const STAFF_TOKEN_MAX_TTL_SECONDS = 12 * 60 * 60;
|
||||
|
||||
/**
|
||||
* @param {string} org
|
||||
* @param {string} exp
|
||||
*/
|
||||
function message(org, exp) {
|
||||
return `webform-mw.staff-report.v1:${org}:${exp}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} secret
|
||||
* @param {string} org
|
||||
* @param {string} exp
|
||||
* @returns {string}
|
||||
*/
|
||||
export function signStaffToken(secret, org, exp) {
|
||||
return createHmac("sha256", secret).update(message(org, exp)).digest("base64url");
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time string comparison. Lengths may differ; that case returns
|
||||
* false without calling timingSafeEqual (which would throw).
|
||||
*
|
||||
* @param {string} a
|
||||
* @param {string} b
|
||||
*/
|
||||
export function safeEqual(a, b) {
|
||||
const ab = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | null | undefined} secret
|
||||
* @param {string | null | undefined} org
|
||||
* @param {string | null | undefined} exp
|
||||
* @param {string | null | undefined} sig
|
||||
* @param {number} [nowSeconds]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function verifyStaffToken(secret, org, exp, sig, nowSeconds = Math.floor(Date.now() / 1000)) {
|
||||
if (!secret || !org || !exp || !sig) return false;
|
||||
if (!/^[1-9]\d*$/.test(org) || !/^[1-9]\d*$/.test(exp)) return false;
|
||||
const expN = Number(exp);
|
||||
if (expN <= nowSeconds) return false;
|
||||
if (expN - nowSeconds > STAFF_TOKEN_MAX_TTL_SECONDS) return false;
|
||||
return safeEqual(signStaffToken(secret, org, exp), sig);
|
||||
}
|
||||
Reference in New Issue
Block a user