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
+56 -31
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: [
"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'",
"form-action 'self'",
"base-uri 'self'",
"object-src 'none'",
].join("; "),
},
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 ${frameAncestors}`,
"form-action 'self'",
"base-uri 'self'",
"object-src 'none'",
].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 },
];
},
};