Staff report: CSP frame-ancestors + frame-mode + WebForm-mw Civi extension

App side:
- Per-route CSP: /staff/report now sets frame-ancestors 'self'
  <CIVI_BASE_URL origin> and drops X-Frame-Options so the CiviCRM
  extension can iframe it. All other routes keep frame-ancestors
  'none' + X-Frame-Options: DENY via a path-negation source.
- Staff page recognises ?frame=1 and renders without SiteHeader/
  SiteFooter so it fills the iframe cleanly.
- StaffReportView posts its scrollHeight to the parent window via
  postMessage when framed; the Civi tab listens and auto-resizes
  the iframe (no nested scrollbar). Anchor strip drops its sticky
  positioning in frame mode since there's no internal scroll.

CiviCRM extension (civi-extension/webform-mw/, key webform-mw):
- info.xml + main hook file (webform_mw.php) implementing
  hook_civicrm_tabset to add an 'Engagement Report' tab to
  Organization contact-view pages.
- CRM/WebformMw/Page/Tab.php + Smarty template render an iframe
  pointing at <WEBFORM_MW_APP_URL>/staff/report?org=<cid>&key=&frame=1,
  with a postMessage listener that validates event.origin against
  the configured app URL before resizing.
- Config via PHP constants in civicrm.settings.php (WEBFORM_MW_APP_URL,
  WEBFORM_MW_STAFF_KEY) or matching env vars. Help banner shown when
  unconfigured.
