diff --git a/app/.env.development b/app/.env.development index 8c19a7a6e..01156e380 100644 --- a/app/.env.development +++ b/app/.env.development @@ -7,6 +7,6 @@ SENTRY_IGNORE_API_RESOLUTION_ERROR=1 MAPTILER_API_KEY=123 ADFS_PROFILE_URL=https://www.myaccount-r.eiam.admin.ch/ NEXTAUTH_URL=https://localhost:3000 -# NEXT_PUBLIC_SENTRY_DSN=https://… -# NEXT_PUBLIC_SENTRY_CSP="https://*.sentry.io" -# NEXT_PUBLIC_SENTRY_ENV="dev" +# SENTRY_DSN=https://… +# SENTRY_CSP="https://*.sentry.io" +# SENTRY_ENV="dev" diff --git a/app/domain/env.ts b/app/domain/env.ts index 414948e4f..7e6a25382 100644 --- a/app/domain/env.ts +++ b/app/domain/env.ts @@ -48,8 +48,8 @@ export const ADFS_PROFILE_URL = export const MAPTILER_API_KEY = clientEnv?.MAPTILER_API_KEY ?? process.env.MAPTILER_API_KEY ?? ""; -export const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN; -export const SENTRY_ENV = process.env.NEXT_PUBLIC_SENTRY_ENV; +export const SENTRY_DSN = clientEnv?.SENTRY_DSN ?? process.env.SENTRY_DSN; +export const SENTRY_ENV = clientEnv?.SENTRY_ENV ?? process.env.SENTRY_ENV; /** * Server-side-only **RUNTIME** variables (not exposed through window) diff --git a/app/middleware.ts b/app/middleware.ts new file mode 100644 index 000000000..8df28336b --- /dev/null +++ b/app/middleware.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; + +const EMBEDDABLE_PATH_PATTERNS = [ + /^\/embed\//, + /^\/preview$/, + /^\/api\/embed-aem-ext\//, +]; + +function buildCSP(frameAncestors: string): string { + const isDev = process.env.NODE_ENV === "development"; + const isVercel = !!process.env.VERCEL; + const sentryCSP = process.env.SENTRY_CSP ? ` ${process.env.SENTRY_CSP}` : ""; + const unsafeEval = isDev ? " 'unsafe-eval'" : ""; + const vercelDefault = isVercel + ? " https://vercel.live/ https://vercel.com" + : ""; + const vercelScript = isVercel + ? " https://vercel.live/ https://vercel.com" + : ""; + const vercelScriptElem = isVercel + ? " https://vercel.live https://vercel.com https://*.vercel.app" + : ""; + const vercelWorker = isVercel ? " https://*.vercel.app" : ""; + + return [ + `default-src 'self' 'unsafe-inline'${unsafeEval}${sentryCSP}${vercelDefault}`, + `script-src 'self' 'unsafe-inline'${unsafeEval}${sentryCSP}${vercelScript} https://api.mapbox.com https://api.maptiler.com`, + `script-src-elem 'self' 'unsafe-inline' https://*.admin.ch https://visualize.admin.ch https://*.visualize.admin.ch${vercelScriptElem} https://api.mapbox.com`, + `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`, + `font-src 'self'`, + `img-src 'self' * data: blob:`, + `connect-src 'self' *`, + `worker-src 'self' blob: https://*.admin.ch${vercelWorker}`, + `form-action 'self'`, + `frame-ancestors ${frameAncestors}`, + `object-src 'none'`, + `base-uri 'self'`, + `upgrade-insecure-requests`, + ].join("; "); +} + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + const isEmbeddable = EMBEDDABLE_PATH_PATTERNS.some((p) => p.test(pathname)); + const frameAncestors = isEmbeddable ? "*" : "'self'"; + + const reportOnly = process.env.CSP_REPORT_ONLY === "true"; + const cspKey = reportOnly + ? "Content-Security-Policy-Report-Only" + : "Content-Security-Policy"; + + const response = NextResponse.next(); + response.headers.set(cspKey, buildCSP(frameAncestors)); + + if (process.env.PREVENT_SEARCH_BOTS === "true") { + response.headers.set("X-Robots-Tag", "noindex, nofollow"); + } + + return response; +} + +export const config = { + matcher: [ + { + source: "/((?!_next/static|_next/image|favicon\\.ico).*)", + missing: [ + { type: "header", key: "next-router-prefetch" }, + { type: "header", key: "purpose", value: "prefetch" }, + ], + }, + ], +}; diff --git a/app/next.config.js b/app/next.config.js index a64179199..9d8115d04 100644 --- a/app/next.config.js +++ b/app/next.config.js @@ -35,8 +35,8 @@ console.log("GitHub Repo", process.env.NEXT_PUBLIC_GITHUB_REPO); console.log("Extra Certs", process.env.NODE_EXTRA_CA_CERTS); console.log("Prevent search bots", process.env.PREVENT_SEARCH_BOTS); -if (process.env.NEXT_PUBLIC_SENTRY_DSN) { - console.log("Sentry DSN:", process.env.NEXT_PUBLIC_SENTRY_DSN); +if (process.env.SENTRY_DSN) { + console.log("Sentry DSN:", process.env.SENTRY_DSN); } module.exports = withSentryConfig( @@ -54,58 +54,9 @@ module.exports = withSentryConfig( }, headers: async () => { - // See https://content-security-policy.com/ & https://developers.google.com/tag-platform/security/guides/csp - const isDev = process.env.NODE_ENV === "development"; - const isVercel = !!process.env.VERCEL; - const sentryCSP = process.env.NEXT_PUBLIC_SENTRY_CSP - ? ` ${process.env.NEXT_PUBLIC_SENTRY_CSP}` - : ""; - const unsafeEval = isDev ? " 'unsafe-eval'" : ""; - // Vercel Toolbar / Live Comments hosts — only needed on Vercel deployments - const vercelDefault = isVercel - ? " https://vercel.live/ https://vercel.com" - : ""; - const vercelScript = isVercel - ? " https://vercel.live/ https://vercel.com" - : ""; - const vercelScriptElem = isVercel - ? " https://vercel.live https://vercel.com https://*.vercel.app" - : ""; - const vercelWorker = isVercel ? " https://*.vercel.app" : ""; - - const buildCSP = (frameAncestors) => - [ - `default-src 'self' 'unsafe-inline'${unsafeEval}${sentryCSP}${vercelDefault}`, - `script-src 'self' 'unsafe-inline'${unsafeEval}${sentryCSP}${vercelScript} https://api.mapbox.com https://api.maptiler.com`, - `script-src-elem 'self' 'unsafe-inline' https://*.admin.ch https://visualize.admin.ch https://*.visualize.admin.ch${vercelScriptElem} https://api.mapbox.com`, - `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`, - `font-src 'self'`, - - // * to allow loading legend images from custom WMS / WMTS endpoints and data: to allow downloading images - `img-src 'self' * data: blob:`, - - // * to allow WMS / WMTS endpoints - `connect-src 'self' *`, - - `worker-src 'self' blob: https://*.admin.ch${vercelWorker}`, - `form-action 'self'`, - `frame-ancestors ${frameAncestors}`, - `object-src 'none'`, - `base-uri 'self'`, - `upgrade-insecure-requests`, - ].join("; "); - - // When CSP_REPORT_ONLY=true, emit the report-only header so violations - // are surfaced to the browser console without being enforced. Useful - // for rolling out tighter policies. The header is otherwise always - // present — there is intentionally no kill-switch to fully disable CSP. - const reportOnly = - process.env.CSP_REPORT_ONLY && - process.env.CSP_REPORT_ONLY === "true"; - const cspKey = reportOnly - ? "Content-Security-Policy-Report-Only" - : "Content-Security-Policy"; - + // Static security headers that don't depend on runtime env vars. + // Dynamic headers (CSP, X-Robots-Tag) are set in middleware.ts so they + // can read runtime env vars injected into the container at startup. const baseHeaders = [ { key: "X-Content-Type-Options", value: "nosniff" }, { @@ -129,43 +80,8 @@ module.exports = withSentryConfig( // `frame-ancestors` already handles clickjacking protection. { key: "Cross-Origin-Opener-Policy", value: "same-origin" }, ]; - if (process.env.PREVENT_SEARCH_BOTS === "true") { - baseHeaders.push({ - key: "X-Robots-Tag", - value: "noindex, nofollow", - }); - } - - const headers = []; - - // Catch-all — block iframing to prevent clickjacking on the editor / browser / login UI. - // Must come first: when multiple Next.js header rules match the same path, - // later rules override earlier ones for the same header key. - headers.push({ - source: "/:path*", - headers: [ - ...baseHeaders, - { key: cspKey, value: buildCSP("'self'") }, - ], - }); - - // Routes that are intended to be embedded in third-party iframes. - // These override the catch-all CSP to allow `frame-ancestors *`. - // `/api/embed-aem-ext/*` serves the AEM external-embed HTML wrapper, - // which partner sites may iframe directly. - const embeddableSources = [ - "/embed/:path*", - "/preview", - "/api/embed-aem-ext/:path*", - ]; - for (const source of embeddableSources) { - headers.push({ - source, - headers: [...baseHeaders, { key: cspKey, value: buildCSP("*") }], - }); - } - return headers; + return [{ source: "/:path*", headers: baseHeaders }]; }, pageExtensions: ["js", "ts", "tsx", "mdx"], diff --git a/app/pages/api/client-env.ts b/app/pages/api/client-env.ts index 1bc463af9..0d24cf5e6 100644 --- a/app/pages/api/client-env.ts +++ b/app/pages/api/client-env.ts @@ -24,6 +24,8 @@ export default async function clientEnvApi( GRAPHQL_ENDPOINT: process.env.GRAPHQL_ENDPOINT, ADFS_PROFILE_URL: process.env.ADFS_PROFILE_URL, MAPTILER_API_KEY: process.env.MAPTILER_API_KEY, + SENTRY_DSN: process.env.SENTRY_DSN, + SENTRY_ENV: process.env.SENTRY_ENV, })}`; if (result) {