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.
63 lines
2.1 KiB
PHP
63 lines
2.1 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Engagement Report tab page.
|
|
*
|
|
* Loaded as an AJAX snippet by the contact-view tabset (snippet=1). The
|
|
* 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 = (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=' . $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.
|
|
$this->assign('appOrigin', parse_url($appUrl, PHP_URL_SCHEME)
|
|
. '://' . parse_url($appUrl, PHP_URL_HOST));
|
|
}
|
|
$this->assign('configured', $configured);
|
|
|
|
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), '+/', '-_'), '=');
|
|
}
|
|
|
|
}
|