Revert "perf: make root HTML cacheable by dropping per-request CSP nonce"

This reverts commit 522bf6a019.
This commit is contained in:
Linus Rath
2026-04-18 13:10:42 +02:00
parent b8f39198e1
commit 440a4e919a
6 changed files with 42 additions and 17 deletions
+1 -1
View File
@@ -180,7 +180,7 @@ Built with Next.js and the JMAP protocol.
- **TOTP two-factor authentication** - **TOTP two-factor authentication**
- **Account security panel** - manage passwords and 2FA via Stalwart admin API - **Account security panel** - manage passwords and 2FA via Stalwart admin API
- **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in) - **"Remember me"** - AES-256-GCM encrypted httpOnly cookie (opt-in)
- **Security headers** - enforced CSP (script-src 'self', no unsafe-inline), X-Frame-Options, Referrer-Policy; SSRF redirect validation; PDF iframe sandbox; IP spoofing prevention - **Security headers** - enforced CSP with per-request nonce, X-Frame-Options, Referrer-Policy; SSRF redirect validation; PDF iframe sandbox; IP spoofing prevention
- **Plugin hardening** - dangerous-pattern detection, admin approval required, secure HTTP proxy API (no auth-header exposure) - **Plugin hardening** - dangerous-pattern detection, admin approval required, secure HTTP proxy API (no auth-header exposure)
- **Newsletter unsubscribe** (RFC 2369) - **Newsletter unsubscribe** (RFC 2369)
+8 -1
View File
@@ -18,8 +18,15 @@ export default async function LocaleLayout({
if (!(locales as readonly string[]).includes(locale)) notFound(); if (!(locales as readonly string[]).includes(locale)) notFound();
let messages;
try {
messages = (await import(`@/locales/${locale}/common.json`)).default;
} catch {
notFound();
}
return ( return (
<IntlProvider locale={locale}> <IntlProvider locale={locale} messages={messages}>
<ThemeProvider> <ThemeProvider>
<CalendarAlertProvider> <CalendarAlertProvider>
<RateLimitToastProvider> <RateLimitToastProvider>
+22 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import { getLocale } from "next-intl/server"; import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration"; import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
@@ -39,6 +40,7 @@ export default async function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const locale = await getLocale(); const locale = await getLocale();
const nonce = (await headers()).get("x-nonce") ?? "";
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || ""; const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
return ( return (
@@ -55,7 +57,26 @@ export default async function RootLayout({
{parentOrigin && ( {parentOrigin && (
<meta name="parent-origin" content={parentOrigin} /> <meta name="parent-origin" content={parentOrigin} />
)} )}
<script src="/theme-init.js" /> <script
nonce={nonce}
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
const stored = localStorage.getItem('theme-storage');
const theme = stored ? JSON.parse(stored).state.theme : 'system';
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
const resolved = theme === 'system' ? systemTheme : theme;
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolved);
} catch (e) {
document.documentElement.classList.add('light');
}
})();
`,
}}
/>
</head> </head>
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
+1
View File
@@ -38,6 +38,7 @@ const ALL_MESSAGES = {
interface IntlProviderProps { interface IntlProviderProps {
locale: string; locale: string;
messages: Record<string, unknown>;
children: React.ReactNode; children: React.ReactNode;
} }
+10 -2
View File
@@ -5,11 +5,12 @@ import { routing } from "./i18n/routing";
const intlMiddleware = createIntlMiddleware(routing); const intlMiddleware = createIntlMiddleware(routing);
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const nonce = crypto.randomUUID();
const isDev = process.env.NODE_ENV === "development"; const isDev = process.env.NODE_ENV === "development";
const scriptSrc = isDev const scriptSrc = isDev
? `'self' 'unsafe-eval' blob:` ? `'self' 'nonce-${nonce}' 'unsafe-eval' blob:`
: `'self' blob:`; : `'self' 'nonce-${nonce}' blob:`;
const connectSrc = isDev ? `'self' https: ws: wss:` : `'self' https:`; const connectSrc = isDev ? `'self' https: ws: wss:` : `'self' https:`;
@@ -51,6 +52,13 @@ export function proxy(request: NextRequest) {
} }
const response = intlResponse ?? NextResponse.next(); const response = intlResponse ?? NextResponse.next();
const existing = response.headers.get("x-middleware-override-headers");
response.headers.set(
"x-middleware-override-headers",
existing ? `${existing},x-nonce` : "x-nonce"
);
response.headers.set("x-middleware-request-x-nonce", nonce);
response.headers.set("X-Content-Type-Options", "nosniff"); response.headers.set("X-Content-Type-Options", "nosniff");
// X-Frame-Options only supports DENY/SAMEORIGIN. When frame-ancestors // X-Frame-Options only supports DENY/SAMEORIGIN. When frame-ancestors
-12
View File
@@ -1,12 +0,0 @@
(function () {
try {
var stored = localStorage.getItem('theme-storage');
var theme = stored ? JSON.parse(stored).state.theme : 'system';
var systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
var resolved = theme === 'system' ? systemTheme : theme;
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolved);
} catch (e) {
document.documentElement.classList.add('light');
}
})();