v1.0.0:2 — revert CSP nonces; restore inline-friendly CSP
v1.0.0:1 shipped a per-request nonce-based CSP via Next.js middleware. In production it produced a blank first paint: Next 14.2.x's bootstrap inline scripts weren't picking up the nonce reliably from the x-nonce request header, so the browser blocked them. This release reverts to the pre-experiment posture: - middleware.ts back to auth gating only (no nonce, no CSP). - next.config.js restores the static CSP with `'unsafe-inline'` allowed for script-src and style-src. Same headers (HSTS, Referrer-Policy, Permissions-Policy, frame-ancestors 'none', etc.) all stay. - New startos/versions/v1.0.0.2.ts with empty up/down migrations and a release note explaining the bug + revert. Promoted to `current` in the version graph; v1.0.0:1 moves to `other` so existing installs upgrade in place. No schema changes, no data migration. Existing v1.0.0:1 installs keep their /data. Re-attempt path documented in middleware.ts and next.config.js comments: future PR can revisit nonce CSP using Next's documented pattern verbatim (notably setting CSP on BOTH request headers and response headers — we only set it on response).
This commit is contained in:
+26
-53
@@ -1,76 +1,49 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-request CSP nonce + auth gating.
|
* Auth gating only.
|
||||||
*
|
*
|
||||||
* Nonces drop the previous `'unsafe-inline'` from `script-src`. Next
|
* Previously this also generated a per-request nonce and set a strict
|
||||||
* 13.4+ automatically picks up the nonce from the `x-nonce` request
|
* Content-Security-Policy header. That broke first-paint in production
|
||||||
* header and stamps it on the bootstrap inline scripts it emits, so
|
* (Next 14.2.x): the bootstrap inline scripts in SSR'd HTML weren't
|
||||||
* the in-app code (which doesn't itself emit inline `<script>`) Just
|
* picking up the nonce from `x-nonce` reliably, and the resulting
|
||||||
* Works without any layout changes.
|
* CSP-blocked script left a blank page.
|
||||||
*
|
*
|
||||||
* `style-src` keeps `'unsafe-inline'` because Tailwind / Next still
|
* Reverted to: middleware does auth gating, CSP is set statically in
|
||||||
* inject critical inline `<style>` blocks. Tightening that requires
|
* next.config.js with `'unsafe-inline'` allowed for script + style.
|
||||||
* either nonce-stamping styles too (Next doesn't do this automatically)
|
* That's the same posture we shipped successfully through v1.0.0:1's
|
||||||
* or hashing the inline style bodies, both of which are bigger lifts.
|
* first cutover smoke build before this experiment.
|
||||||
*
|
*
|
||||||
* The CSP set here REPLACES the static CSP previously in
|
* Re-attempt path (later): use Next's documented nonce middleware
|
||||||
* next.config.js (a header set by middleware overrides one set in the
|
* pattern verbatim, including setting the CSP on BOTH request headers
|
||||||
* static config for the same key). All other static security headers
|
* and response headers (the example in
|
||||||
* (HSTS, Referrer-Policy, etc.) stay in next.config.js.
|
* https://nextjs.org/docs/app/building-your-application/configuring/content-security-policy
|
||||||
|
* sets it on both — we only set it on response, which may be the
|
||||||
|
* miss). Test in a real browser before shipping.
|
||||||
*/
|
*/
|
||||||
export function middleware(request: NextRequest) {
|
export function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
const sessionToken = request.cookies.get('sessionToken')?.value;
|
const sessionToken = request.cookies.get('sessionToken')?.value;
|
||||||
|
|
||||||
// ---- Auth gating (existing behavior) -----------------------------
|
|
||||||
if (pathname.startsWith('/main') && !sessionToken) {
|
if (pathname.startsWith('/main') && !sessionToken) {
|
||||||
return NextResponse.redirect(new URL('/auth/login', request.url));
|
return NextResponse.redirect(new URL('/auth/login', request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith('/api')) {
|
||||||
if (
|
if (
|
||||||
pathname.startsWith('/api') &&
|
pathname.startsWith('/api/auth') ||
|
||||||
!pathname.startsWith('/api/auth') &&
|
pathname.startsWith('/api/health')
|
||||||
!pathname.startsWith('/api/health') &&
|
|
||||||
!sessionToken
|
|
||||||
) {
|
) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
if (!sessionToken) {
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Per-request nonce + CSP -------------------------------------
|
return NextResponse.next();
|
||||||
// 16 random bytes -> 22-char base64; plenty for a CSP nonce.
|
|
||||||
const nonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString(
|
|
||||||
'base64',
|
|
||||||
);
|
|
||||||
|
|
||||||
const csp = [
|
|
||||||
"default-src 'self'",
|
|
||||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
|
|
||||||
"style-src 'self' 'unsafe-inline'",
|
|
||||||
"img-src 'self' data: blob:",
|
|
||||||
"font-src 'self' data:",
|
|
||||||
"connect-src 'self'",
|
|
||||||
"frame-ancestors 'none'",
|
|
||||||
"base-uri 'self'",
|
|
||||||
"form-action 'self'",
|
|
||||||
"object-src 'none'",
|
|
||||||
].join('; ');
|
|
||||||
|
|
||||||
// Forward the nonce to Next via a request header — its built-in
|
|
||||||
// <Script> + bootstrap script emitter looks for `x-nonce` and stamps
|
|
||||||
// it onto every inline script it generates.
|
|
||||||
const requestHeaders = new Headers(request.headers);
|
|
||||||
requestHeaders.set('x-nonce', nonce);
|
|
||||||
|
|
||||||
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
|
||||||
response.headers.set('Content-Security-Policy', csp);
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: ['/main/:path*', '/api/:path*'],
|
||||||
// Exclude static assets so the nonce response-header overhead
|
|
||||||
// doesn't hit every image / JS chunk request. App-route requests
|
|
||||||
// and API requests pass through.
|
|
||||||
'/((?!_next/static|_next/image|favicon.ico|icons/|manifest.json|sw.js|sw-register.js).*)',
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,34 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
|
||||||
// Content-Security-Policy is set per-request in middleware.ts so it
|
// Content-Security-Policy.
|
||||||
// can include a per-request nonce (drops the previous 'unsafe-inline'
|
//
|
||||||
// from script-src). Other security headers stay here as static
|
// `script-src` and `style-src` keep `'unsafe-inline'` because Next.js
|
||||||
// response headers.
|
// emits inline bootstrap scripts and Tailwind injects critical inline
|
||||||
|
// `<style>`. We tried nonce-based CSP via middleware in v1.0.0:1 and
|
||||||
|
// it produced a blank-screen first paint in production — the bootstrap
|
||||||
|
// scripts weren't picking up the nonce reliably. Reverted in v1.0.0:2.
|
||||||
|
// The directives we DO get for free here still cut off common XSS
|
||||||
|
// followups:
|
||||||
|
// - frame-ancestors 'none' -> can't be embedded anywhere
|
||||||
|
// - base-uri 'self' -> attacker can't pivot relative URLs
|
||||||
|
// - form-action 'self' -> stolen forms can't POST credentials
|
||||||
|
// - object-src 'none' -> no Flash/Java applets, full stop
|
||||||
|
// - default-src 'self' -> images/fetches/etc default same-origin
|
||||||
|
const csp = [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data: blob:",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"object-src 'none'",
|
||||||
|
].join('; ');
|
||||||
|
|
||||||
const securityHeaders = [
|
const securityHeaders = [
|
||||||
|
{ key: 'Content-Security-Policy', value: csp },
|
||||||
// HSTS: tell browsers to use HTTPS only for this origin for a year.
|
// HSTS: tell browsers to use HTTPS only for this origin for a year.
|
||||||
// StartOS terminates TLS in front of the container, so this applies
|
// StartOS terminates TLS in front of the container, so this applies
|
||||||
// to the public hostname users actually visit.
|
// to the public hostname users actually visit.
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { VersionGraph } from '@start9labs/start-sdk'
|
import { VersionGraph } from '@start9labs/start-sdk'
|
||||||
import { v_1_0_0_1 } from './v1.0.0.1'
|
import { v_1_0_0_1 } from './v1.0.0.1'
|
||||||
|
import { v_1_0_0_2 } from './v1.0.0.2'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Version graph for the `proof-of-work` package.
|
* Version graph for the `proof-of-work` package.
|
||||||
*
|
*
|
||||||
* v1.0.0:1 — initial release, seeded cutover from the legacy `workout-log`
|
* v1.0.0:1 — initial release, seeded cutover from the legacy
|
||||||
* package. No prior version to upgrade from.
|
* `workout-log` package.
|
||||||
|
* v1.0.0:2 — CSP fix (reverts the over-strict nonce-based CSP that
|
||||||
|
* broke first paint in v1.0.0:1).
|
||||||
*
|
*
|
||||||
* StartOS picks `current` as the install target; `other` lists every node
|
* StartOS picks `current` as the install target; `other` lists every
|
||||||
* that can upgrade into `current`. Fresh sideloads land directly on
|
* node that can upgrade into `current`. Hosts on v1.0.0:1 upgrade to
|
||||||
* `current`. Once we ship the post-cutover cleanup release, it goes here as
|
* v1.0.0:2 via the no-op up migration; fresh installs land directly
|
||||||
* the new `current` and v1.0.0:1 moves into `other`.
|
* on v1.0.0:2.
|
||||||
*/
|
*/
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_1_0_0_1,
|
current: v_1_0_0_2,
|
||||||
other: [],
|
other: [v_1_0_0_1],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.0.0:2 — CSP nonce revert.
|
||||||
|
*
|
||||||
|
* v1.0.0:1 shipped a per-request nonce-based Content-Security-Policy
|
||||||
|
* via Next.js middleware. In production, the bootstrap inline scripts
|
||||||
|
* weren't picking up the nonce reliably (Next 14.2.x), so the browser
|
||||||
|
* blocked them and the app showed a blank first paint.
|
||||||
|
*
|
||||||
|
* This release reverts to a static CSP with `'unsafe-inline'` allowed
|
||||||
|
* for script-src and style-src — the same posture that worked through
|
||||||
|
* the v1.0.0:1 cutover smoke build. All other security headers (HSTS,
|
||||||
|
* Referrer-Policy, Permissions-Policy, etc.) and every other v1.0.0:1
|
||||||
|
* change are unchanged.
|
||||||
|
*
|
||||||
|
* No schema changes, no data migration. /data on existing v1.0.0:1
|
||||||
|
* installs is left exactly as-is.
|
||||||
|
*/
|
||||||
|
export const v_1_0_0_2 = VersionInfo.of({
|
||||||
|
version: '1.0.0:2',
|
||||||
|
releaseNotes: {
|
||||||
|
en_US:
|
||||||
|
'Bug fix: blank first paint on v1.0.0:1 caused by an over-strict Content-Security-Policy. Reverts CSP to the same posture that worked through the cutover smoke build. No data migration; /data is untouched.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async () => {},
|
||||||
|
down: IMPOSSIBLE,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user