Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ const nextConfig: NextConfig = {
experimental: {
proxyClientMaxBodySize: '200mb',
},
async headers() {
const extraAncestors = process.env.ALLOWED_FRAME_ANCESTORS?.trim();
const frameAncestors = extraAncestors ? `'self' ${extraAncestors}` : "'self'";

return [
{
source: '/(.*)',
headers: [
// X-Frame-Options only supports SAMEORIGIN (no allow-list),
// so we omit it when custom ancestors are configured.
...(!extraAncestors ? [{ key: 'X-Frame-Options', value: 'SAMEORIGIN' }] : []),
{
key: 'Content-Security-Policy',
value: `frame-ancestors ${frameAncestors}`,
},
],
},
];
},
};

export default nextConfig;
82 changes: 82 additions & 0 deletions tests/server/security-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { NextConfig } from 'next';

async function loadConfig(): Promise<NextConfig> {
vi.resetModules();
const mod = await import('@/next.config');
return mod.default;
}

describe('Security response headers', () => {
afterEach(() => {
delete process.env.ALLOWED_FRAME_ANCESTORS;
});

describe('default (no ALLOWED_FRAME_ANCESTORS)', () => {
it('nextConfig.headers() is defined', async () => {
const config = await loadConfig();
expect(config.headers).toBeDefined();
expect(typeof config.headers).toBe('function');
});

it('includes X-Frame-Options SAMEORIGIN on all routes', async () => {
const config = await loadConfig();
const headerGroups = await config.headers!();
const allRouteGroup = headerGroups.find((g) => g.source === '/(.*)')!;

expect(allRouteGroup).toBeDefined();
expect(allRouteGroup.headers).toContainEqual({
key: 'X-Frame-Options',
value: 'SAMEORIGIN',
});
});

it("includes Content-Security-Policy frame-ancestors 'self'", async () => {
const config = await loadConfig();
const headerGroups = await config.headers!();
const allRouteGroup = headerGroups.find((g) => g.source === '/(.*)')!;

expect(allRouteGroup).toBeDefined();
expect(allRouteGroup.headers).toContainEqual({
key: 'Content-Security-Policy',
value: "frame-ancestors 'self'",
});
});
});

describe('with ALLOWED_FRAME_ANCESTORS', () => {
it('appends allowed origins to frame-ancestors', async () => {
process.env.ALLOWED_FRAME_ANCESTORS = 'https://partner.example.com';
const config = await loadConfig();
const headerGroups = await config.headers!();
const allRouteGroup = headerGroups.find((g) => g.source === '/(.*)')!;

expect(allRouteGroup.headers).toContainEqual({
key: 'Content-Security-Policy',
value: "frame-ancestors 'self' https://partner.example.com",
});
});

it('omits X-Frame-Options when custom ancestors are set', async () => {
process.env.ALLOWED_FRAME_ANCESTORS = 'https://partner.example.com';
const config = await loadConfig();
const headerGroups = await config.headers!();
const allRouteGroup = headerGroups.find((g) => g.source === '/(.*)')!;

const xfo = allRouteGroup.headers.find((h) => h.key === 'X-Frame-Options');
expect(xfo).toBeUndefined();
});

it('supports multiple space-separated origins', async () => {
process.env.ALLOWED_FRAME_ANCESTORS = 'https://a.example.com https://b.example.com';
const config = await loadConfig();
const headerGroups = await config.headers!();
const allRouteGroup = headerGroups.find((g) => g.source === '/(.*)')!;

expect(allRouteGroup.headers).toContainEqual({
key: 'Content-Security-Policy',
value: "frame-ancestors 'self' https://a.example.com https://b.example.com",
});
});
});
});
Loading