Skip to content

Commit 1720709

Browse files
authored
Merge pull request #264 from NHSDigital/feature/CCM-5340_csp-header
CCM-5340: Add CSP header with nonce for next generated inline scripts
2 parents f08421a + 89289a7 commit 1720709

File tree

5 files changed

+7592
-7
lines changed

5 files changed

+7592
-7
lines changed
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
import { NextRequest } from 'next/server';
5+
import { getAccessTokenServer } from '@utils/amplify-utils';
6+
import { middleware } from '../middleware';
7+
8+
jest.mock('@utils/amplify-utils');
9+
10+
const getTokenMock = jest.mocked(getAccessTokenServer);
11+
12+
function getCsp(response: Response) {
13+
const csp = response.headers.get('Content-Security-Policy');
14+
return csp?.split(';').map((s) => s.trim());
15+
}
16+
17+
const OLD_ENV = { ...process.env };
18+
afterAll(() => {
19+
process.env = OLD_ENV;
20+
});
21+
22+
describe('middleware function', () => {
23+
it('if request path is protected, and no access token is obtained, redirect to auth page', async () => {
24+
const url = new URL('https://url.com/manage-templates');
25+
const request = new NextRequest(url);
26+
const response = await middleware(request);
27+
28+
expect(response.status).toBe(307);
29+
expect(response.headers.get('location')).toBe(
30+
'https://url.com/auth?redirect=%2Ftemplates%2F%2Fmanage-templates'
31+
);
32+
expect(response.headers.get('Content-Type')).toBe('text/html');
33+
});
34+
35+
it('if request path is protected, and access token is obtained, respond with CSP', async () => {
36+
getTokenMock.mockResolvedValueOnce('token');
37+
38+
const url = new URL('https://url.com/manage-templates');
39+
const request = new NextRequest(url);
40+
const response = await middleware(request);
41+
const csp = getCsp(response);
42+
43+
expect(csp).toEqual([
44+
"base-uri 'self'",
45+
"default-src 'none'",
46+
"frame-ancestors 'none'",
47+
"font-src 'self' https://assets.nhs.uk",
48+
"form-action 'self'",
49+
"frame-src 'self'",
50+
"connect-src 'self' https://cognito-idp.eu-west-2.amazonaws.com",
51+
"img-src 'self'",
52+
"manifest-src 'self'",
53+
"object-src 'none'",
54+
expect.stringMatching(/^script-src 'self' 'nonce-[\dA-Za-z]+'$/),
55+
expect.stringMatching(/^style-src 'self' 'nonce-[\dA-Za-z]+'$/),
56+
'upgrade-insecure-requests',
57+
'',
58+
]);
59+
});
60+
61+
it('if request path is not protected, respond with CSP', async () => {
62+
const url = new URL('https://url.com/create-and-submit-templates');
63+
const request = new NextRequest(url);
64+
const response = await middleware(request);
65+
const csp = getCsp(response);
66+
67+
expect(csp).toEqual([
68+
"base-uri 'self'",
69+
"default-src 'none'",
70+
"frame-ancestors 'none'",
71+
"font-src 'self' https://assets.nhs.uk",
72+
"form-action 'self'",
73+
"frame-src 'self'",
74+
"connect-src 'self' https://cognito-idp.eu-west-2.amazonaws.com",
75+
"img-src 'self'",
76+
"manifest-src 'self'",
77+
"object-src 'none'",
78+
expect.stringMatching(/^script-src 'self' 'nonce-[\dA-Za-z]+'$/),
79+
expect.stringMatching(/^style-src 'self' 'nonce-[\dA-Za-z]+'$/),
80+
'upgrade-insecure-requests',
81+
'',
82+
]);
83+
});
84+
85+
it('when running in development mode, CSP script-src allows unsafe-eval', async () => {
86+
// @ts-expect-error assignment to const
87+
process.env.NODE_ENV = 'development';
88+
89+
const url = new URL('https://url.com/create-and-submit-templates');
90+
const request = new NextRequest(url);
91+
const response = await middleware(request);
92+
const csp = getCsp(response);
93+
94+
expect(csp).toEqual([
95+
"base-uri 'self'",
96+
"default-src 'none'",
97+
"frame-ancestors 'none'",
98+
"font-src 'self' https://assets.nhs.uk",
99+
"form-action 'self'",
100+
"frame-src 'self'",
101+
"connect-src 'self' https://cognito-idp.eu-west-2.amazonaws.com",
102+
"img-src 'self'",
103+
"manifest-src 'self'",
104+
"object-src 'none'",
105+
expect.stringMatching(
106+
/^script-src 'self' 'nonce-[\dA-Za-z]+' 'unsafe-eval'$/
107+
),
108+
expect.stringMatching(/^style-src 'self' 'nonce-[\dA-Za-z]+'$/),
109+
'upgrade-insecure-requests',
110+
'',
111+
]);
112+
});
113+
});

