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
@@ -7,24 +7,41 @@
* template emits a single iframe pointing at the WebForm-mw staff report
* plus a small postMessage listener that auto-sizes the iframe to the
* report's content height.
*
* The iframe URL carries a signed, org-bound, expiring token rather than
* the shared secret itself — see lib/staff-token.mjs in the app. We only
* mint it after confirming the current user can view this contact, so
* Civi's contact ACLs carry through to the embedded report.
*/
class CRM_WebformMw_Page_Tab extends CRM_Core_Page {
/**
* Token lifetime. The app rejects anything valid for more than 12 hours.
*/
const TOKEN_TTL_SECONDS = 8 * 60 * 60;
public function run() {
$cid = CRM_Utils_Request::retrieve('cid', 'Positive', $this, TRUE);
$cid = (int) CRM_Utils_Request::retrieve('cid', 'Positive', $this, TRUE);
if (!CRM_Contact_BAO_Contact_Permission::allow($cid, CRM_Core_Permission::VIEW)) {
CRM_Utils_System::permissionDenied();
return;
}
$appUrl = _webform_mw_app_url();
$key = _webform_mw_staff_key();
$configured = ($appUrl !== '' && $key !== '');
if ($configured) {
$exp = time() + self::TOKEN_TTL_SECONDS;
$src = $appUrl . '/staff/report'
. '?org=' . urlencode((string) $cid)
. '&key=' . urlencode($key)
. '?org=' . $cid
. '&exp=' . $exp
. '&sig=' . urlencode(self::sign($key, $cid, $exp))
. '&frame=1';
$this->assign('iframeSrc', $src);
// Expose just the app origin so the parent-side postMessage
// listener can validate event.origin without leaking the secret.
// listener can validate event.origin.
$this->assign('appOrigin', parse_url($appUrl, PHP_URL_SCHEME)
. '://' . parse_url($appUrl, PHP_URL_HOST));
}
@@ -32,4 +49,14 @@ class CRM_WebformMw_Page_Tab extends CRM_Core_Page {
parent::run();
}
/**
* base64url(HMAC-SHA256(key, "webform-mw.staff-report.v1:<org>:<exp>")).
* Must stay byte-for-byte compatible with signStaffToken() in the app.
*/
private static function sign(string $key, int $org, int $exp): string {
$mac = hash_hmac('sha256', "webform-mw.staff-report.v1:{$org}:{$exp}", $key, TRUE);
return rtrim(strtr(base64_encode($mac), '+/', '-_'), '=');
}
}