CSP, Athena
Content Security Policy configuration for the Athena admin dashboard
Overview
Athena applies a nonce-based Content-Security-Policy (CSP) via Next.js edge middleware. The CSP
is generated per request by buildCsp() in src/lib/csp.ts. In production, unsafe-eval is
absent from script-src. In development (NODE_ENV=development), unsafe-eval is added to
allow Next.js Hot Module Replacement (HMR). A CI gate on the compiled edge bundle artifact
enforces that unsafe-eval never appears in the production CSP.
This behavior was implemented in athena#108. Prior to that fix, buildCsp() was embedded in
src/middleware.ts and applied a single CSP for both environments, which caused a CSP
EvalError in the browser that prevented React from hydrating in development.
How It Works
CSP Module: src/lib/csp.ts
The buildCsp function is the single source of truth for Athena's CSP. It accepts a nonce
string and the current NODE_ENV, and returns the full CSP header value:
// src/lib/csp.ts
export function buildCsp(nonce: string, env?: string): string {
const isDev = (env ?? process.env.NODE_ENV) === 'development';
const directives = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}'${isDev ? " 'unsafe-eval'" : ''}`,
"style-src 'self' 'unsafe-inline'",
"connect-src 'self'",
"img-src 'self' data:",
"font-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
];
return directives.join('; ');
}buildCsp lives in src/lib/csp.ts, not in src/middleware.ts, so it can be imported and
tested independently. The env parameter accepts an explicit value for test isolation; in
production the function reads process.env.NODE_ENV directly.
Middleware: src/middleware.ts
The middleware generates a fresh cryptographic nonce on every request and calls buildCsp to
produce the CSP header:
// src/middleware.ts
import { buildCsp } from '@/lib/csp';
const nonceBytes = new Uint8Array(16);
crypto.getRandomValues(nonceBytes);
const nonce = btoa(String.fromCharCode(...nonceBytes));
const csp = buildCsp(nonce);
response.headers.set('Content-Security-Policy', csp);
request.headers.set('x-nonce', nonce);Nonce Propagation
The nonce is forwarded from the middleware to the layout via the x-nonce request header:
// layout.tsx
import { headers } from 'next/headers';
const nonce = (await headers()).get('x-nonce') ?? '';
// Apply to all <Script> components and inline <script> blocks
return <Script src="/bundle.js" nonce={nonce} />;Any new <Script> component or inline <script> block added to Athena must receive the nonce
attribute. Scripts without a nonce are blocked by the CSP.
Production vs. Development CSP
| Directive | Production | Development |
|---|---|---|
default-src | 'self' | 'self' |
script-src | 'self' 'nonce-{NONCE}' | 'self' 'nonce-{NONCE}' 'unsafe-eval' |
style-src | 'self' 'unsafe-inline' | 'self' 'unsafe-inline' |
connect-src | 'self' | 'self' |
img-src | 'self' data: | 'self' data: |
font-src | 'self' | 'self' |
object-src | 'none' | 'none' |
base-uri | 'self' | 'self' |
form-action | 'self' | 'self' |
frame-ancestors | 'none' | 'none' |
'unsafe-eval' in development allows Next.js to use eval() for HMR (React Refresh). In
production, Next.js does not use eval(), so 'unsafe-eval' is not required and must not appear.
Known Residual Risk: style-src 'unsafe-inline'
style-src 'unsafe-inline' permits inline <style> blocks and style= attributes. This is a
CSS injection vector: CSS injection can be used for UI redressing (styled overlays that mimic
the admin UI) in a CIAM context.
The risk is lower than script injection because CSS injection cannot exfiltrate data via network
requests directly. Removing unsafe-inline from style-src requires that no Athena or Canvas
components rely on inline styles. This is tracked as a follow-on security hardening task.
CI Gate
A CI step runs on every push and pull request to verify that unsafe-eval is absent from the
compiled production edge bundle:
# .github/workflows/ci.yml
- name: CSP production gate, no unsafe-eval in prod edge bundle
run: |
bun run build
if grep -q "unsafe-eval" .next/server/edge-chunks/*.js 2>/dev/null || \
grep -q "unsafe-eval" .next/server/middleware.js 2>/dev/null; then
echo "ERROR: 'unsafe-eval' found in production edge bundle, production CSP gate failed"
exit 1
fi
echo "CSP gate passed: 'unsafe-eval' not found in production edge bundle"The grep target is the compiled edge bundle artifact (.next/server/), not the source file.
Grepping the source file is insufficient, buildCsp conditionally includes unsafe-eval and
a source-file grep cannot evaluate the runtime condition. Grepping the build output captures
what the production middleware will actually emit.
If this CI step is ever modified, preserve the .next/server/ grep target. Reverting to a
source-file grep silently removes enforcement without any indication that the gate is no longer
valid.
Making a CSP Change
Follow this procedure for any change to Athena's CSP:
- Edit
src/lib/csp.ts, do not edit CSP logic insrc/middleware.tsdirectly - Run the unit tests:
bun run test src/__tests__/csp.test.ts - The tests verify the exact CSP diff: confirm the changed directive produces the expected
header value for both
developmentandproductionenvironments - Verify the CI artifact gate passes locally:
bun run build, then inspect.next/server/middleware.jsto confirmunsafe-evalis not present in the production path - Open a pull request, the CI gate runs automatically on push
Optional: use Content-Security-Policy-Report-Only in a staging or dev environment as a
pre-flight before enforcing a new directive. Set the report-only header alongside the
enforcing header, observe violations in the browser console or a report endpoint, then remove
the report-only header once violations are resolved.
API / Technical Details
buildCsp export contract
// src/lib/csp.ts
export function buildCsp(nonce: string, env?: string): stringnonce, base64-encoded 16-byte random value, generated per-request in middlewareenv, optional; defaults toprocess.env.NODE_ENVif not provided- Returns the full CSP header value as a semicolon-delimited string
The env parameter exists exclusively for unit test isolation. Production code calls
buildCsp(nonce) without the second argument.
Unit test contract (src/__tests__/csp.test.ts)
The unit tests are the specification for the CSP contract:
NODE_ENV=production,unsafe-evalmust not appear anywhere in the CSP stringNODE_ENV=development,unsafe-evalmust appear inscript-srconlyNODE_ENV=undefined, treated as non-development;unsafe-evalmust not appear- Nonce value must be interpolated correctly in
script-src 'nonce-{value}'
Examples
Verifying the production CSP in Chrome DevTools
- Open Chrome DevTools (F12)
- Go to the Network tab
- Navigate to the Athena dashboard (
https://<athena-domain>/dashboardorhttp://localhost:3001/dashboard) - Click the HTML document request
- Inspect the Response Headers panel, look for
content-security-policy
Expected script-src in production:
script-src 'self' 'nonce-<base64-value>''unsafe-eval' must not appear. If it does, the CI artifact gate did not run or the build
was not a clean production build.
Checking that all script tags have a nonce
In Chrome DevTools Console on any Athena page:
document.querySelectorAll('script[nonce]').length
// Should equal document.querySelectorAll('script').length
// Any script without a nonce attribute is blocked by the CSPEdge Cases
React never hydrates in dev, EvalError in console
If the browser console shows:
EvalError: Evaluating a string as JavaScript violates the following Content Security Policy directiveand the app shows an infinite loading spinner, unsafe-eval is absent from the dev CSP.
Verify that NODE_ENV is set to "development" in the dev container environment. In the dev
compose file, NODE_ENV=development must be present in the Athena container environment block.
If it is absent or set to "production", buildCsp omits unsafe-eval and HMR breaks.
Adding a new inline <script> block
Any inline <script> block must carry the nonce from x-nonce. Without it, the CSP blocks
execution silently:
// layout.tsx, read nonce from headers, pass to inline script
const nonce = (await headers()).get('x-nonce') ?? '';
return (
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: `window.__CONFIG__ = {}` }} />
);CI gate fails after a build change
If the CI gate fails after a Next.js upgrade or build configuration change, verify the edge bundle output path. Next.js may write the edge middleware to a different path across major versions. Update the grep target path in the CI step to match the actual output location.
Security Considerations
unsafe-eval is development-only and CI-enforced
unsafe-eval in script-src allows eval(), new Function(), and similar constructs. These
are XSS vectors in the presence of user-controlled input. The production CSP must never include
unsafe-eval. The CI artifact gate exists specifically to prevent accidental inclusion.
Do not add unsafe-eval to the production path as a workaround for a missing nonce. If a
script is blocked by the CSP in production, the correct fix is to add the nonce attribute, not
to weaken the CSP.
style-src 'unsafe-inline', known limitation
The current CSP includes style-src 'unsafe-inline'. This is a residual risk, CSS injection
via inline styles is a real attack surface in a CIAM admin UI. Removing unsafe-inline from
style-src requires confirming that no Canvas components or Athena layouts use inline styles.
This is tracked as a security follow-on item.
frame-ancestors 'none', admin UI must not be embeddable
frame-ancestors 'none' prevents Athena from being embedded in any iframe. This is intentional
security hardening, the admin UI must not be embeddable, as iframe embedding enables
clickjacking attacks against admin operations.
Compliance
script-src 'nonce-{NONCE}'withoutunsafe-inlineaddresses OWASP A03:2021 Injection (XSS)- CI enforcement of the production CSP gate provides an additional defense layer not present in most comparable admin panel implementations
frame-ancestors 'none'addresses OWASP A05:2021 Security Misconfiguration (clickjacking)