Files
WebForm-mw/lib/staff-token.test.mjs
T
Joel Brock 818c979479 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.
2026-10-30 11:17:28 -07:00

65 lines
2.6 KiB
JavaScript

// 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);
});