frontend/src/app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export const metadata: Metadata = {
99
description: content.global.mainLayout.description,
1010
};
1111

12+
export const dynamic = 'force-dynamic';
13+
1214
export default function RootLayout({
1315
children,
1416
}: {

frontend/src/middleware.ts

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,84 @@ import { NextResponse, type NextRequest } from 'next/server';
22
import { getAccessTokenServer } from '@utils/amplify-utils';
33
import { getBasePath } from '@utils/get-base-path';
44

5-
function isExcludedPath(path: string, excludedPaths: string[]): boolean {
6-
return excludedPaths.some((excludedPath) => path.startsWith(excludedPath));
5+
function getContentSecurityPolicy(nonce: string) {
6+
const contentSecurityPolicyDirective = {
7+
'base-uri': [`'self'`],
8+
'default-src': [`'none'`],
9+
'frame-ancestors': [`'none'`],
10+
'font-src': [`'self'`, 'https://assets.nhs.uk'],
11+
'form-action': [`'self'`],
12+
'frame-src': [`'self'`],
13+
'connect-src': [`'self'`, 'https://cognito-idp.eu-west-2.amazonaws.com'],
14+
'img-src': [`'self'`],
15+
'manifest-src': [`'self'`],
16+
'object-src': [`'none'`],
17+
'script-src': [`'self'`, `'nonce-${nonce}'`],
18+
'style-src': [`'self'`, `'nonce-${nonce}'`],
19+
'upgrade-insecure-requests;': [],
20+
};
21+
22+
if (process.env.NODE_ENV === 'development') {
23+
contentSecurityPolicyDirective['script-src'].push(`'unsafe-eval'`);
24+
}
25+
26+
return Object.entries(contentSecurityPolicyDirective)
27+
.map(([key, value]) => `${key} ${value.join(' ')}`)
28+
.join('; ');
29+
}
30+
31+
function isPublicPath(path: string, publicPaths: string[]): boolean {
32+
return publicPaths.some((publicPath) => path.startsWith(publicPath));
733
}
834

935
export async function middleware(request: NextRequest) {
10-
const excludedPaths = ['/create-and-submit-templates', '/auth'];
36+
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
37+
38+
const csp = getContentSecurityPolicy(nonce);
39+
40+
const requestHeaders = new Headers(request.headers);
41+
requestHeaders.set('Content-Security-Policy', csp);
42+
43+
const publicPaths = ['/create-and-submit-templates', '/auth'];
1144

12-
if (isExcludedPath(request.nextUrl.pathname, excludedPaths)) {
13-
return NextResponse.next();
45+
if (isPublicPath(request.nextUrl.pathname, publicPaths)) {
46+
const publicPathResponse = NextResponse.next({
47+
request: {
48+
headers: requestHeaders,
49+
},
50+
});
51+
52+
publicPathResponse.headers.set('Content-Security-Policy', csp);
53+
54+
return publicPathResponse;
1455
}
1556

1657
const token = await getAccessTokenServer();
1758

1859
if (!token) {
19-
return Response.redirect(
60+
const redirectResponse = NextResponse.redirect(
2061
new URL(
2162
`/auth?redirect=${encodeURIComponent(
2263
`${getBasePath()}/${request.nextUrl.pathname}`
2364
)}`,
2465
request.url
2566
)
2667
);
68+
69+
redirectResponse.headers.set('Content-Type', 'text/html');
70+
71+
return redirectResponse;
2772
}
73+
74+
const response = NextResponse.next({
75+
request: {
76+
headers: requestHeaders,
77+
},
78+
});
79+
80+
response.headers.set('Content-Security-Policy', csp);
81+
82+
return response;
2883
}
2984

3085
export const config = {
@@ -34,7 +89,8 @@ export const config = {
3489
* - _next/static (static files)
3590
* - _next/image (image optimization files)
3691
* - favicon.ico (favicon file)
92+
* - lib/ (our static content)
3793
*/
38-
'/((?!_next/static|_next/image|favicon.ico).*)',
94+
'/((?!_next/static|_next/image|favicon.ico|lib/).*)',
3995
],
4096
};

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"create-amplify-outputs": "tsx ./scripts/create-amplify-outputs.ts",
1515
"create-backend-sandbox": "./scripts/create_backend_sandbox.sh",
1616
"destroy-backend-sandbox": "./scripts/destroy_backend_sandbox.sh",
17+
"generate-dependencies": "npm run generate-dependencies --workspaces --if-present",
1718
"start": "npm run start --workspace frontend",
1819
"test:accessibility": "pa11y-ci -c ./tests/accessibility/.pa11y-ci.js",
1920
"test:unit": "npm run test:unit --workspaces",

0 commit comments

Comments
 (0)