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
+16 -15
View File
@@ -23,7 +23,8 @@ import { FileLink } from "./report/FileLink";
interface StaffReportViewProps {
org: number;
authKey: string;
/** Validated staff auth params (`exp=&sig=` or legacy `key=`), sans org. */
authQuery: string;
/** CIVI_BASE_URL, used to build outbound file links. */
civiBaseUrl: string;
/** True when the page is being embedded in a CiviCRM tab via iframe. */
@@ -39,7 +40,7 @@ const STAGE_OPTION_GROUP_ID = 75;
export function StaffReportView({
org,
authKey,
authQuery,
civiBaseUrl,
framed = false,
}: StaffReportViewProps) {
@@ -50,7 +51,7 @@ export function StaffReportView({
(async () => {
try {
const res = await fetch(
`/api/staff/report?org=${encodeURIComponent(String(org))}&key=${encodeURIComponent(authKey)}`,
`/api/staff/report?org=${encodeURIComponent(String(org))}&${authQuery}`,
{ cache: "no-store" },
);
if (!res.ok) {
@@ -72,7 +73,7 @@ export function StaffReportView({
return () => {
alive = false;
};
}, [org, authKey]);
}, [org, authQuery]);
// When embedded, post our content height to the parent so the Civi tab's
// iframe can resize to fit (no nested scrollbars). The receiving script
@@ -209,7 +210,7 @@ export function StaffReportView({
options={data.options}
civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
authQuery={authQuery}
/>
))}
@@ -275,13 +276,13 @@ function StaffSection({
options,
civiBaseUrl,
org,
authKey,
authQuery,
}: {
section: StaffReportSection;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
org: number;
authKey: string;
authQuery: string;
}) {
const filled = section.fields.filter((f) => f.history.length > 0);
const empty = section.fields.filter((f) => f.history.length === 0);
@@ -336,7 +337,7 @@ function StaffSection({
options={options}
civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
authQuery={authQuery}
/>
))}
</ul>
@@ -384,13 +385,13 @@ function CompactFieldRow({
options,
civiBaseUrl,
org,
authKey,
authQuery,
}: {
field: StaffReportField;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
org: number;
authKey: string;
authQuery: string;
}) {
const [open, setOpen] = useState(false);
const latest = field.history[0];
@@ -408,7 +409,7 @@ function CompactFieldRow({
options={options}
civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
authQuery={authQuery}
/>
</span>
{latest.date ? (
@@ -447,7 +448,7 @@ function CompactFieldRow({
options={options}
civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
authQuery={authQuery}
/>
</span>
</li>
@@ -464,14 +465,14 @@ function FieldValue({
options,
civiBaseUrl,
org,
authKey,
authQuery,
}: {
field: StaffReportField;
entry: FieldHistoryEntry;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
org: number;
authKey: string;
authQuery: string;
}) {
if (field.descriptor.render === "file") {
const v = entry.value as
@@ -487,7 +488,7 @@ function FieldValue({
civiSignedUrl={v.url}
mime={v.mime}
org={org}
authKey={authKey}
authQuery={authQuery}
civiBaseUrl={civiBaseUrl}
/>
);
+5 -4
View File
@@ -12,7 +12,7 @@ import { categoryFromMime, resolveMime } from "@/lib/mime.mjs";
* - office -> download link + "View in Google Docs" secondary link
* - other -> plain download link
*
* The proxy URL is `/api/staff/file?id=&org=&key=` (re-streams with
* The proxy URL is `/api/staff/file?id=&org=&<auth>` (re-streams with
* Content-Disposition: inline for previews, or `&dl=1` for downloads). The
* Civi-signed URL (carrying its short-lived fcs JWT) is passed straight to
* Google's Docs Viewer for office files; we deliberately don't proxy that
@@ -24,7 +24,7 @@ export function FileLink({
civiSignedUrl,
mime: explicitMime,
org,
authKey,
authQuery,
civiBaseUrl,
}: {
fileId: number | string;
@@ -33,7 +33,8 @@ export function FileLink({
civiSignedUrl?: string;
mime?: string;
org: number;
authKey: string;
/** Validated staff auth params (`exp=&sig=` or legacy `key=`), sans org. */
authQuery: string;
civiBaseUrl: string;
}) {
const [open, setOpen] = useState(false);
@@ -44,7 +45,7 @@ export function FileLink({
const proxyBase =
`/api/staff/file?id=${encodeURIComponent(String(fileId))}` +
`&org=${encodeURIComponent(String(org))}` +
`&key=${encodeURIComponent(authKey)}`;
`&${authQuery}`;
const previewSrc = proxyBase;
// Prefer Civi's signed URL for downloads when present (one fewer hop
// through our Lambda); the proxy is the fallback.