mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { headers } from 'next/headers';
|
||||
import '@/styles/globals.css';
|
||||
import Providers from './providers';
|
||||
|
||||
@@ -12,10 +13,13 @@ export default function RootLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Nonce set per-request by middleware; required for this inline script under the CSP.
|
||||
const nonce = headers().get('x-nonce') ?? undefined;
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
|
||||
@@ -242,10 +242,14 @@ export default function TicketsPage() {
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<script>window.onload=function(){window.print();window.onafterprint=function(){window.close()};}<\/script>' +
|
||||
'</body></html>'
|
||||
);
|
||||
w.document.close();
|
||||
// Drive printing from the opener rather than an inline <script> in the popup — the
|
||||
// about:blank window inherits this page's CSP, which blocks non-nonced inline scripts.
|
||||
w.focus();
|
||||
w.onafterprint = () => w.close();
|
||||
w.print();
|
||||
};
|
||||
|
||||
const handleDeleteClick = (ticket: any) => {
|
||||
|
||||
@@ -2,21 +2,93 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const PUBLIC_PATHS = ['/login', '/reset-password'];
|
||||
|
||||
/**
|
||||
* Build the Content-Security-Policy for a single request.
|
||||
*
|
||||
* Production uses a strict, nonce-based policy with `strict-dynamic`: only scripts
|
||||
* carrying this request's nonce (and scripts they load) may execute, which neutralises
|
||||
* reflected/stored XSS regardless of any host allowlist. Next.js applies the nonce to
|
||||
* its own bootstrap/chunk scripts automatically because middleware forwards it on the
|
||||
* request `Content-Security-Policy` header (see below); our own inline scripts read it
|
||||
* from the `x-nonce` request header in the root layout.
|
||||
*
|
||||
* `strict-dynamic` also covers the jsQR script the boarding scanner injects at runtime
|
||||
* (a trusted script's dynamically-created <script> is allowed), so no CDN host needs
|
||||
* allowlisting.
|
||||
*
|
||||
* Development relaxes `script-src` (Next.js HMR/react-refresh needs `unsafe-eval` and
|
||||
* inline) and allows the HMR websocket, and drops `upgrade-insecure-requests` so
|
||||
* plain-HTTP localhost keeps working.
|
||||
*/
|
||||
function buildCsp(nonce: string): string {
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
// Origin the browser calls for API/XHR/fetch — must be allowed in connect-src.
|
||||
let apiOrigin = '';
|
||||
try {
|
||||
apiOrigin = new URL(process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000').origin;
|
||||
} catch {
|
||||
apiOrigin = '';
|
||||
}
|
||||
|
||||
const scriptSrc = isProd
|
||||
? `'self' 'nonce-${nonce}' 'strict-dynamic'`
|
||||
: `'self' 'unsafe-inline' 'unsafe-eval'`;
|
||||
|
||||
const connectSrc = isProd
|
||||
? `'self' ${apiOrigin}`.trim()
|
||||
: `'self' ${apiOrigin} ws: wss:`.trim();
|
||||
|
||||
const directives = [
|
||||
`default-src 'self'`,
|
||||
`base-uri 'self'`,
|
||||
`script-src ${scriptSrc}`,
|
||||
// Inline styles (Tailwind runtime + React `style=` attributes) can't execute JS;
|
||||
// nonce-ing them reliably breaks Next/React, so 'unsafe-inline' is the accepted stance.
|
||||
`style-src 'self' 'unsafe-inline'`,
|
||||
`img-src 'self' data: blob: ${apiOrigin}`.trim(),
|
||||
`font-src 'self' data:`,
|
||||
`connect-src ${connectSrc}`,
|
||||
`worker-src 'self' blob:`,
|
||||
`frame-src 'self'`,
|
||||
`object-src 'none'`,
|
||||
`form-action 'self'`,
|
||||
`frame-ancestors 'none'`,
|
||||
...(isProd ? ['upgrade-insecure-requests'] : []),
|
||||
];
|
||||
|
||||
return directives.join('; ');
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const nonce = btoa(crypto.randomUUID());
|
||||
const csp = buildCsp(nonce);
|
||||
|
||||
// Forward the nonce + CSP on the *request* so Next.js nonces its own scripts and our
|
||||
// layout can read `x-nonce`. The browser-enforced copy is set on the response below.
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set('x-nonce', nonce);
|
||||
requestHeaders.set('Content-Security-Policy', csp);
|
||||
|
||||
const render = () => NextResponse.next({ request: { headers: requestHeaders } });
|
||||
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
let response: NextResponse;
|
||||
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
|
||||
return NextResponse.next();
|
||||
response = render();
|
||||
} else {
|
||||
// Token is stored in localStorage (client-side only), so middleware can't
|
||||
// read it directly. We use a cookie set on login as the server-side signal.
|
||||
const token = request.cookies.get('auth_token')?.value;
|
||||
response = token ? render() : NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
|
||||
// Token is stored in localStorage (client-side only), so middleware can't
|
||||
// read it directly. We use a cookie set on login as the server-side signal.
|
||||
const token = request.cookies.get('auth_token')?.value;
|
||||
if (!token) {
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
response.headers.set('Content-Security-Policy', csp);
|
||||
// Anti-clickjacking. `frame-ancestors 'none'` (in the CSP above) is the modern control;
|
||||
// X-Frame-Options: DENY is the legacy equivalent for older browsers and scanners.
|
||||
response.headers.set('X-Frame-Options', 'DENY');
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { headers } from 'next/headers';
|
||||
import './globals.css';
|
||||
import { Providers } from './providers';
|
||||
import AppHeader from '@/components/AppHeader';
|
||||
@@ -15,10 +16,13 @@ export default function RootLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Nonce set per-request by middleware; required for this inline script under the CSP.
|
||||
const nonce = headers().get('x-nonce') ?? undefined;
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="font-sans antialiased flex flex-col min-h-screen">
|
||||
<script
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
|
||||
@@ -19,34 +19,99 @@ const PRESERVED_ROUTES = [
|
||||
'/go/',
|
||||
];
|
||||
|
||||
/**
|
||||
* Build the Content-Security-Policy for a single request.
|
||||
*
|
||||
* Production uses a strict, nonce-based policy with `strict-dynamic`: only scripts
|
||||
* carrying this request's nonce (and scripts they load) may execute, which neutralises
|
||||
* reflected/stored XSS regardless of any host allowlist. Next.js applies the nonce to
|
||||
* its own bootstrap/chunk scripts automatically because middleware forwards it on the
|
||||
* request `Content-Security-Policy` header (see below); our own inline scripts read it
|
||||
* from the `x-nonce` request header in the root layout.
|
||||
*
|
||||
* Development relaxes `script-src` (Next.js HMR/react-refresh needs `unsafe-eval` and
|
||||
* inline) and allows the HMR websocket, and drops `upgrade-insecure-requests` so
|
||||
* plain-HTTP localhost keeps working.
|
||||
*/
|
||||
function buildCsp(nonce: string): string {
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
// Origin the browser calls for API/XHR/fetch — must be allowed in connect-src.
|
||||
let apiOrigin = '';
|
||||
try {
|
||||
apiOrigin = new URL(process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000').origin;
|
||||
} catch {
|
||||
apiOrigin = '';
|
||||
}
|
||||
|
||||
const scriptSrc = isProd
|
||||
? `'self' 'nonce-${nonce}' 'strict-dynamic'`
|
||||
: `'self' 'unsafe-inline' 'unsafe-eval'`;
|
||||
|
||||
const connectSrc = isProd
|
||||
? `'self' ${apiOrigin}`.trim()
|
||||
: `'self' ${apiOrigin} ws: wss:`.trim();
|
||||
|
||||
const directives = [
|
||||
`default-src 'self'`,
|
||||
`base-uri 'self'`,
|
||||
`script-src ${scriptSrc}`,
|
||||
// Inline styles (Tailwind runtime + React `style=` attributes) can't execute JS;
|
||||
// nonce-ing them reliably breaks Next/React, so 'unsafe-inline' is the accepted stance.
|
||||
`style-src 'self' 'unsafe-inline'`,
|
||||
`img-src 'self' data: blob: ${apiOrigin}`.trim(),
|
||||
`font-src 'self' data:`,
|
||||
`connect-src ${connectSrc}`,
|
||||
`worker-src 'self' blob:`,
|
||||
`frame-src 'self'`,
|
||||
`object-src 'none'`,
|
||||
`form-action 'self'`,
|
||||
`frame-ancestors 'none'`,
|
||||
...(isProd ? ['upgrade-insecure-requests'] : []),
|
||||
];
|
||||
|
||||
return directives.join('; ');
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const nonce = btoa(crypto.randomUUID());
|
||||
const csp = buildCsp(nonce);
|
||||
|
||||
// Forward the nonce + CSP on the *request* so Next.js nonces its own scripts and our
|
||||
// layout can read `x-nonce`. The browser-enforced copy is set on the response below.
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set('x-nonce', nonce);
|
||||
requestHeaders.set('Content-Security-Policy', csp);
|
||||
|
||||
const render = () => NextResponse.next({ request: { headers: requestHeaders } });
|
||||
|
||||
const { pathname } = request.nextUrl;
|
||||
const isHardRefresh = !request.headers.get('referer');
|
||||
|
||||
// Only intercept hard refreshes (no Referer header = direct navigation / refresh)
|
||||
const referer = request.headers.get('referer');
|
||||
const isHardRefresh = !referer;
|
||||
|
||||
// Skip Next.js internals, static files, and API routes
|
||||
let response: NextResponse;
|
||||
if (
|
||||
pathname.startsWith('/_next') ||
|
||||
pathname.startsWith('/api') ||
|
||||
pathname.includes('.') ||
|
||||
pathname === '/'
|
||||
) {
|
||||
return NextResponse.next();
|
||||
// Next.js internals, static files, API routes, and home: no redirect, just render.
|
||||
response = render();
|
||||
} else if (PRESERVED_ROUTES.some((r) => pathname.startsWith(r))) {
|
||||
// On hard refresh of a preserved route, let it through.
|
||||
response = render();
|
||||
} else if (isHardRefresh && pathname.startsWith('/packages')) {
|
||||
// On hard refresh of package detail or packages list, redirect to home.
|
||||
response = NextResponse.redirect(new URL('/', request.url));
|
||||
} else {
|
||||
response = render();
|
||||
}
|
||||
|
||||
// On hard refresh of a preserved route, let it through
|
||||
if (PRESERVED_ROUTES.some((r) => pathname.startsWith(r))) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// On hard refresh of package detail or packages list, redirect to home
|
||||
if (isHardRefresh && (pathname.startsWith('/packages'))) {
|
||||
return NextResponse.redirect(new URL('/', request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
response.headers.set('Content-Security-Policy', csp);
|
||||
// Anti-clickjacking. `frame-ancestors 'none'` (in the CSP above) is the modern control;
|
||||
// X-Frame-Options: DENY is the legacy equivalent for older browsers and scanners.
|
||||
response.headers.set('X-Frame-Options', 'DENY');
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
|
||||
Reference in New Issue
Block a user