diff --git a/lib/__tests__/proxy-security-headers.test.ts b/lib/__tests__/proxy-security-headers.test.ts index 97be8c632..d1837a8a2 100644 --- a/lib/__tests__/proxy-security-headers.test.ts +++ b/lib/__tests__/proxy-security-headers.test.ts @@ -135,6 +135,19 @@ describe('ALLOWED_FRAME_ANCESTORS', () => { else process.env.ALLOWED_FRAME_ANCESTORS = original; }); + it('sends HSTS only on a request that arrived over TLS', async () => { + const plain = await load('/en/mail'); + expect(plain.headers.get('strict-transport-security')).toBeNull(); + const forwarded = await proxy(new NextRequest('http://localhost:3000/en/mail', { + headers: { 'sec-fetch-dest': 'document', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'none', 'x-forwarded-proto': 'https' }, + })); + expect(forwarded.headers.get('strict-transport-security')).toBe('max-age=63072000'); + const direct = await proxy(new NextRequest('https://webmail.example/en/mail', { + headers: { 'sec-fetch-dest': 'document', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'none' }, + })); + expect(direct.headers.get('strict-transport-security')).toBe('max-age=63072000'); + }); + it('lets a portal frame the mail UI', async () => { const res = await load('/en/mail'); expect(res.headers.get('content-security-policy')).toContain('frame-ancestors https://portal.example'); diff --git a/next.config.ts b/next.config.ts index 65f3e6c1b..ca4915334 100644 --- a/next.config.ts +++ b/next.config.ts @@ -49,6 +49,8 @@ if (isLite && (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? "never") !== "always") { const nextConfig: NextConfig = { output: isLite ? "export" : "standalone", + // No `X-Powered-By: Next.js` on every response. + poweredByHeader: false, // Directory-style pages (out/en/mail/index.html) are what static hosts and // the shipped SPA-fallback rules expect; next/image has no optimizer here. ...(isLite ? { trailingSlash: true, images: { unoptimized: true } } : {}), diff --git a/proxy.ts b/proxy.ts index f00047d52..722b2457d 100644 --- a/proxy.ts +++ b/proxy.ts @@ -398,6 +398,13 @@ export async function proxy(request: NextRequest) { response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); response.headers.set("X-XSS-Protection", "0"); + // Only on a request that arrived over TLS (directly or via the reverse + // proxy's X-Forwarded-Proto): a plain-http dev or LAN install must not be + // pinned to https by a header it cannot honour. No includeSubDomains, which + // would speak for the operator's other hosts. + if (request.nextUrl.protocol === "https:" || request.headers.get("x-forwarded-proto") === "https") { + response.headers.set("Strict-Transport-Security", "max-age=63072000"); + } response.headers.set( "Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()"