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:
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* GET /api/staff/file?id=<fileId>&org=<orgId>&key=<STAFF_REPORT_KEY>
|
||||
* GET /api/staff/file?id=<fileId>&org=<orgId>&exp=<unix>&sig=<hmac>
|
||||
*
|
||||
* Server-side proxy that fetches a CiviCRM attachment and re-streams it
|
||||
* with `Content-Disposition: inline`, so the staff-report lightbox can
|
||||
@@ -8,7 +8,8 @@
|
||||
* its UI but wrong for an embedded preview.
|
||||
*
|
||||
* Authorization layers:
|
||||
* 1. STAFF_REPORT_KEY query param (same gate as /api/staff/report).
|
||||
* 1. Signed org-bound token (same gate as /api/staff/report; see
|
||||
* lib/staff-auth.ts). The token's org must match `org`.
|
||||
* 2. Per-file ownership probe: the requested fileId must appear as a
|
||||
* value in one of the org's file-typed custom-field columns, OR in
|
||||
* one of the org's activities' file-typed columns. This is the same
|
||||
@@ -25,7 +26,7 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isStaffKeyValid } from "@/lib/staff-auth";
|
||||
import { staffAuthQuery } from "@/lib/staff-auth";
|
||||
import { civi, civi3 } from "@/lib/civicrm";
|
||||
import { resolveMime } from "@/lib/mime.mjs";
|
||||
|
||||
@@ -204,12 +205,18 @@ async function fileBelongsToOrg(fileId: number, orgId: number): Promise<boolean>
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const url = new URL(req.url);
|
||||
const key = url.searchParams.get("key");
|
||||
const idStr = url.searchParams.get("id");
|
||||
const orgStr = url.searchParams.get("org");
|
||||
const wantsDownload = url.searchParams.get("dl") === "1";
|
||||
|
||||
if (!isStaffKeyValid(key)) {
|
||||
if (
|
||||
!staffAuthQuery({
|
||||
org: orgStr,
|
||||
exp: url.searchParams.get("exp"),
|
||||
sig: url.searchParams.get("sig"),
|
||||
key: url.searchParams.get("key"),
|
||||
})
|
||||
) {
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/**
|
||||
* GET /api/staff/report?org=<id>&key=<secret>
|
||||
* GET /api/staff/report?org=<id>&exp=<unix>&sig=<hmac>
|
||||
*
|
||||
* Returns a comprehensive read-only StaffReportPayload for the given
|
||||
* organization. Field list is built at request time from
|
||||
* CustomField.get (no static config).
|
||||
*
|
||||
* Auth: STAFF_REPORT_KEY must match the `key` query param.
|
||||
* Auth: a signed org-bound token (or the transitional legacy `key`); see
|
||||
* lib/staff-auth.ts.
|
||||
*
|
||||
* STUB MODE: if CiviCRM env vars are unset, returns a fabricated payload
|
||||
* exercising every render kind so the staff page is usable in dev.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isStaffKeyValid } from "@/lib/staff-auth";
|
||||
import { staffAuthQuery } from "@/lib/staff-auth";
|
||||
import { civi, civi3 } from "@/lib/civicrm";
|
||||
import { mapCustomFieldRow } from "@/lib/staff-field-mapping.mjs";
|
||||
import type {
|
||||
@@ -226,10 +227,16 @@ function buildStubPayload(orgId: number): StaffReportPayload {
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const url = new URL(req.url);
|
||||
const key = url.searchParams.get("key");
|
||||
const orgStr = url.searchParams.get("org");
|
||||
|
||||
if (!isStaffKeyValid(key)) {
|
||||
if (
|
||||
!staffAuthQuery({
|
||||
org: orgStr,
|
||||
exp: url.searchParams.get("exp"),
|
||||
sig: url.searchParams.get("sig"),
|
||||
key: url.searchParams.get("key"),
|
||||
})
|
||||
) {
|
||||
// Don't leak whether the route exists.
|
||||
return new NextResponse("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user