diff --git a/docker/proxy.ts b/docker/proxy.ts index 15111884d..11c0bd2a6 100644 --- a/docker/proxy.ts +++ b/docker/proxy.ts @@ -1,4 +1,5 @@ import { type NextRequest, NextResponse } from 'next/server'; +import { getContentSecurityPolicy } from '@/lib/csp'; import { matchesConfiguredPath } from '@/lib/match-configured-path'; export const config = { @@ -23,6 +24,9 @@ const trackerHeaders = { 'Cache-Control': 'public, max-age=86400, must-revalidate', }; +// Resolved once at startup — env vars don't change after the process starts. +const contentSecurityPolicy = getContentSecurityPolicy(); + function customCollectEndpoint(request: NextRequest) { const collectEndpoint = process.env.COLLECT_API_ENDPOINT; @@ -69,12 +73,20 @@ function disableLogin(request: NextRequest) { export default function middleware(req: NextRequest) { const fns = [customCollectEndpoint, customScriptName, customScriptUrl, disableLogin]; + let res: NextResponse | undefined; + for (const fn of fns) { - const res = fn(req); + res = fn(req); if (res) { - return res; + break; } } - return NextResponse.next(); + res ??= NextResponse.next(); + + // Set the CSP here, not only at build time in next.config.ts, so + // ALLOWED_FRAME_URLS is resolved from the runtime environment. + res.headers.set('Content-Security-Policy', contentSecurityPolicy); + + return res; } diff --git a/next.config.ts b/next.config.ts index 922d128bd..fe5b9d44b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,7 @@ import 'dotenv/config'; import createNextIntlPlugin from 'next-intl/plugin'; import pkg from './package.json' with { type: 'json' }; +import { getContentSecurityPolicy } from './src/lib/csp'; const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); @@ -17,20 +18,11 @@ const corsMaxAge = process.env.CORS_MAX_AGE || ''; const defaultCurrency = process.env.DEFAULT_CURRENCY || ''; const defaultLocale = process.env.DEFAULT_LOCALE || ''; const forceSSL = process.env.FORCE_SSL || ''; -const frameAncestors = process.env.ALLOWED_FRAME_URLS || ''; const trackerScriptName = process.env.TRACKER_SCRIPT_NAME || ''; const trackerScriptURL = process.env.TRACKER_SCRIPT_URL || ''; const selfTrack = process.env.UMAMI_SELF_TRACK || ''; const selfRecord = process.env.UMAMI_SELF_RECORD || ''; -function getUrlOrigin(url: string) { - try { - return new URL(url).origin; - } catch { - return ''; - } -} - function isRelativeUrl(url: string) { return Boolean(url && !/^https?:\/\//i.test(url)); } @@ -39,19 +31,6 @@ function normalizePath(url: string) { return `/${url.replace(/^\/+|\/+$/g, '')}`; } -const apiUrlOrigin = getUrlOrigin(apiUrl); -const connectSrc = ["'self'", 'https:', apiUrlOrigin].filter(Boolean).join(' '); - -const contentSecurityPolicy = ` - default-src 'self'; - img-src 'self' https: data: blob:; - script-src 'self' 'unsafe-eval' 'unsafe-inline'; - style-src 'self' 'unsafe-inline'; - connect-src ${connectSrc}; - frame-src 'self' http: https:; - frame-ancestors 'self' ${frameAncestors}; -`; - const defaultHeaders = [ { key: 'X-DNS-Prefetch-Control', @@ -59,7 +38,7 @@ const defaultHeaders = [ }, { key: 'Content-Security-Policy', - value: contentSecurityPolicy.replace(/\s{2,}/g, ' ').trim(), + value: getContentSecurityPolicy(), }, ]; diff --git a/src/lib/csp.ts b/src/lib/csp.ts new file mode 100644 index 000000000..cfeb82a09 --- /dev/null +++ b/src/lib/csp.ts @@ -0,0 +1,28 @@ +function getUrlOrigin(url: string) { + try { + return new URL(url).origin; + } catch { + return ''; + } +} + +// Builds the Content-Security-Policy. Reads the environment when called, so it +// can run at build time (next.config.ts) or per request in the Docker proxy, +// where ALLOWED_FRAME_URLS and API_URL then apply without rebuilding the image. +export function getContentSecurityPolicy() { + const apiUrlOrigin = getUrlOrigin(process.env.API_URL || ''); + const connectSrc = ["'self'", 'https:', apiUrlOrigin].filter(Boolean).join(' '); + const frameAncestors = ["'self'", process.env.ALLOWED_FRAME_URLS || ''].filter(Boolean).join(' '); + + const directives = [ + `default-src 'self'`, + `img-src 'self' https: data: blob:`, + `script-src 'self' 'unsafe-eval' 'unsafe-inline'`, + `style-src 'self' 'unsafe-inline'`, + `connect-src ${connectSrc}`, + `frame-src 'self' http: https:`, + `frame-ancestors ${frameAncestors}`, + ]; + + return `${directives.join('; ')};`; +}