Skip to content

Commit 145252e

Browse files
authored
fix(nextjs): forward CSP nonce as request header in clerkMiddleware (#7828)
1 parent 19f34bf commit 145252e

File tree

4 files changed

+80
-21
lines changed

4 files changed

+80
-21
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/nextjs': patch
3+
---
4+
5+
Fixed an issue where the CSP nonce generated by `clerkMiddleware({ contentSecurityPolicy: { strict: true } })` was not forwarded as a request header. Server components can now access the nonce via `headers()`, allowing `ClerkProvider` and Next.js to apply it to `<script>` tags.

integration/templates/next-app-router/src/middleware.ts

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,24 @@
11
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
22

3-
const csp = `default-src 'self';
4-
script-src 'self' 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' 'nonce-deadbeef';
5-
img-src 'self' https://img.clerk.com;
6-
worker-src 'self' blob:;
7-
style-src 'self' 'unsafe-inline';
8-
frame-src 'self' https://challenges.cloudflare.com;
9-
`;
10-
113
const isProtectedRoute = createRouteMatcher(['/protected(.*)', '/user(.*)', '/switcher(.*)']);
124
const isAdminRoute = createRouteMatcher(['/only-admin(.*)']);
13-
const isCSPRoute = createRouteMatcher(['/csp']);
14-
15-
export default clerkMiddleware(async (auth, req) => {
16-
if (isProtectedRoute(req)) {
17-
await auth.protect();
18-
}
195

20-
if (isAdminRoute(req)) {
21-
await auth.protect({ role: 'org:admin' });
22-
}
6+
export default clerkMiddleware(
7+
async (auth, req) => {
8+
if (isProtectedRoute(req)) {
9+
await auth.protect();
10+
}
2311

24-
if (isCSPRoute(req)) {
25-
req.headers.set('Content-Security-Policy', csp.replace(/\n/g, ''));
26-
}
27-
});
12+
if (isAdminRoute(req)) {
13+
await auth.protect({ role: 'org:admin' });
14+
}
15+
},
16+
{
17+
contentSecurityPolicy: {
18+
strict: true,
19+
},
20+
},
21+
);
2822

2923
export const config = {
3024
matcher: [

packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,3 +962,57 @@ describe('Dev Browser JWT when redirecting to cross origin for page requests', f
962962
expect((await clerkClient()).authenticateRequest).toBeCalled();
963963
});
964964
});
965+
966+
describe('contentSecurityPolicy option', () => {
967+
it('forwards CSP headers as request headers when strict mode is enabled', async () => {
968+
const resp = await clerkMiddleware({
969+
contentSecurityPolicy: { strict: true },
970+
})(mockRequest({ url: '/test' }), {} as NextFetchEvent);
971+
972+
expect(resp?.status).toEqual(200);
973+
974+
// Verify CSP response header is set
975+
const cspHeader = resp?.headers.get('content-security-policy');
976+
expect(cspHeader).toBeTruthy();
977+
expect(cspHeader).toContain("'strict-dynamic'");
978+
expect(cspHeader).toContain("'nonce-");
979+
980+
// Verify nonce response header is set
981+
const nonceHeader = resp?.headers.get('x-nonce');
982+
expect(nonceHeader).toBeTruthy();
983+
984+
// Verify CSP headers are forwarded as request headers via x-middleware-override-headers
985+
const overrideHeaders = resp?.headers.get('x-middleware-override-headers');
986+
expect(overrideHeaders).toContain('content-security-policy');
987+
expect(overrideHeaders).toContain('x-nonce');
988+
989+
// Verify the actual request header values are set
990+
const requestCSP = resp?.headers.get('x-middleware-request-content-security-policy');
991+
expect(requestCSP).toEqual(cspHeader);
992+
993+
const requestNonce = resp?.headers.get('x-middleware-request-x-nonce');
994+
expect(requestNonce).toEqual(nonceHeader);
995+
});
996+
997+
it('forwards CSP headers as request headers when not in strict mode', async () => {
998+
const resp = await clerkMiddleware({
999+
contentSecurityPolicy: {},
1000+
})(mockRequest({ url: '/test' }), {} as NextFetchEvent);
1001+
1002+
expect(resp?.status).toEqual(200);
1003+
1004+
// Verify CSP response header is set
1005+
const cspHeader = resp?.headers.get('content-security-policy');
1006+
expect(cspHeader).toBeTruthy();
1007+
1008+
// No nonce in non-strict mode
1009+
expect(resp?.headers.get('x-nonce')).toBeNull();
1010+
1011+
// Verify CSP header is forwarded as request header
1012+
const overrideHeaders = resp?.headers.get('x-middleware-override-headers');
1013+
expect(overrideHeaders).toContain('content-security-policy');
1014+
1015+
const requestCSP = resp?.headers.get('x-middleware-request-content-security-policy');
1016+
expect(requestCSP).toEqual(cspHeader);
1017+
});
1018+
});

packages/nextjs/src/server/clerkMiddleware.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,10 +229,16 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl
229229
options.contentSecurityPolicy,
230230
);
231231

232+
const cspRequestHeaders: Record<string, string> = {};
232233
headers.forEach(([key, value]) => {
233234
setHeader(handlerResult, key, value);
235+
cspRequestHeaders[key] = value;
234236
});
235237

238+
// Forward CSP headers as request headers so server components
239+
// can access the nonce via headers()
240+
setRequestHeadersOnNextResponse(handlerResult, clerkRequest, cspRequestHeaders);
241+
236242
logger.debug('Clerk generated CSP', () => ({
237243
headers,
238244
}));

0 commit comments

Comments
 (0)