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
|
* Server-side proxy that fetches a CiviCRM attachment and re-streams it
|
||||||
* with `Content-Disposition: inline`, so the staff-report lightbox can
|
* with `Content-Disposition: inline`, so the staff-report lightbox can
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
* its UI but wrong for an embedded preview.
|
* its UI but wrong for an embedded preview.
|
||||||
*
|
*
|
||||||
* Authorization layers:
|
* 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
|
* 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
|
* 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
|
* one of the org's activities' file-typed columns. This is the same
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
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 { civi, civi3 } from "@/lib/civicrm";
|
||||||
import { resolveMime } from "@/lib/mime.mjs";
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const key = url.searchParams.get("key");
|
|
||||||
const idStr = url.searchParams.get("id");
|
const idStr = url.searchParams.get("id");
|
||||||
const orgStr = url.searchParams.get("org");
|
const orgStr = url.searchParams.get("org");
|
||||||
const wantsDownload = url.searchParams.get("dl") === "1";
|
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 });
|
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
|
* Returns a comprehensive read-only StaffReportPayload for the given
|
||||||
* organization. Field list is built at request time from
|
* organization. Field list is built at request time from
|
||||||
* CustomField.get (no static config).
|
* 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
|
* STUB MODE: if CiviCRM env vars are unset, returns a fabricated payload
|
||||||
* exercising every render kind so the staff page is usable in dev.
|
* exercising every render kind so the staff page is usable in dev.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
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 { civi, civi3 } from "@/lib/civicrm";
|
||||||
import { mapCustomFieldRow } from "@/lib/staff-field-mapping.mjs";
|
import { mapCustomFieldRow } from "@/lib/staff-field-mapping.mjs";
|
||||||
import type {
|
import type {
|
||||||
@@ -226,10 +227,16 @@ function buildStubPayload(orgId: number): StaffReportPayload {
|
|||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const key = url.searchParams.get("key");
|
|
||||||
const orgStr = url.searchParams.get("org");
|
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.
|
// Don't leak whether the route exists.
|
||||||
return new NextResponse("Not found", { status: 404 });
|
return new NextResponse("Not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { StaffReportView } from "@/components/StaffReportView";
|
import { StaffReportView } from "@/components/StaffReportView";
|
||||||
import { SiteHeader, SiteFooter } from "@/components/SiteChrome";
|
import { SiteHeader, SiteFooter } from "@/components/SiteChrome";
|
||||||
import { isStaffKeyValid } from "@/lib/staff-auth";
|
import { staffAuthQuery } from "@/lib/staff-auth";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
searchParams: Promise<{ org?: string; key?: string; frame?: string }>;
|
searchParams: Promise<{ org?: string; key?: string; exp?: string; sig?: string; frame?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
@@ -13,12 +13,13 @@ export const metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default async function StaffReportPage({ searchParams }: PageProps) {
|
export default async function StaffReportPage({ searchParams }: PageProps) {
|
||||||
const { org, key, frame } = await searchParams;
|
const { org, key, exp, sig, frame } = await searchParams;
|
||||||
const isFramed = frame === "1";
|
const isFramed = frame === "1";
|
||||||
|
|
||||||
// Generic "not found" if the key is missing or wrong — don't confirm
|
// Generic "not found" if the token/key is missing or wrong — don't
|
||||||
// route existence.
|
// confirm route existence.
|
||||||
if (!isStaffKeyValid(key)) {
|
const authQuery = staffAuthQuery({ org, exp, sig, key });
|
||||||
|
if (!authQuery) {
|
||||||
return <NotFound framed={isFramed} />;
|
return <NotFound framed={isFramed} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ export default async function StaffReportPage({ searchParams }: PageProps) {
|
|||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<StaffReportView
|
<StaffReportView
|
||||||
org={orgId}
|
org={orgId}
|
||||||
authKey={key!}
|
authQuery={authQuery}
|
||||||
civiBaseUrl={civiBaseUrl}
|
civiBaseUrl={civiBaseUrl}
|
||||||
framed={isFramed}
|
framed={isFramed}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,24 +7,41 @@
|
|||||||
* template emits a single iframe pointing at the WebForm-mw staff report
|
* template emits a single iframe pointing at the WebForm-mw staff report
|
||||||
* plus a small postMessage listener that auto-sizes the iframe to the
|
* plus a small postMessage listener that auto-sizes the iframe to the
|
||||||
* report's content height.
|
* 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 {
|
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() {
|
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();
|
$appUrl = _webform_mw_app_url();
|
||||||
$key = _webform_mw_staff_key();
|
$key = _webform_mw_staff_key();
|
||||||
$configured = ($appUrl !== '' && $key !== '');
|
$configured = ($appUrl !== '' && $key !== '');
|
||||||
|
|
||||||
if ($configured) {
|
if ($configured) {
|
||||||
|
$exp = time() + self::TOKEN_TTL_SECONDS;
|
||||||
$src = $appUrl . '/staff/report'
|
$src = $appUrl . '/staff/report'
|
||||||
. '?org=' . urlencode((string) $cid)
|
. '?org=' . $cid
|
||||||
. '&key=' . urlencode($key)
|
. '&exp=' . $exp
|
||||||
|
. '&sig=' . urlencode(self::sign($key, $cid, $exp))
|
||||||
. '&frame=1';
|
. '&frame=1';
|
||||||
$this->assign('iframeSrc', $src);
|
$this->assign('iframeSrc', $src);
|
||||||
// Expose just the app origin so the parent-side postMessage
|
// 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)
|
$this->assign('appOrigin', parse_url($appUrl, PHP_URL_SCHEME)
|
||||||
. '://' . parse_url($appUrl, PHP_URL_HOST));
|
. '://' . parse_url($appUrl, PHP_URL_HOST));
|
||||||
}
|
}
|
||||||
@@ -32,4 +49,14 @@ class CRM_WebformMw_Page_Tab extends CRM_Core_Page {
|
|||||||
|
|
||||||
parent::run();
|
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), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ without prior context can self-serve.
|
|||||||
- Visible only on **Organization** contacts (Individuals and Households
|
- Visible only on **Organization** contacts (Individuals and Households
|
||||||
see no tab). The check happens server-side in the tabset hook.
|
see no tab). The check happens server-side in the tabset hook.
|
||||||
- Tab body is an iframe pointing at
|
- Tab body is an iframe pointing at
|
||||||
`${WEBFORM_MW_APP_URL}/staff/report?org=<cid>&key=<secret>&frame=1`.
|
`${WEBFORM_MW_APP_URL}/staff/report?org=<cid>&exp=<unix>&sig=<hmac>&frame=1`.
|
||||||
- `frame=1` tells the embedded app to suppress its site header/footer
|
- `frame=1` tells the embedded app to suppress its site header/footer
|
||||||
and emit a `postMessage({type:"webform-mw-height", height})` payload
|
and emit a `postMessage({type:"webform-mw-height", height})` payload
|
||||||
on render and on resize. The tab's small inline script listens for
|
on render and on resize. The tab's small inline script listens for
|
||||||
@@ -63,13 +63,15 @@ without prior context can self-serve.
|
|||||||
|
|
||||||
## Security notes
|
## Security notes
|
||||||
|
|
||||||
- The staff secret travels with each tab render inside the iframe `src`.
|
- The shared secret never leaves the server. Each tab render mints a
|
||||||
Anyone permitted to view the Engagement Report tab (i.e., anyone with
|
signed token, `HMAC-SHA256(secret, "webform-mw.staff-report.v1:<org>:<exp>")`,
|
||||||
CiviCRM access) can read the URL in their browser's DevTools and reuse
|
valid for one org for 8 hours. The app rejects tokens for other orgs,
|
||||||
the secret to view any org's report. Acceptable model if "CiviCRM
|
expired tokens, and anything valid for longer than 12 hours.
|
||||||
access" and "should view any staff report" overlap; otherwise consider
|
- Tokens are only minted after `CRM_Contact_BAO_Contact_Permission::allow()`
|
||||||
upgrading the embedded app to per-user signed tokens and minting them
|
confirms the current user can view the org, so Civi contact ACLs carry
|
||||||
in the page controller.
|
through to the embedded report.
|
||||||
|
- Rotating the secret: change `WEBFORM_MW_STAFF_KEY` and the app's
|
||||||
|
`STAFF_REPORT_KEY` together; outstanding tokens stop working at once.
|
||||||
- The extension uses `access CiviCRM` as its access argument — anyone
|
- The extension uses `access CiviCRM` as its access argument — anyone
|
||||||
with that permission sees the tab on Organization contacts. Tighten by
|
with that permission sees the tab on Organization contacts. Tighten by
|
||||||
changing the `<access_arguments>` value in `xml/Menu/webform_mw.xml`
|
changing the `<access_arguments>` value in `xml/Menu/webform_mw.xml`
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
<urls>
|
<urls>
|
||||||
<url desc="Main Extension Page">https://github.com/joelbrock/WebForm-mw</url>
|
<url desc="Main Extension Page">https://github.com/joelbrock/WebForm-mw</url>
|
||||||
</urls>
|
</urls>
|
||||||
<releaseDate>2026-06-05</releaseDate>
|
<releaseDate>2026-10-30</releaseDate>
|
||||||
<version>0.3.1</version>
|
<version>0.4.0</version>
|
||||||
<develStage>beta</develStage>
|
<develStage>beta</develStage>
|
||||||
<compatibility>
|
<compatibility>
|
||||||
<ver>5.50</ver>
|
<ver>5.50</ver>
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import { FileLink } from "./report/FileLink";
|
|||||||
|
|
||||||
interface StaffReportViewProps {
|
interface StaffReportViewProps {
|
||||||
org: number;
|
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. */
|
/** CIVI_BASE_URL, used to build outbound file links. */
|
||||||
civiBaseUrl: string;
|
civiBaseUrl: string;
|
||||||
/** True when the page is being embedded in a CiviCRM tab via iframe. */
|
/** 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({
|
export function StaffReportView({
|
||||||
org,
|
org,
|
||||||
authKey,
|
authQuery,
|
||||||
civiBaseUrl,
|
civiBaseUrl,
|
||||||
framed = false,
|
framed = false,
|
||||||
}: StaffReportViewProps) {
|
}: StaffReportViewProps) {
|
||||||
@@ -50,7 +51,7 @@ export function StaffReportView({
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
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" },
|
{ cache: "no-store" },
|
||||||
);
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -72,7 +73,7 @@ export function StaffReportView({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [org, authKey]);
|
}, [org, authQuery]);
|
||||||
|
|
||||||
// When embedded, post our content height to the parent so the Civi tab's
|
// 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
|
// iframe can resize to fit (no nested scrollbars). The receiving script
|
||||||
@@ -209,7 +210,7 @@ export function StaffReportView({
|
|||||||
options={data.options}
|
options={data.options}
|
||||||
civiBaseUrl={civiBaseUrl}
|
civiBaseUrl={civiBaseUrl}
|
||||||
org={org}
|
org={org}
|
||||||
authKey={authKey}
|
authQuery={authQuery}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -275,13 +276,13 @@ function StaffSection({
|
|||||||
options,
|
options,
|
||||||
civiBaseUrl,
|
civiBaseUrl,
|
||||||
org,
|
org,
|
||||||
authKey,
|
authQuery,
|
||||||
}: {
|
}: {
|
||||||
section: StaffReportSection;
|
section: StaffReportSection;
|
||||||
options: Record<number, SelectOption[]>;
|
options: Record<number, SelectOption[]>;
|
||||||
civiBaseUrl: string;
|
civiBaseUrl: string;
|
||||||
org: number;
|
org: number;
|
||||||
authKey: string;
|
authQuery: string;
|
||||||
}) {
|
}) {
|
||||||
const filled = section.fields.filter((f) => f.history.length > 0);
|
const filled = section.fields.filter((f) => f.history.length > 0);
|
||||||
const empty = 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}
|
options={options}
|
||||||
civiBaseUrl={civiBaseUrl}
|
civiBaseUrl={civiBaseUrl}
|
||||||
org={org}
|
org={org}
|
||||||
authKey={authKey}
|
authQuery={authQuery}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -384,13 +385,13 @@ function CompactFieldRow({
|
|||||||
options,
|
options,
|
||||||
civiBaseUrl,
|
civiBaseUrl,
|
||||||
org,
|
org,
|
||||||
authKey,
|
authQuery,
|
||||||
}: {
|
}: {
|
||||||
field: StaffReportField;
|
field: StaffReportField;
|
||||||
options: Record<number, SelectOption[]>;
|
options: Record<number, SelectOption[]>;
|
||||||
civiBaseUrl: string;
|
civiBaseUrl: string;
|
||||||
org: number;
|
org: number;
|
||||||
authKey: string;
|
authQuery: string;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const latest = field.history[0];
|
const latest = field.history[0];
|
||||||
@@ -408,7 +409,7 @@ function CompactFieldRow({
|
|||||||
options={options}
|
options={options}
|
||||||
civiBaseUrl={civiBaseUrl}
|
civiBaseUrl={civiBaseUrl}
|
||||||
org={org}
|
org={org}
|
||||||
authKey={authKey}
|
authQuery={authQuery}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
{latest.date ? (
|
{latest.date ? (
|
||||||
@@ -447,7 +448,7 @@ function CompactFieldRow({
|
|||||||
options={options}
|
options={options}
|
||||||
civiBaseUrl={civiBaseUrl}
|
civiBaseUrl={civiBaseUrl}
|
||||||
org={org}
|
org={org}
|
||||||
authKey={authKey}
|
authQuery={authQuery}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -464,14 +465,14 @@ function FieldValue({
|
|||||||
options,
|
options,
|
||||||
civiBaseUrl,
|
civiBaseUrl,
|
||||||
org,
|
org,
|
||||||
authKey,
|
authQuery,
|
||||||
}: {
|
}: {
|
||||||
field: StaffReportField;
|
field: StaffReportField;
|
||||||
entry: FieldHistoryEntry;
|
entry: FieldHistoryEntry;
|
||||||
options: Record<number, SelectOption[]>;
|
options: Record<number, SelectOption[]>;
|
||||||
civiBaseUrl: string;
|
civiBaseUrl: string;
|
||||||
org: number;
|
org: number;
|
||||||
authKey: string;
|
authQuery: string;
|
||||||
}) {
|
}) {
|
||||||
if (field.descriptor.render === "file") {
|
if (field.descriptor.render === "file") {
|
||||||
const v = entry.value as
|
const v = entry.value as
|
||||||
@@ -487,7 +488,7 @@ function FieldValue({
|
|||||||
civiSignedUrl={v.url}
|
civiSignedUrl={v.url}
|
||||||
mime={v.mime}
|
mime={v.mime}
|
||||||
org={org}
|
org={org}
|
||||||
authKey={authKey}
|
authQuery={authQuery}
|
||||||
civiBaseUrl={civiBaseUrl}
|
civiBaseUrl={civiBaseUrl}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { categoryFromMime, resolveMime } from "@/lib/mime.mjs";
|
|||||||
* - office -> download link + "View in Google Docs" secondary link
|
* - office -> download link + "View in Google Docs" secondary link
|
||||||
* - other -> plain download 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
|
* Content-Disposition: inline for previews, or `&dl=1` for downloads). The
|
||||||
* Civi-signed URL (carrying its short-lived fcs JWT) is passed straight to
|
* 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
|
* Google's Docs Viewer for office files; we deliberately don't proxy that
|
||||||
@@ -24,7 +24,7 @@ export function FileLink({
|
|||||||
civiSignedUrl,
|
civiSignedUrl,
|
||||||
mime: explicitMime,
|
mime: explicitMime,
|
||||||
org,
|
org,
|
||||||
authKey,
|
authQuery,
|
||||||
civiBaseUrl,
|
civiBaseUrl,
|
||||||
}: {
|
}: {
|
||||||
fileId: number | string;
|
fileId: number | string;
|
||||||
@@ -33,7 +33,8 @@ export function FileLink({
|
|||||||
civiSignedUrl?: string;
|
civiSignedUrl?: string;
|
||||||
mime?: string;
|
mime?: string;
|
||||||
org: number;
|
org: number;
|
||||||
authKey: string;
|
/** Validated staff auth params (`exp=&sig=` or legacy `key=`), sans org. */
|
||||||
|
authQuery: string;
|
||||||
civiBaseUrl: string;
|
civiBaseUrl: string;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -44,7 +45,7 @@ export function FileLink({
|
|||||||
const proxyBase =
|
const proxyBase =
|
||||||
`/api/staff/file?id=${encodeURIComponent(String(fileId))}` +
|
`/api/staff/file?id=${encodeURIComponent(String(fileId))}` +
|
||||||
`&org=${encodeURIComponent(String(org))}` +
|
`&org=${encodeURIComponent(String(org))}` +
|
||||||
`&key=${encodeURIComponent(authKey)}`;
|
`&${authQuery}`;
|
||||||
const previewSrc = proxyBase;
|
const previewSrc = proxyBase;
|
||||||
// Prefer Civi's signed URL for downloads when present (one fewer hop
|
// Prefer Civi's signed URL for downloads when present (one fewer hop
|
||||||
// through our Lambda); the proxy is the fallback.
|
// through our Lambda); the proxy is the fallback.
|
||||||
|
|||||||
+42
-12
@@ -1,18 +1,48 @@
|
|||||||
/**
|
/**
|
||||||
* Shared-secret auth for the internal staff routes.
|
* Auth for the internal staff routes (/staff/report, /api/staff/report,
|
||||||
|
* /api/staff/file).
|
||||||
*
|
*
|
||||||
* Staff hit URLs of the form /staff/report?org=<id>&key=<secret>. The
|
* Preferred: signed, org-bound, expiring tokens minted by the CiviCRM
|
||||||
* secret is read from the STAFF_REPORT_KEY env var. If unset, the routes
|
* extension — /staff/report?org=<id>&exp=<unix>&sig=<hmac>. See
|
||||||
* refuse every request (closed by default).
|
* lib/staff-token.mjs. The extension only mints a token after checking the
|
||||||
|
* Civi user can view that org, so Civi ACLs carry through.
|
||||||
*
|
*
|
||||||
* Stub mode (CIVI_* unset) does NOT bypass this check — we want to test
|
* Legacy (transitional): ?key=<STAFF_REPORT_KEY>. Still accepted so the app
|
||||||
* the auth surface in dev too. For local dev, set STAFF_REPORT_KEY=dev in
|
* can deploy before the updated extension reaches the Civi server. Remove
|
||||||
* .env.local.
|
* the legacy branch once every Civi install emits signed tokens.
|
||||||
|
*
|
||||||
|
* If STAFF_REPORT_KEY is unset, every request is refused (closed by
|
||||||
|
* default). Stub mode (CIVI_* unset) does NOT bypass this check — for local
|
||||||
|
* dev, set STAFF_REPORT_KEY=dev in .env.local.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function isStaffKeyValid(key: string | null | undefined): boolean {
|
import { safeEqual, verifyStaffToken } from "./staff-token.mjs";
|
||||||
const expected = process.env.STAFF_REPORT_KEY;
|
|
||||||
if (!expected) return false;
|
interface StaffAuthParams {
|
||||||
if (!key) return false;
|
org: string | null | undefined;
|
||||||
return key === expected;
|
exp?: string | null;
|
||||||
|
sig?: string | null;
|
||||||
|
key?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate staff credentials for `org`. Returns the query-string fragment
|
||||||
|
* (without `org`) that downstream fetches and file links must carry, or
|
||||||
|
* null when unauthorized.
|
||||||
|
*/
|
||||||
|
export function staffAuthQuery({ org, exp, sig, key }: StaffAuthParams): string | null {
|
||||||
|
const secret = process.env.STAFF_REPORT_KEY;
|
||||||
|
if (!secret) return null;
|
||||||
|
|
||||||
|
if (exp || sig) {
|
||||||
|
return verifyStaffToken(secret, org, exp, sig)
|
||||||
|
? `exp=${encodeURIComponent(exp!)}&sig=${encodeURIComponent(sig!)}`
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key && safeEqual(key, secret)) {
|
||||||
|
console.warn("[staff-auth] legacy ?key= access used; update the CiviCRM extension to signed tokens");
|
||||||
|
return `key=${encodeURIComponent(key)}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// @ts-check
|
||||||
|
/**
|
||||||
|
* Signed, org-bound, expiring tokens for the internal staff report.
|
||||||
|
*
|
||||||
|
* The CiviCRM extension's Engagement Report tab mints a token per render:
|
||||||
|
*
|
||||||
|
* sig = base64url( HMAC-SHA256( STAFF_REPORT_KEY,
|
||||||
|
* "webform-mw.staff-report.v1:<org>:<exp>" ) )
|
||||||
|
*
|
||||||
|
* and links to /staff/report?org=<org>&exp=<exp>&sig=<sig>. The raw shared
|
||||||
|
* secret never leaves the two servers, and a captured URL only unlocks one
|
||||||
|
* org until it expires — unlike the legacy `?key=<secret>` form, which
|
||||||
|
* unlocked every org forever.
|
||||||
|
*
|
||||||
|
* The PHP side (civi-extension/.../Page/Tab.php) must build the message
|
||||||
|
* string byte-for-byte the same way. The fixed test vector in
|
||||||
|
* staff-token.test.mjs was produced by the PHP implementation.
|
||||||
|
*
|
||||||
|
* Written as JS+JSDoc rather than TS so Node's built-in --test runner can
|
||||||
|
* import this file directly — matches lib/mime.mjs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Longest validity window we accept, measured from now. The extension
|
||||||
|
* issues 8-hour tokens; the extra headroom absorbs clock skew between the
|
||||||
|
* Civi server and Amplify without letting anyone mint long-lived links.
|
||||||
|
*/
|
||||||
|
export const STAFF_TOKEN_MAX_TTL_SECONDS = 12 * 60 * 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} org
|
||||||
|
* @param {string} exp
|
||||||
|
*/
|
||||||
|
function message(org, exp) {
|
||||||
|
return `webform-mw.staff-report.v1:${org}:${exp}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} secret
|
||||||
|
* @param {string} org
|
||||||
|
* @param {string} exp
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function signStaffToken(secret, org, exp) {
|
||||||
|
return createHmac("sha256", secret).update(message(org, exp)).digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant-time string comparison. Lengths may differ; that case returns
|
||||||
|
* false without calling timingSafeEqual (which would throw).
|
||||||
|
*
|
||||||
|
* @param {string} a
|
||||||
|
* @param {string} b
|
||||||
|
*/
|
||||||
|
export function safeEqual(a, b) {
|
||||||
|
const ab = Buffer.from(a);
|
||||||
|
const bb = Buffer.from(b);
|
||||||
|
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string | null | undefined} secret
|
||||||
|
* @param {string | null | undefined} org
|
||||||
|
* @param {string | null | undefined} exp
|
||||||
|
* @param {string | null | undefined} sig
|
||||||
|
* @param {number} [nowSeconds]
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function verifyStaffToken(secret, org, exp, sig, nowSeconds = Math.floor(Date.now() / 1000)) {
|
||||||
|
if (!secret || !org || !exp || !sig) return false;
|
||||||
|
if (!/^[1-9]\d*$/.test(org) || !/^[1-9]\d*$/.test(exp)) return false;
|
||||||
|
const expN = Number(exp);
|
||||||
|
if (expN <= nowSeconds) return false;
|
||||||
|
if (expN - nowSeconds > STAFF_TOKEN_MAX_TTL_SECONDS) return false;
|
||||||
|
return safeEqual(signStaffToken(secret, org, exp), sig);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
"sync-help": "node --env-file=.env.local scripts/sync-help-from-civi.mjs",
|
"sync-help": "node --env-file=.env.local scripts/sync-help-from-civi.mjs",
|
||||||
"test:mapping": "node --test lib/staff-field-mapping.test.mjs",
|
"test:mapping": "node --test lib/staff-field-mapping.test.mjs",
|
||||||
"test:mime": "node --test lib/mime.test.mjs",
|
"test:mime": "node --test lib/mime.test.mjs",
|
||||||
"test": "node --test lib/staff-field-mapping.test.mjs lib/mime.test.mjs"
|
"test:token": "node --test lib/staff-token.test.mjs",
|
||||||
|
"test": "node --test lib/staff-field-mapping.test.mjs lib/mime.test.mjs lib/staff-token.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.4.3",
|
"next": "16.4.3",
|
||||||
|
|||||||
Reference in New Issue
Block a user