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
34 changes: 7 additions & 27 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,11 @@ import createNextIntlPlugin from 'next-intl/plugin';

const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');

// Development needs 'unsafe-eval' because React DevTools uses eval for
// enhanced error stack reconstruction. Not required (or used) in production.
const isDev = process.env.NODE_ENV === 'development';

// Full Content-Security-Policy. With SRI enabled (below), script tags get
// build-time integrity hashes, so we can use script-src 'self' without
// 'unsafe-inline' — any injected inline script is blocked by the browser.
const cspHeader = [
"default-src 'self'",
`script-src 'self'${isDev ? " 'unsafe-eval'" : ''}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' blob: data:",
"font-src 'self'",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
'upgrade-insecure-requests',
].join('; ');
// Content-Security-Policy is set per-request in src/proxy.ts with a random nonce
// (the RSC streaming payload uses framework inline scripts, so a static
// `script-src 'self'` without a nonce blocks hydration — SRI only covers
// external scripts). Do not re-add a static CSP here: duplicate CSP headers
// intersect, and the static one would override the nonce policy.

const nextConfig: NextConfig = {
// Enable standalone output for Docker deployment
Expand All @@ -42,9 +27,8 @@ const nextConfig: NextConfig = {

// Subresource Integrity: generate SHA-256 hashes for all JS bundles at
// build time. Browsers verify file integrity via the `integrity` attribute,
// which allows a strict CSP (script-src 'self') without 'unsafe-inline'.
// This preserves static generation / CDN caching — no nonce or dynamic
// rendering required.
// complementing the nonce-based CSP set in src/proxy.ts (integrity checks
// still apply to external scripts; SRI cannot cover inline scripts).
experimental: {
sri: {
algorithm: 'sha256',
Expand Down Expand Up @@ -77,10 +61,6 @@ const nextConfig: NextConfig = {
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
// Full CSP with strict script-src (no unsafe-inline in production).
// SRI provides integrity hashes for framework scripts so 'self'
// suffices; any attacker-injected inline script is blocked.
{ key: 'Content-Security-Policy', value: cspHeader },
],
},
];
Expand Down
47 changes: 46 additions & 1 deletion src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,40 @@ import { routing } from './i18n/routing';

const intlMiddleware = createMiddleware(routing);

/**
* Per-request CSP nonce. The RSC streaming payload (`self.__next_f.push(...)`) is delivered via
* framework-generated inline scripts, so a bare `script-src 'self'` blocks hydration entirely
* (SRI integrity attributes only cover external scripts). A random per-request nonce lets the
* framework inline scripts run while injected scripts (no nonce) stay blocked.
* 'strict-dynamic' extends trust to chunks loaded at runtime by already-trusted scripts.
* All pages render dynamically behind this proxy, so the nonce costs no static optimization.
*/
function generateNonce(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary);
}

function buildCsp(nonce: string): string {
// Development needs 'unsafe-eval' for React DevTools' error stack reconstruction.
const devExtras = process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : '';
return [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${devExtras}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' blob: data:",
"font-src 'self'",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
'upgrade-insecure-requests',
].join('; ');
}

/**
* Steem wallet URLs use /@account/... (legacy). Next.js treats path segments starting with @ as
* parallel route slots, so /\@user/... never reaches [username] and becomes a 404. Normalize to
Expand All @@ -30,6 +64,15 @@ export default function proxy(request: NextRequest) {
}

const normalized = accountPathWithoutAtPrefix(request.nextUrl.pathname);

// Attach the per-request nonce to the request headers BEFORE the intl middleware builds its
// rewrite response, so the renderer sees them. Next.js parses the nonce out of the CSP request
// header during render and applies it to framework scripts (external and inline RSC payload).
const nonce = generateNonce();
const csp = buildCsp(nonce);
request.headers.set('x-nonce', nonce);
request.headers.set('Content-Security-Policy', csp);

let forIntl: NextRequest = request;
if (normalized !== null) {
const url = request.nextUrl.clone();
Expand All @@ -39,7 +82,9 @@ export default function proxy(request: NextRequest) {
headers: request.headers,
});
}
return intlMiddleware(forIntl);
const response = intlMiddleware(forIntl);
response.headers.set('Content-Security-Policy', csp);
return response;
}

export const config = {
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/proxy-csp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from 'vitest';
import { NextRequest, NextResponse } from 'next/server';

// next-intl's middleware module cannot be resolved in the vitest environment;
// the CSP/nonce behavior under test does not depend on intl routing.
vi.mock('next-intl/middleware', () => ({
default: () => (request: NextRequest) =>
NextResponse.next({ request: { headers: request.headers } }),
}));
vi.mock('@/i18n/routing', () => ({
routing: { locales: ['en'], defaultLocale: 'en' },
}));

import proxy from '@/proxy';

function req(path: string): NextRequest {
return new NextRequest(new URL(`http://localhost${path}`));
}

describe('proxy CSP nonce', () => {
it('sets a CSP response header with a nonce and strict-dynamic on page responses', () => {
const res = proxy(req('/market'));
const csp = res.headers.get('content-security-policy');
expect(csp).toBeTruthy();
expect(csp).toContain("script-src 'self' 'nonce-");
expect(csp).toContain("'strict-dynamic'");
const scriptSrc = csp!.split(';').find((d) => d.trim().startsWith('script-src'))!;
expect(scriptSrc).not.toContain("'unsafe-inline'");
expect(csp).toContain("frame-ancestors 'none'");
});

it('generates a fresh nonce per request', () => {
const csp1 = proxy(req('/market')).headers.get('content-security-policy');
const csp2 = proxy(req('/market')).headers.get('content-security-policy');
expect(csp1).toBeTruthy();
expect(csp1).not.toBe(csp2);
});

it('mirrors the same nonce into the request headers seen by the renderer', () => {
const request = req('/market');
proxy(request);
const nonce = request.headers.get('x-nonce');
const cspReq = request.headers.get('content-security-policy');
expect(nonce).toBeTruthy();
expect(cspReq).toContain(`'nonce-${nonce}'`);
});

it('keeps the /@account normalization working with the nonce applied', () => {
const request = req('/@alice/transfers');
const res = proxy(request);
expect(res.headers.get('content-security-policy')).toContain("'nonce-");
});

it('skips CSP on the healthcheck short-circuit', () => {
const res = proxy(req('/.well-known/healthcheck.json'));
expect(res.headers.get('content-security-policy')).toBeNull();
});
});
Loading