- README documents install, config, behaviour, security caveats.
This commit is contained in:
Joel Brock
2026-06-05 17:42:35 -07:00
parent b548b6425b
commit 4ca3c194d7
9 changed files with 457 additions and 58 deletions
+49 -21
View File
@@ -4,7 +4,7 @@ import { SiteHeader, SiteFooter } from "@/components/SiteChrome";
import { isStaffKeyValid } from "@/lib/staff-auth";
interface PageProps {
searchParams: Promise<{ org?: string; key?: string }>;
searchParams: Promise<{ org?: string; key?: string; frame?: string }>;
}
export const metadata = {
@@ -13,12 +13,13 @@ export const metadata = {
};
export default async function StaffReportPage({ searchParams }: PageProps) {
const { org, key } = await searchParams;
const { org, key, frame } = await searchParams;
const isFramed = frame === "1";
// Generic "not found" if the key is missing or wrong — don't confirm
// route existence.
if (!isStaffKeyValid(key)) {
return <NotFound />;
return <NotFound framed={isFramed} />;
}
const orgId = Number(org);
@@ -27,6 +28,35 @@ export default async function StaffReportPage({ searchParams }: PageProps) {
// outbound file links. No secret material is exposed.
const civiBaseUrl = process.env.CIVI_BASE_URL ?? "";
// When embedded in CiviCRM (?frame=1), drop the site header/footer so the
// report fills the iframe cleanly. Standalone visits keep full chrome.
const body = (
<main id="main" className="flex-1">
<div
className={
isFramed
? "mx-auto max-w-5xl px-3 py-4"
: "mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-14"
}
>
{orgValid ? (
<Suspense fallback={null}>
<StaffReportView
org={orgId}
authKey={key!}
civiBaseUrl={civiBaseUrl}
framed={isFramed}
/>
</Suspense>
) : (
<MissingOrg />
)}
</div>
</main>
);
if (isFramed) return body;
return (
<>
<a
@@ -36,34 +66,32 @@ export default async function StaffReportPage({ searchParams }: PageProps) {
Skip to content
</a>
<SiteHeader />
<main id="main" className="flex-1">
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-14">
{orgValid ? (
<Suspense fallback={null}>
<StaffReportView org={orgId} authKey={key!} civiBaseUrl={civiBaseUrl} />
</Suspense>
) : (
<MissingOrg />
)}
</div>
</main>
{body}
<SiteFooter />
</>
);
}
function NotFound() {
return (
<>
<SiteHeader />
<main className="flex-1">
<div className="mx-auto max-w-2xl px-4 py-20 text-center">
function NotFound({ framed }: { framed: boolean }) {
const inner = (
<div
className={
framed
? "mx-auto max-w-2xl px-4 py-8 text-center"
: "mx-auto max-w-2xl px-4 py-20 text-center"
}
>
<h1 className="font-display text-3xl text-ink">Not found</h1>
<p className="mt-3 text-ink-soft">
The page you requested doesn&apos;t exist.
</p>
</div>
</main>
);
if (framed) return <main className="flex-1">{inner}</main>;
return (
<>
<SiteHeader />
<main className="flex-1">{inner}</main>
<SiteFooter />
</>
);
@@ -0,0 +1,35 @@
<?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.
*/
class CRM_WebformMw_Page_Tab extends CRM_Core_Page {
public function run() {
$cid = CRM_Utils_Request::retrieve('cid', 'Positive', $this, TRUE);
$appUrl = _webform_mw_app_url();
$key = _webform_mw_staff_key();
$configured = ($appUrl !== '' && $key !== '');
if ($configured) {
$src = $appUrl . '/staff/report'
. '?org=' . urlencode((string) $cid)
. '&key=' . urlencode($key)
. '&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.
$this->assign('appOrigin', parse_url($appUrl, PHP_URL_SCHEME)
. '://' . parse_url($appUrl, PHP_URL_HOST));
}
$this->assign('configured', $configured);
parent::run();
}
}
+103
View File
@@ -0,0 +1,103 @@
# WebForm-mw — CiviCRM extension
Adds an **Engagement Report** tab to Organization contact-view pages that
embeds the FCI Co-op Survey staff report
(`https://survey.fci.coop/staff/report`) in an iframe. The tab shows the
report for the current organization, scoped by the contact id in the URL.
The embedded app handles its own auth via a shared staff secret. The
extension is otherwise read-only and adds no Civi tables, custom fields,
or scheduled jobs.
## Install
1. Copy the entire `webform-mw/` directory to your CiviCRM extensions
directory (typically `<civi-root>/sites/default/ext/` or wherever
`[civicrm.extensionsDir]` points in your `civicrm.settings.php`).
- The directory name on disk must be `webform-mw` (matching `<key>` in
`info.xml`).
2. In CiviCRM:
`Administer → System Settings → Extensions → Add new → Refresh`,
then click **Install** next to "WebForm-mw".
3. Configure (see next section). The tab will be hidden until both
settings are present.
## Configuration
The extension reads two values, in this order:
1. Constants in `civicrm.settings.php` (preferred):
```php
define('WEBFORM_MW_APP_URL', 'https://survey.fci.coop');
define('WEBFORM_MW_STAFF_KEY', '...the STAFF_REPORT_KEY shared with the app...');
```
2. Or environment variables (`WEBFORM_MW_APP_URL`, `WEBFORM_MW_STAFF_KEY`)
set wherever PHP-FPM / the web server reads its environment from.
`WEBFORM_MW_STAFF_KEY` must match the `STAFF_REPORT_KEY` configured on the
Next.js app (Amplify environment / SSM Parameter Store). The two are the
same shared secret; rotate them together.
`WEBFORM_MW_APP_URL` is the public base URL of the WebForm-mw deployment
(no trailing slash). Production: `https://survey.fci.coop`.
When either value is missing, the tab body shows a help banner with the
exact configuration snippet to paste, so anyone installing the extension
without prior context can self-serve.
## Behaviour
- Tab title: **Engagement Report**.
- Visible only on **Organization** contacts (Individuals and Households
see no tab). The check happens server-side in the tabset hook.
- Tab body is an iframe pointing at
`${WEBFORM_MW_APP_URL}/staff/report?org=<cid>&key=<secret>&frame=1`.
- `frame=1` tells the embedded app to suppress its site header/footer
and emit a `postMessage({type:"webform-mw-height", height})` payload
on render and on resize. The tab's small inline script listens for
this message and auto-sizes the iframe so there's no nested scrollbar.
- The script validates the postMessage `event.origin` against the
configured `WEBFORM_MW_APP_URL` origin before resizing.
## Security notes
- The staff secret travels with each tab render inside the iframe `src`.
Anyone permitted to view the Engagement Report tab (i.e., anyone with
CiviCRM access) can read the URL in their browser's DevTools and reuse
the secret to view any org's report. Acceptable model if "CiviCRM
access" and "should view any staff report" overlap; otherwise consider
upgrading the embedded app to per-user signed tokens and minting them
in the page controller.
- The extension uses `access CiviCRM` as its access argument — anyone
with that permission sees the tab on Organization contacts. Tighten by
changing the `<access_arguments>` value in `xml/Menu/webform_mw.xml`
to a more specific permission (e.g., `view all contacts`) and
reinstalling.
- The embedded app's CSP (`frame-ancestors`) must include the CiviCRM
origin or the iframe will refuse to render. The Next app reads
`CIVI_BASE_URL` at build time and adds its origin to the staff route's
CSP automatically. Confirm with `curl -I <app>/staff/report` after
deploy — you should see `Content-Security-Policy: ... frame-ancestors
'self' https://<your-civi-host> ...`.
## Files
- `info.xml` — extension manifest. Key `webform-mw`, type `module`.
- `webform_mw.php` — `hook_civicrm_tabset` + config readers.
- `CRM/WebformMw/Page/Tab.php` — page controller for the tab snippet.
- `templates/CRM/WebformMw/Page/Tab.tpl` — iframe + height-listener.
- `xml/Menu/webform_mw.xml` — registers the
`civicrm/contact/view/engagement-report` URL.
## Versions tested
- CiviCRM 5.50+
- PHP 7.4+ / 8.x
- Tested against Backdrop-as-host and Drupal-as-host CiviCRM deployments.
## Uninstalling
`Administer → Extensions → Disable`, then `Uninstall`. Removes nothing
from the database; the extension stores no Civi data of its own.
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0"?>
<extension key="webform-mw" type="module">
<file>webform_mw</file>
<name>WebForm-mw</name>
<description>
Adds an "Engagement Report" tab to Organization contact pages that embeds
the FCI Co-op Survey staff report (the WebForm-mw Next.js app) for the
organization. Read-only; the embedded app handles its own auth via a
shared staff secret.
</description>
<license>AGPL-3.0</license>
<maintainer>
<author>Food Co-op Initiative</author>
<email>survey@fci.coop</email>
</maintainer>
<urls>
<url desc="Main Extension Page">https://github.com/joelbrock/WebForm-mw</url>
</urls>
<releaseDate>2026-06-05</releaseDate>
<version>0.1.0</version>
<develStage>beta</develStage>
<compatibility>
<ver>5.50</ver>
</compatibility>
<comments>
Configuration is via PHP constants in civicrm.settings.php (or
environment variables on the CiviCRM server). See README.md.
</comments>
<classloader>
<psr0 prefix="CRM_" path=""/>
</classloader>
<civix>
<namespace>CRM/WebformMw</namespace>
<format>22.05.0</format>
</civix>
</extension>
@@ -0,0 +1,40 @@
{* Engagement Report tab content. *}
{if $configured}
<div id="wfmw-engagement-report-wrap" style="margin:-1em -1em 0 -1em;">
<iframe
id="wfmw-engagement-report"
src="{$iframeSrc|escape:'htmlall'}"
title="Engagement Report"
style="width:100%;height:1200px;border:0;display:block;background:transparent;"
referrerpolicy="same-origin"
loading="eager"
></iframe>
</div>
<script>
(function () {
var APP_ORIGIN = {$appOrigin|json_encode};
var frame = document.getElementById('wfmw-engagement-report');
if (!frame) return;
window.addEventListener('message', function (e) {
if (APP_ORIGIN && e.origin !== APP_ORIGIN) return;
var d = e && e.data;
if (!d || d.type !== 'webform-mw-height' || typeof d.height !== 'number') return;
// Add a little headroom so the report's own bottom padding isn't clipped.
var h = Math.max(600, Math.floor(d.height) + 24);
frame.style.height = h + 'px';
}, false);
})();
</script>
{else}
<div class="messages status no-popup">
<p><strong>{ts}Engagement Report is not configured.{/ts}</strong></p>
<p>
{ts}Add the following to your <code>civicrm.settings.php</code> (or set both as environment variables on the CiviCRM server) and reload:{/ts}
</p>
<pre>define('WEBFORM_MW_APP_URL', 'https://survey.fci.coop');
define('WEBFORM_MW_STAFF_KEY', '...the STAFF_REPORT_KEY shared with the app...');</pre>
<p>
{ts}See the extension's README.md for details.{/ts}
</p>
</div>
{/if}
+81
View File
@@ -0,0 +1,81 @@
<?php
/**
* WebForm-mw CiviCRM extension.
*
* Adds an "Engagement Report" tab to Organization contact-view pages that
* embeds the WebForm-mw Next.js staff report via an iframe.
*
* Configuration (in civicrm.settings.php or as env vars on the Civi server):
*
* define('WEBFORM_MW_APP_URL', 'https://survey.fci.coop');
* define('WEBFORM_MW_STAFF_KEY', '<the STAFF_REPORT_KEY shared with the app>');
*
* Both values must be set or the tab renders a help banner explaining what
* to configure. See README.md.
*/
/**
* Resolve the app URL from constant or env. Empty string when unset.
*/
function _webform_mw_app_url(): string {
if (defined('WEBFORM_MW_APP_URL')) {
return rtrim((string) constant('WEBFORM_MW_APP_URL'), '/');
}
$env = getenv('WEBFORM_MW_APP_URL');
return is_string($env) && $env !== '' ? rtrim($env, '/') : '';
}
/**
* Resolve the staff secret from constant or env. Empty string when unset.
*/
function _webform_mw_staff_key(): string {
if (defined('WEBFORM_MW_STAFF_KEY')) {
return (string) constant('WEBFORM_MW_STAFF_KEY');
}
$env = getenv('WEBFORM_MW_STAFF_KEY');
return is_string($env) ? $env : '';
}
/**
* Implements hook_civicrm_tabset().
*
* Adds the Engagement Report tab to the Organization contact summary tabset.
* Other contact types (Individual, Household) get no tab.
*/
function webform_mw_civicrm_tabset($tabsetName, &$tabs, $context) {
if ($tabsetName !== 'civicrm/contact/view') {
return;
}
$cid = $context['contact_id'] ?? NULL;
if (!$cid) {
return;
}
// Restrict to Organization contacts.
$contactType = NULL;
try {
$contactType = civicrm_api3('Contact', 'getvalue', [
'id' => (int) $cid,
'return' => 'contact_type',
]);
}
catch (\Throwable $e) {
// Quietly skip — failing here should not break the contact page.
return;
}
if ($contactType !== 'Organization') {
return;
}
$tabs[] = [
'id' => 'engagement_report',
'title' => ts('Engagement Report'),
'weight' => 200,
'count' => NULL,
'icon' => 'crm-i fa-line-chart',
'url' => CRM_Utils_System::url(
'civicrm/contact/view/engagement-report',
"reset=1&cid={$cid}&snippet=1"
),
];
}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="iso-8859-1" ?>
<menu>
<item>
<path>civicrm/contact/view/engagement-report</path>
<title>Engagement Report</title>
<page_callback>CRM_WebformMw_Page_Tab</page_callback>
<access_arguments>access CiviCRM</access_arguments>
</item>
</menu>
+46 -4
View File
@@ -25,6 +25,8 @@ interface StaffReportViewProps {
authKey: 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. */
framed?: boolean;
}
type LoadState =
@@ -34,7 +36,12 @@ type LoadState =
const STAGE_OPTION_GROUP_ID = 75;
export function StaffReportView({ org, authKey, civiBaseUrl }: StaffReportViewProps) {
export function StaffReportView({
org,
authKey,
civiBaseUrl,
framed = false,
}: StaffReportViewProps) {
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
useEffect(() => {
@@ -66,6 +73,28 @@ export function StaffReportView({ org, authKey, civiBaseUrl }: StaffReportViewPr
};
}, [org, authKey]);
// 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
// lives in the WebForm-mw Civi extension's tab template.
useEffect(() => {
if (!framed || typeof window === "undefined") return;
if (window.parent === window) return;
const post = () => {
window.parent.postMessage(
{ type: "webform-mw-height", height: document.documentElement.scrollHeight },
"*",
);
};
post();
const ro = new ResizeObserver(post);
ro.observe(document.documentElement);
window.addEventListener("load", post);
return () => {
ro.disconnect();
window.removeEventListener("load", post);
};
}, [framed, load]);
if (load.kind === "loading") return <LoadingState />;
if (load.kind === "error") return <ErrorState message={load.message} />;
const { data } = load;
@@ -106,7 +135,11 @@ export function StaffReportView({ org, authKey, civiBaseUrl }: StaffReportViewPr
<div className="h-px bg-rule" />
</header>
<SectionAnchorNav sections={data.sections} hasActivities={data.activities.length > 0} />
<SectionAnchorNav
sections={data.sections}
hasActivities={data.activities.length > 0}
framed={framed}
/>
{membersField && membersField.history.length > 0 ? (
<MembershipChart
@@ -140,23 +173,32 @@ function Stat({ label, value }: { label: string; value: React.ReactNode }) {
);
}
/** Sticky horizontal anchor strip — one chip per section + Submissions. */
/**
* Horizontal anchor strip — one chip per section + Submissions.
*
* Sticky in standalone mode; non-sticky when embedded in a CiviCRM tab
* (the iframe auto-resizes to fit content so there's no internal scroll
* for `sticky` to engage against).
*/
function SectionAnchorNav({
sections,
hasActivities,
framed,
}: {
sections: StaffReportSection[];
hasActivities: boolean;
framed: boolean;
}) {
const items = sections.map((s) => ({
href: `#section-${s.groupName}`,
label: s.groupKind === "org" ? "Org profile" : s.groupTitle,
}));
if (hasActivities) items.push({ href: "#section-submissions", label: "Submissions" });
const stickyCls = framed ? "" : "sticky top-0 z-30 backdrop-blur";
return (
<nav
aria-label="Section navigation"
className="sticky top-0 z-30 -mx-4 border-y border-rule bg-paper/95 px-4 py-2 backdrop-blur sm:-mx-6 sm:px-6"
className={`${stickyCls} -mx-4 border-y border-rule bg-paper/95 px-4 py-2 sm:-mx-6 sm:px-6`}
>
<ul className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px]">
{items.map((it) => (
+47 -22
View File
@@ -2,41 +2,37 @@ import path from "node:path";
import type { NextConfig } from "next";
/**
* Security headers applied to every response.
* Security headers.
*
* Notes on each:
* - CSP: tight default; allows Google Fonts (next/font) and the same-origin
* /api routes. No third-party scripts. `frame-ancestors 'none'` prevents
* this app being embedded in another site's iframe.
* - HSTS: only meaningful behind HTTPS (Render terminates TLS, so this is
* correct in production).
* - Permissions-Policy: drop everything we don't use.
* - Referrer-Policy: same-origin — never leak the cid+cs query string to
* other origins via the Referer header.
* - X-Content-Type-Options: prevents MIME sniffing.
* Two profiles:
* - strict (default): frame-ancestors 'none' + X-Frame-Options: DENY.
* Applied to every route except /staff/report.
* - staff-embed: frame-ancestors 'self' <civi-origin>, no X-Frame-Options.
* Lets the CiviCRM "Engagement Report" extension embed the staff page
* in an iframe on contact pages.
*
* The catch-all source uses a negative lookahead so it does NOT match
* /staff/report — otherwise both rules apply and the browser ANDs the
* frame-ancestors directives together, blocking embedding entirely.
*/
// Next.js React dev runtime uses dynamic-script execution for fast-refresh,
// error overlays, and source-map reconstruction. Permit that ONLY in dev so
// HMR works; production CSP stays strict (no dynamic execution allowed).
const isDev = process.env.NODE_ENV !== "production";
const devOnlyDynamicScript = isDev ? " 'unsafe-eval'" : "";
const securityHeaders = [
{
key: "Content-Security-Policy",
value: [
const buildCsp = (frameAncestors: string) =>
[
"default-src 'self'",
`script-src 'self' 'unsafe-inline'${devOnlyDynamicScript}`,
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com data:",
"img-src 'self' data:",
"connect-src 'self'",
"frame-ancestors 'none'",
`frame-ancestors ${frameAncestors}`,
"form-action 'self'",
"base-uri 'self'",
"object-src 'none'",
].join("; "),
},
].join("; ");
const sharedHeaders = [
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "same-origin" },
@@ -44,9 +40,34 @@ const securityHeaders = [
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
},
];
function civiOriginForCsp(): string {
const raw = process.env.CIVI_BASE_URL;
if (!raw) return "";
try {
return new URL(raw).origin;
} catch {
return "";
}
}
const strictHeaders = [
{ key: "Content-Security-Policy", value: buildCsp("'none'") },
...sharedHeaders,
{ key: "X-Frame-Options", value: "DENY" },
];
const staffEmbedHeaders = (() => {
const origin = civiOriginForCsp();
const frameAncestors = origin ? `'self' ${origin}` : "'self'";
return [
{ key: "Content-Security-Policy", value: buildCsp(frameAncestors) },
...sharedHeaders,
// Intentionally NO X-Frame-Options: frame-ancestors above is the policy.
];
})();
const nextConfig: NextConfig = {
poweredByHeader: false,
reactStrictMode: true,
@@ -61,7 +82,11 @@ const nextConfig: NextConfig = {
root: path.resolve(__dirname),
},
async headers() {
return [{ source: "/:path*", headers: securityHeaders }];
return [
{ source: "/staff/report", headers: staffEmbedHeaders },
// Catch-all that explicitly excludes /staff/report — see header notes.
{ source: "/((?!staff/report).*)", headers: strictHeaders },
];
},
};