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:
+56
-31
@@ -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 },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user