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:
Joel Brock
2026-10-30 11:17:28 -07:00
parent e943158c49
commit 818c979479
12 changed files with 282 additions and 63 deletions
+42 -12
View File
@@ -1,18 +1,48 @@
/**
* Shared-secret auth for the internal staff routes.
* Auth for the internal staff routes (/staff/report, /api/staff/report,
* /api/staff/file).
*
* Staff hit URLs of the form /staff/report?org=<id>&key=<secret>. The
* secret is read from the STAFF_REPORT_KEY env var. If unset, the routes
* refuse every request (closed by default).
* Preferred: signed, org-bound, expiring tokens minted by the CiviCRM
* extension — /staff/report?org=<id>&exp=<unix>&sig=<hmac>. See
* lib/staff-token.mjs. The extension only mints a token after checking the
* Civi user can view that org, so Civi ACLs carry through.
*
* Stub mode (CIVI_* unset) does NOT bypass this check — we want to test
* the auth surface in dev too. For local dev, set STAFF_REPORT_KEY=dev in
* .env.local.
* Legacy (transitional): ?key=<STAFF_REPORT_KEY>. Still accepted so the app
* can deploy before the updated extension reaches the Civi server. Remove
* the legacy branch once every Civi install emits signed tokens.
*
* If STAFF_REPORT_KEY is unset, every request is refused (closed by
* default). Stub mode (CIVI_* unset) does NOT bypass this check — for local
* dev, set STAFF_REPORT_KEY=dev in .env.local.
*/
export function isStaffKeyValid(key: string | null | undefined): boolean {
const expected = process.env.STAFF_REPORT_KEY;
if (!expected) return false;
if (!key) return false;
return key === expected;
import { safeEqual, verifyStaffToken } from "./staff-token.mjs";
interface StaffAuthParams {
org: string | null | undefined;
exp?: string | null;
sig?: string | null;
key?: string | null;
}
/**
* Validate staff credentials for `org`. Returns the query-string fragment
* (without `org`) that downstream fetches and file links must carry, or
* null when unauthorized.
*/
export function staffAuthQuery({ org, exp, sig, key }: StaffAuthParams): string | null {
const secret = process.env.STAFF_REPORT_KEY;
if (!secret) return null;
if (exp || sig) {
return verifyStaffToken(secret, org, exp, sig)
? `exp=${encodeURIComponent(exp!)}&sig=${encodeURIComponent(sig!)}`
: null;
}
if (key && safeEqual(key, secret)) {
console.warn("[staff-auth] legacy ?key= access used; update the CiviCRM extension to signed tokens");
return `key=${encodeURIComponent(key)}`;
}
return null;
}
+78
View File
@@ -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);
}
+64
View File
@@ -0,0 +1,64 @@
// Run with: npm run test:token
import test from "node:test";
import assert from "node:assert/strict";
import {
signStaffToken,
verifyStaffToken,
safeEqual,
STAFF_TOKEN_MAX_TTL_SECONDS,
} from "./staff-token.mjs";
const SECRET = "test-secret";
const NOW = 1_800_000_000 - 3600; // one hour before the vector's exp
// Produced by the PHP implementation in civi-extension Tab.php:
// rtrim(strtr(base64_encode(hash_hmac('sha256',
// 'webform-mw.staff-report.v1:1234:1800000000', 'test-secret', TRUE)),
// '+/', '-_'), '=')
const PHP_VECTOR = "HlcxJCgUHfa6fhuvldUxZmCk_y62TcuQ4dH2JjFSqCo";
test("signStaffToken matches the PHP implementation", () => {
assert.equal(signStaffToken(SECRET, "1234", "1800000000"), PHP_VECTOR);
});
test("verifyStaffToken accepts a valid, unexpired token", () => {
assert.equal(verifyStaffToken(SECRET, "1234", "1800000000", PHP_VECTOR, NOW), true);
});
test("verifyStaffToken rejects a token for a different org", () => {
assert.equal(verifyStaffToken(SECRET, "1235", "1800000000", PHP_VECTOR, NOW), false);
});
test("verifyStaffToken rejects a tampered exp", () => {
assert.equal(verifyStaffToken(SECRET, "1234", "1800000001", PHP_VECTOR, NOW), false);
});
test("verifyStaffToken rejects a wrong secret", () => {
assert.equal(verifyStaffToken("other-secret", "1234", "1800000000", PHP_VECTOR, NOW), false);
});
test("verifyStaffToken rejects an expired token", () => {
assert.equal(verifyStaffToken(SECRET, "1234", "1800000000", PHP_VECTOR, 1_800_000_000), false);
});
test("verifyStaffToken rejects exp too far in the future", () => {
const now = 1_800_000_000 - STAFF_TOKEN_MAX_TTL_SECONDS - 1;
assert.equal(verifyStaffToken(SECRET, "1234", "1800000000", PHP_VECTOR, now), false);
});
test("verifyStaffToken rejects missing or malformed inputs", () => {
assert.equal(verifyStaffToken("", "1234", "1800000000", PHP_VECTOR, NOW), false);
assert.equal(verifyStaffToken(SECRET, null, "1800000000", PHP_VECTOR, NOW), false);
assert.equal(verifyStaffToken(SECRET, "1234", undefined, PHP_VECTOR, NOW), false);
assert.equal(verifyStaffToken(SECRET, "1234", "1800000000", "", NOW), false);
assert.equal(verifyStaffToken(SECRET, "01234", "1800000000", PHP_VECTOR, NOW), false);
assert.equal(verifyStaffToken(SECRET, "1234", "18e8", PHP_VECTOR, NOW), false);
assert.equal(verifyStaffToken(SECRET, "-1", "1800000000", PHP_VECTOR, NOW), false);
});
test("safeEqual compares by value and tolerates length mismatch", () => {
assert.equal(safeEqual("abc", "abc"), true);
assert.equal(safeEqual("abc", "abd"), false);
assert.equal(safeEqual("abc", "abcd"), false);
assert.equal(safeEqual("", ""), true);
});