From a03f76ec85714a03160d1809d2dc1bf4f19584fc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:21:32 +0100 Subject: [PATCH] feat: send X-Powered-By: bulwarkmail.org on every response --- lib/__tests__/next-config-powered-by.test.ts | 35 ++++++++++++++++++++ next.config.ts | 9 ++++- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 lib/__tests__/next-config-powered-by.test.ts diff --git a/lib/__tests__/next-config-powered-by.test.ts b/lib/__tests__/next-config-powered-by.test.ts new file mode 100644 index 000000000..83e4d16c3 --- /dev/null +++ b/lib/__tests__/next-config-powered-by.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { NextConfig } from 'next'; +import { getPathMatch } from 'next/dist/shared/lib/router/utils/path-match'; + +async function loadConfig(): Promise { + vi.resetModules(); + const { default: config } = await import('../../next.config'); + return config; +} + +afterEach(() => { + vi.resetModules(); +}); + +describe('X-Powered-By header', () => { + it('names Bulwark instead of Next.js, without a version', async () => { + const config = await loadConfig(); + const rules = (await config.headers?.()) ?? []; + const poweredBy = rules.flatMap((rule) => + rule.headers + .filter((header) => header.key.toLowerCase() === 'x-powered-by') + .map((header) => ({ source: rule.source, value: header.value })) + ); + + expect(config.poweredByHeader).toBe(false); + expect(poweredBy).toEqual([{ source: '/:path*', value: 'bulwarkmail.org' }]); + }); + + it('matches the root, pages, API routes and static files', () => { + const match = getPathMatch('/:path*'); + for (const path of ['/', '/en/mail', '/api/config', '/_next/static/chunks/main.js']) { + expect(match(path), path).not.toBe(false); + } + }); +}); diff --git a/next.config.ts b/next.config.ts index 4e72c6948..1709bbdc6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -49,7 +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. + // No `X-Powered-By: Next.js`; headers() below sends + // `X-Powered-By: bulwarkmail.org` instead, without a version. 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. @@ -77,6 +78,12 @@ const nextConfig: NextConfig = { : { async headers() { return [ + { + // Lets people see a site runs Bulwark. No version, so it tells + // a scanner nothing about which fixes are missing. + source: "/:path*", + headers: [{ key: "X-Powered-By", value: "bulwarkmail.org" }], + }, { // Untrusted plugin iframes have opaque origins and load these public, // immutable chunks in CORS mode. Next prefixes this source with basePath.