feat(auth-service): nonce-based CSP and 5 security cucumber scenarios#100
feat(auth-service): nonce-based CSP and 5 security cucumber scenarios#100
Conversation
🦋 Changeset detectedLatest commit: 2755af3 The changes in this PR will be included in the next version bump. Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 48 minutes and 9 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (11)
📝 WalkthroughWalkthroughThis PR enhances security in the auth service by introducing per-response Content-Security-Policy nonces for inline scripts, extracting security header logic into a dedicated middleware module, strengthening metrics endpoint authentication to deny access when the admin password is unset, and updating multiple routes and templates to propagate and apply nonces consistently. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
🚅 Deployed to the ePDS-pr-100 environment in ePDS
|
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
packages/auth-service/src/routes/choose-handle.ts (1)
315-326:⚠️ Potential issue | 🟠 MajorInclude the nonce in this error render path.
This catch branch is the only POST error render that omits
res.locals.cspNonce, so the returned handle picker page will have a nonce-only CSP but a non-nonced inline script.🔒 Proposed fix
renderChooseHandlePage( handleDomain, 'Could not verify handle availability. Please try again.', res.locals.csrfToken, showRandomButton, customCss, + res.locals.cspNonce as string, ),🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/auth-service/src/routes/choose-handle.ts` around lines 315 - 326, The error branch that returns the handle picker omits the CSP nonce, causing a mismatch between the page CSP and inline scripts; update the render call in the catch block so renderChooseHandlePage receives res.locals.cspNonce (like other error paths do) — locate the catch that logs with logger.error({ err, fullHandle }, 'Failed to check handle availability') and pass res.locals.cspNonce into the renderChooseHandlePage invocation along with handleDomain, the error message, res.locals.csrfToken, showRandomButton, and customCss.packages/auth-service/src/routes/preview.ts (1)
215-243:⚠️ Potential issue | 🟠 MajorPass the CSP nonce to preview handle pages too.
/preview/choose-handleand/preview/choose-handle-pickerstill renderrenderChooseHandlePage()withoutres.locals.cspNonce, so their inline handle-checking script will be blocked by the new nonce-only CSP.🔒 Proposed fix
renderChooseHandlePage( FAKE_HANDLE_DOMAIN, queryString(req, 'error'), fakeCsrfToken(), true, css, + res.locals.cspNonce as string, ), @@ renderChooseHandlePage( FAKE_HANDLE_DOMAIN, queryString(req, 'error'), fakeCsrfToken(), false, css, + res.locals.cspNonce as string, ),🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/auth-service/src/routes/preview.ts` around lines 215 - 243, The preview routes for '/preview/choose-handle' and '/preview/choose-handle-picker' call renderChooseHandlePage without including the CSP nonce, so the inline handle-checking script will be blocked; update both handlers (the async functions that call getBranding and sendHtml) to read res.locals.cspNonce and pass it into renderChooseHandlePage (alongside FAKE_HANDLE_DOMAIN, queryString(req,'error'), fakeCsrfToken(), the boolean flag, and css) so the page gets the nonce for its inline script.packages/auth-service/src/routes/login-page.ts (1)
394-413:⚠️ Potential issue | 🟠 MajorMove the OTP
oninputhandler into the nonce-stamped script.With
'unsafe-inline'removed fromscript-src, CSP blocks the inlineoninput="..."attribute on the OTP field; the nonce on line 413 authorizes only<script nonce="...">blocks, not inline event handlers. Attach this listener inside the script instead.Proposed fix
<input type="text" id="code" name="code" required maxlength="${opts.otpLength}" pattern="${inputProps.pattern}" inputmode="${inputProps.inputmode}" autocomplete="one-time-code" placeholder="${inputProps.placeholder}" class="otp-input" autocapitalize="${inputProps.autocapitalize}" - oninput="this.value=this.value.replace(/[\\s-]/g,'')" style="letter-spacing: ${Math.max(2, Math.round(32 / opts.otpLength))}px"> @@ var recoveryLink = document.getElementById('recovery-link'); + var codeInput = document.getElementById('code'); + + codeInput.addEventListener('input', function() { + this.value = this.value.replace(/[\\s-]/g, ''); + });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/auth-service/src/routes/login-page.ts` around lines 394 - 413, Remove the inline oninput attribute from the OTP input (id="code", class="otp-input") and instead attach the same sanitizing listener inside the existing nonce-stamped script (the <script nonce="${escapeHtml(opts.cspNonce)}"> block); locate where the page writes the script and add document.getElementById('code')?.addEventListener('input', ...) to replace spaces and hyphens (same regex used inline), preserving behavior like maxlength/pattern/inputmode from opts and without adding any inline handlers so the CSP nonce authorizes the logic.packages/auth-service/src/index.ts (1)
40-50:⚠️ Potential issue | 🔴 CriticalUse timing-safe comparison for admin password authentication.
Line 76 compares the
Authorizationheader against a secret-derived value using!==. Replace withtimingSafeEqual()(available from@certified-app/shared) to prevent timing attacks:Suggested fix
import { timingSafeEqual } from '@certified-app/shared' // ... if (!authHeader || !timingSafeEqual(authHeader, expected)) { res.status(401).json({ error: 'Unauthorized' }) return }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/auth-service/src/index.ts` around lines 40 - 50, The admin auth comparison currently uses a plain !== check on authHeader vs expected; import timingSafeEqual from '@certified-app/shared' and replace that direct comparison with a timing-safe check by converting both sides to Buffers (e.g., Buffer.from(authHeader) and Buffer.from(expected)) and calling timingSafeEqual(bufferA, bufferB); ensure you still handle missing authHeader or expected early (respond 401) and update the conditional that currently uses authHeader/expected !== to use the timingSafeEqual result instead.
🧹 Nitpick comments (3)
e2e/step-definitions/security.steps.ts (2)
56-64: UseHeaders.getSetCookie()for multi-valuedSet-Cookie.Node's
Headers.get('set-cookie')joins multipleSet-Cookievalues with,— but because cookieExpiresattributes themselves contain commas, the combined string is ambiguous and easy to misparse. For theepds_csrf=substring check this still works today, but it's fragile: any future check that tries to inspect the cookie value would be broken by the join. Prefer the dedicatedgetSetCookie()accessor (Node 20+/undici), which returnsstring[].Suggested change
-Then('the response sets a CSRF cookie', function (this: EpdsWorld) { - const { headers } = getCapturedResponse(this) - const setCookie = headers.get('set-cookie') ?? '' - if (!/epds_csrf=/.test(setCookie)) { +Then('the response sets a CSRF cookie', function (this: EpdsWorld) { + const { headers } = getCapturedResponse(this) + const cookies = headers.getSetCookie() + if (!cookies.some((c) => /^epds_csrf=/.test(c))) { throw new Error( - `Expected Set-Cookie to include epds_csrf=..., got: ${setCookie || '(none)'}`, + `Expected Set-Cookie to include epds_csrf=..., got: ${cookies.join(' | ') || '(none)'}`, ) } })🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@e2e/step-definitions/security.steps.ts` around lines 56 - 64, Replace the fragile use of headers.get('set-cookie') in the Then step testing CSRF cookie with the multi-value accessor headers.getSetCookie(); in the step function (the Then handler using getCapturedResponse(this)), call headers.getSetCookie() to get a string[] (or empty array if undefined), then assert that some element matches /epds_csrf=/ and throw the same error message if none match, preserving getCapturedResponse(this) and the step name.
139-155:/preview/login404 fallback to/healthsilently weakens the CSP scenario.If the preview route is disabled in the environment under test, the step silently probes
/healthinstead. Since the middleware applies CSP globally, the nonce/unsafe-inlineassertions still pass — but the scenario's stated intent ("the login page is loaded") is no longer validated, and a regression where the login route specifically loses CSP would go undetected. Consider either requiring previews in the test env (fail on 404) or probing the real login route.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@e2e/step-definitions/security.steps.ts` around lines 139 - 155, The step When('the login page is loaded', async function (this: EpdsWorld) currently falls back from previewUrl (`${testEnv.authUrl}/preview/login`) to `/health` on a 404, which weakens the intended "login page is loaded" assertion; change the behavior in this step so that a 404 on previewUrl fails the test (throw or set a failing assertion) or instead probe the real login route (e.g., `${testEnv.authUrl}/login` or the actual auth-service login endpoint) and use that response for setCapturedResponse(this, ...), ensuring the test validates the actual login page CSP rather than a generic `/health` response.packages/shared/src/preview-ui.ts (1)
491-494: Consider defensive escaping (or validation) ofcspNoncein the attribute.
cspNonceis injected raw into a double-quoted HTML attribute. In practice auth-service generates it viacrypto.randomBytes(16).toString('base64url')so it's restricted to[A-Za-z0-9_-]and safe, but this helper is exported and could be called by future code. A one-line guard (orescapeHtml) keeps the contract defensive against an accidental caller that passes through unvalidated input.Optional hardening
export function previewClientIdScriptHtml(cspNonce?: string): string { - const nonceAttr = cspNonce ? ` nonce="${cspNonce}"` : '' + if (cspNonce && !/^[A-Za-z0-9_-]+$/.test(cspNonce)) { + throw new Error('cspNonce must be base64url-safe') + } + const nonceAttr = cspNonce ? ` nonce="${cspNonce}"` : '' return `<script${nonceAttr}>\n${PREVIEW_CLIENT_ID_SCRIPT_BODY}</script>` }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/shared/src/preview-ui.ts` around lines 491 - 494, The previewClientIdScriptHtml helper currently injects cspNonce raw into nonceAttr; make this defensive by validating or escaping cspNonce before interpolation in previewClientIdScriptHtml (e.g., in the function sanitize the input used to build nonceAttr: either run a strict whitelist check for /^[A-Za-z0-9_-]+$/ and only use it when it passes, or pass it through an HTML-attribute escape helper like escapeHtml and fall back to empty string on failure), so update previewClientIdScriptHtml to compute a safe nonce value and then build nonceAttr from that safe value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/auth-service/src/__tests__/security-headers.test.ts`:
- Around line 32-51: The withServer helper nests listen and close callbacks too
deeply; refactor by extracting small helpers to start and stop the server and
use async/await with try/finally to ensure cleanup: create a startServer
function that returns the server and bound port (use app.listen and AddressInfo)
and a closeServer helper that closes the server, then in withServer await
startServer(), call fn(baseUrl) inside a try block and always await
closeServer() in finally so you remove nested .then/.catch callbacks around
server.close and satisfy SonarCloud nesting rules.
In `@packages/auth-service/src/index.ts`:
- Around line 73-76: The direct string comparison authHeader !== expected must
be replaced with the constant-time helper timingSafeEqual to avoid timing
attacks: convert both values to Buffers (ensure authHeader is a string or use an
empty string if missing), check lengths equal, then call
timingSafeEqual(authBuf, expectedBuf) and branch on its boolean result instead
of !==; update the conditional around authHeader/expected (the symbols
authHeader, expected) to use this helper so secret comparisons are done in
constant time.
In `@packages/auth-service/src/middleware/security-headers.ts`:
- Around line 37-40: The code can pass a string[] to db.getAuthFlowByRequestUri
because req.query.request_uri is typed string | string[] | undefined; update the
check that sets clientId so it only calls db.getAuthFlowByRequestUri when typeof
req.query.request_uri === 'string' (e.g., guard req.query.request_uri with a
typeof check) and avoid casting with as string; reference the
req.query.request_uri usage and db.getAuthFlowByRequestUri call and ensure
clientId is only assigned from db.getAuthFlowByRequestUri(req.query.request_uri)
when the guard passes.
---
Outside diff comments:
In `@packages/auth-service/src/index.ts`:
- Around line 40-50: The admin auth comparison currently uses a plain !== check
on authHeader vs expected; import timingSafeEqual from '@certified-app/shared'
and replace that direct comparison with a timing-safe check by converting both
sides to Buffers (e.g., Buffer.from(authHeader) and Buffer.from(expected)) and
calling timingSafeEqual(bufferA, bufferB); ensure you still handle missing
authHeader or expected early (respond 401) and update the conditional that
currently uses authHeader/expected !== to use the timingSafeEqual result
instead.
In `@packages/auth-service/src/routes/choose-handle.ts`:
- Around line 315-326: The error branch that returns the handle picker omits the
CSP nonce, causing a mismatch between the page CSP and inline scripts; update
the render call in the catch block so renderChooseHandlePage receives
res.locals.cspNonce (like other error paths do) — locate the catch that logs
with logger.error({ err, fullHandle }, 'Failed to check handle availability')
and pass res.locals.cspNonce into the renderChooseHandlePage invocation along
with handleDomain, the error message, res.locals.csrfToken, showRandomButton,
and customCss.
In `@packages/auth-service/src/routes/login-page.ts`:
- Around line 394-413: Remove the inline oninput attribute from the OTP input
(id="code", class="otp-input") and instead attach the same sanitizing listener
inside the existing nonce-stamped script (the <script
nonce="${escapeHtml(opts.cspNonce)}"> block); locate where the page writes the
script and add document.getElementById('code')?.addEventListener('input', ...)
to replace spaces and hyphens (same regex used inline), preserving behavior like
maxlength/pattern/inputmode from opts and without adding any inline handlers so
the CSP nonce authorizes the logic.
In `@packages/auth-service/src/routes/preview.ts`:
- Around line 215-243: The preview routes for '/preview/choose-handle' and
'/preview/choose-handle-picker' call renderChooseHandlePage without including
the CSP nonce, so the inline handle-checking script will be blocked; update both
handlers (the async functions that call getBranding and sendHtml) to read
res.locals.cspNonce and pass it into renderChooseHandlePage (alongside
FAKE_HANDLE_DOMAIN, queryString(req,'error'), fakeCsrfToken(), the boolean flag,
and css) so the page gets the nonce for its inline script.
---
Nitpick comments:
In `@e2e/step-definitions/security.steps.ts`:
- Around line 56-64: Replace the fragile use of headers.get('set-cookie') in the
Then step testing CSRF cookie with the multi-value accessor
headers.getSetCookie(); in the step function (the Then handler using
getCapturedResponse(this)), call headers.getSetCookie() to get a string[] (or
empty array if undefined), then assert that some element matches /epds_csrf=/
and throw the same error message if none match, preserving
getCapturedResponse(this) and the step name.
- Around line 139-155: The step When('the login page is loaded', async function
(this: EpdsWorld) currently falls back from previewUrl
(`${testEnv.authUrl}/preview/login`) to `/health` on a 404, which weakens the
intended "login page is loaded" assertion; change the behavior in this step so
that a 404 on previewUrl fails the test (throw or set a failing assertion) or
instead probe the real login route (e.g., `${testEnv.authUrl}/login` or the
actual auth-service login endpoint) and use that response for
setCapturedResponse(this, ...), ensuring the test validates the actual login
page CSP rather than a generic `/health` response.
In `@packages/shared/src/preview-ui.ts`:
- Around line 491-494: The previewClientIdScriptHtml helper currently injects
cspNonce raw into nonceAttr; make this defensive by validating or escaping
cspNonce before interpolation in previewClientIdScriptHtml (e.g., in the
function sanitize the input used to build nonceAttr: either run a strict
whitelist check for /^[A-Za-z0-9_-]+$/ and only use it when it passes, or pass
it through an HTML-attribute escape helper like escapeHtml and fall back to
empty string on failure), so update previewClientIdScriptHtml to compute a safe
nonce value and then build nonceAttr from that safe value.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: fdf4ebf3-e701-4ff9-9c0e-bbb1ea21c749
📒 Files selected for processing (11)
.changeset/csp-nonce-and-metrics-auth.mde2e/step-definitions/security.steps.tsfeatures/security.featurepackages/auth-service/src/__tests__/security-headers.test.tspackages/auth-service/src/index.tspackages/auth-service/src/middleware/security-headers.tspackages/auth-service/src/routes/choose-handle.tspackages/auth-service/src/routes/login-page.tspackages/auth-service/src/routes/preview.tspackages/shared/src/index.tspackages/shared/src/preview-ui.ts
| async function withServer( | ||
| app: Express, | ||
| fn: (baseUrl: string) => Promise<void>, | ||
| ): Promise<void> { | ||
| await new Promise<void>((resolve, reject) => { | ||
| const server = app.listen(0, '127.0.0.1', () => { | ||
| const addr = server.address() as AddressInfo | ||
| fn(`http://127.0.0.1:${addr.port}`) | ||
| .then(() => { | ||
| server.close(() => { | ||
| resolve() | ||
| }) | ||
| }) | ||
| .catch((err: unknown) => { | ||
| server.close(() => { | ||
| reject(err as Error) | ||
| }) | ||
| }) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Refactor withServer() to clear the SonarCloud nesting failure.
The current helper nests callbacks deeply enough to fail SonarCloud on Lines 41 and 46. Split listen/close into small helpers and use try/finally for cleanup.
♻️ Proposed refactor
async function withServer(
app: Express,
fn: (baseUrl: string) => Promise<void>,
): Promise<void> {
- await new Promise<void>((resolve, reject) => {
+ const server = await new Promise<ReturnType<Express['listen']>>((resolve) => {
const server = app.listen(0, '127.0.0.1', () => {
- const addr = server.address() as AddressInfo
- fn(`http://127.0.0.1:${addr.port}`)
- .then(() => {
- server.close(() => {
- resolve()
- })
- })
- .catch((err: unknown) => {
- server.close(() => {
- reject(err as Error)
- })
- })
+ resolve(server)
})
})
+
+ const addr = server.address() as AddressInfo
+ try {
+ await fn(`http://127.0.0.1:${addr.port}`)
+ } finally {
+ await new Promise<void>((resolve, reject) => {
+ server.close((err) => {
+ if (err) reject(err)
+ else resolve()
+ })
+ })
+ }
}🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis
[failure] 41-41: Refactor this code to not nest functions more than 4 levels deep.
[failure] 46-46: Refactor this code to not nest functions more than 4 levels deep.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/auth-service/src/__tests__/security-headers.test.ts` around lines 32
- 51, The withServer helper nests listen and close callbacks too deeply;
refactor by extracting small helpers to start and stop the server and use
async/await with try/finally to ensure cleanup: create a startServer function
that returns the server and bound port (use app.listen and AddressInfo) and a
closeServer helper that closes the server, then in withServer await
startServer(), call fn(baseUrl) inside a try block and always await
closeServer() in finally so you remove nested .then/.catch callbacks around
server.close and satisfy SonarCloud nesting rules.
| const authHeader = req.headers.authorization | ||
| const expected = | ||
| 'Basic ' + Buffer.from('admin:' + adminPassword).toString('base64') | ||
| if (!authHeader || authHeader !== expected) { |
There was a problem hiding this comment.
Use timingSafeEqual() for the Basic auth secret comparison.
authHeader !== expected compares a secret-derived value with a regular string comparison. Use the shared constant-time helper for this check. As per coding guidelines, "Use timingSafeEqual() for all secret and token comparisons".
🛡️ Proposed fix
-import { createLogger, getEpdsVersion } from '@certified-app/shared'
+import {
+ createLogger,
+ getEpdsVersion,
+ timingSafeEqual,
+} from '@certified-app/shared'
@@
- if (!authHeader || authHeader !== expected) {
+ if (!authHeader || !timingSafeEqual(authHeader, expected)) {
res.status(401).json({ error: 'Unauthorized' })
return
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const authHeader = req.headers.authorization | |
| const expected = | |
| 'Basic ' + Buffer.from('admin:' + adminPassword).toString('base64') | |
| if (!authHeader || authHeader !== expected) { | |
| import { | |
| createLogger, | |
| getEpdsVersion, | |
| timingSafeEqual, | |
| } from '@certified-app/shared' | |
| // ... other code ... | |
| const authHeader = req.headers.authorization | |
| const expected = | |
| 'Basic ' + Buffer.from('admin:' + adminPassword).toString('base64') | |
| if (!authHeader || !timingSafeEqual(authHeader, expected)) { | |
| res.status(401).json({ error: 'Unauthorized' }) | |
| return | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/auth-service/src/index.ts` around lines 73 - 76, The direct string
comparison authHeader !== expected must be replaced with the constant-time
helper timingSafeEqual to avoid timing attacks: convert both values to Buffers
(ensure authHeader is a string or use an empty string if missing), check lengths
equal, then call timingSafeEqual(authBuf, expectedBuf) and branch on its boolean
result instead of !==; update the conditional around authHeader/expected (the
symbols authHeader, expected) to use this helper so secret comparisons are done
in constant time.
| if (!clientId && req.query.request_uri) { | ||
| clientId = | ||
| db.getAuthFlowByRequestUri(req.query.request_uri as string)?.clientId ?? | ||
| undefined |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Check if the file exists and examine the security-headers middleware
fd -t f security-headers.tsRepository: hypercerts-org/ePDS
Length of output: 119
🏁 Script executed:
# Read the security-headers.ts file to see the exact code
cat -n packages/auth-service/src/middleware/security-headers.ts | head -60Repository: hypercerts-org/ePDS
Length of output: 3068
🏁 Script executed:
# Search for other uses of req.query in the codebase to see patterns
rg "req\.query\." packages/auth-service/src/ -A 2 -B 2 | head -80Repository: hypercerts-org/ePDS
Length of output: 3890
🏁 Script executed:
# Find the getAuthFlowByRequestUri method definition
rg "getAuthFlowByRequestUri" packages/shared/src/db.ts -A 5Repository: hypercerts-org/ePDS
Length of output: 408
🏁 Script executed:
# Also check the overall EpdsDb class to understand the context
rg "class EpdsDb|getAuthFlowByRequestUri" packages/shared/src/db.ts -B 2 -A 10 | head -60Repository: hypercerts-org/ePDS
Length of output: 959
Guard request_uri before passing it to the DB lookup.
req.query.request_uri can be string | string[] | undefined at runtime when query parameters are repeated or malformed. The current code's truthy check on line 37 would pass for an array, then unsafely cast it with as string before calling the DB method. Add a type guard to ensure only strings reach the database lookup.
Suggested fix
- if (!clientId && req.query.request_uri) {
+ const requestUri =
+ typeof req.query.request_uri === 'string'
+ ? req.query.request_uri
+ : undefined
+ if (!clientId && requestUri) {
clientId =
- db.getAuthFlowByRequestUri(req.query.request_uri as string)?.clientId ??
- undefined
+ db.getAuthFlowByRequestUri(requestUri)?.clientId ?? undefined
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/auth-service/src/middleware/security-headers.ts` around lines 37 -
40, The code can pass a string[] to db.getAuthFlowByRequestUri because
req.query.request_uri is typed string | string[] | undefined; update the check
that sets clientId so it only calls db.getAuthFlowByRequestUri when typeof
req.query.request_uri === 'string' (e.g., guard req.query.request_uri with a
typeof check) and avoid casting with as string; reference the
req.query.request_uri usage and db.getAuthFlowByRequestUri call and ensure
clientId is only assigned from db.getAuthFlowByRequestUri(req.query.request_uri)
when the guard passes.
| res.locals.csrfToken, | ||
| showRandomButton, | ||
| customCss, | ||
| res.locals.cspNonce as string, | ||
| ), | ||
| ) | ||
| return |
There was a problem hiding this comment.
🔴 The PR threads cspNonce through most renderChooseHandlePage call sites but misses two locations, causing the browser to block the inline script tag via CSP in those paths. In choose-handle.ts, the catch block at lines ~314-327 (network/timeout error from the PDS check-handle fetch) calls renderChooseHandlePage with only 5 arguments, omitting cspNonce — all four other error-path call sites in the same POST handler correctly pass res.locals.cspNonce as string. In preview.ts, both /preview/choose-handle and /preview/choose-handle-picker routes also omit cspNonce, breaking handle-picker interactivity (live availability check, random handle generation, form validation, double-submit prevention) on those preview pages. Fix: pass res.locals.cspNonce as string as the sixth argument in all three missing call sites.
Extended reasoning...
What the bug is and how it manifests
This PR replaces 'unsafe-inline' in the script-src CSP directive with a per-response nonce, stamped on inline script tags via res.locals.cspNonce. The renderChooseHandlePage function was updated to accept an optional cspNonce parameter and conditionally emit a nonced script tag when it is provided. The PR correctly threads the nonce through four of the five renderChooseHandlePage call sites in the POST handler, and into the GET handler and several preview routes — but misses three call sites.
The specific code paths that trigger it
-
choose-handle.ts catch block (~line 314-327): The try/catch block wrapping the fetch() call to /_internal/check-handle catches network errors and timeouts. The catch arm calls renderChooseHandlePage(handleDomain, 'Could not verify handle availability...', res.locals.csrfToken, showRandomButton, customCss) with exactly five arguments — no cspNonce. The four surrounding error paths (invalid format at ~line 269-278, non-OK HTTP status at ~line 303-312, handle taken at ~line 337-343, plus the GET handler at ~line 192-201) all correctly pass res.locals.cspNonce as string as the sixth argument.
-
preview.ts /preview/choose-handle (~line 219-225) and /preview/choose-handle-picker (~line 236-242): Both routes call renderChooseHandlePage with five arguments. The login preview routes (/preview/login and /preview/login-otp) were correctly updated in this PR to pass cspNonce: res.locals.cspNonce as string, but the two choose-handle preview routes were overlooked.
Why existing code does not prevent it
The sixth parameter is typed as cspNonce?: string (optional), so TypeScript compiles these call sites without error. The template renders the nonce attribute conditionally — when cspNonce is undefined, it silently emits a bare script tag with no nonce attribute. The securityHeaders middleware runs globally (before all routes) and always emits script-src 'self' 'nonce-xyz' on every response, so the mismatch — nonce in header, no nonce on script — triggers a CSP violation on every affected page load.
Impact
For the catch path: any user who hits a network error or timeout while the PDS internal check-handle service is unreachable gets a choose-handle page where the inline script is browser-blocked. This silently breaks live availability checking, the random handle button, form validation, and double-submit prevention — the user sees the form but all client-side interactivity is dead. For the preview paths: visiting /preview/choose-handle or /preview/choose-handle-picker with AUTH_PREVIEW_ROUTES=1 always produces a CSP-blocked script, making these preview routes completely non-functional for developer iteration.
Step-by-step proof
- User submits POST /auth/choose-handle with a handle; the PDS internal service is momentarily unreachable.
- The fetch() to /_internal/check-handle throws; the catch (err) block executes.
- renderChooseHandlePage(handleDomain, 'Could not verify...', csrfToken, showRandomButton, customCss) is called with 5 args; cspNonce is undefined.
- Template emits a bare script tag with no nonce attribute.
- Browser receives Content-Security-Policy with script-src nonce-abc123.
- Browser blocks the inline script: CSP violation logged to console, no nonce match.
- All handle-picker JS is inert: availability check, random handle, validation, and double-submit guard are all broken.
- Same outcome for any visitor to /preview/choose-handle or /preview/choose-handle-picker.
How to fix
Pass res.locals.cspNonce as string as the sixth argument to renderChooseHandlePage in the catch block in choose-handle.ts, and in both preview routes in preview.ts.
Replace the auth service's Content-Security-Policy script-src 'unsafe-inline' with a per-response nonce. The security-headers middleware now generates a fresh base64url nonce on every request, stamps it into script-src, and exposes it via res.locals.cspNonce so templates can emit <script nonce="..."> for inline scripts. All inline scripts ePDS ships (login page, choose-handle page, preview index) are threaded through to read and stamp the nonce. Also tighten /metrics on the auth service: if PDS_ADMIN_PASSWORD is unset, return 401 instead of serving metrics unauthenticated, so a missing env var can't silently open the endpoint. Extract the inline security-headers middleware into its own module with dedicated unit tests (7 tests) covering the nonce contract, baseline headers, and client-origin img-src. Enable 5 previously-pending security.feature scenarios: two CSRF checks (targeting the server-rendered recovery form, which uses ePDS's own CSRF middleware rather than better-auth's), the security-headers table, the CSP check, and the metrics 401. New step definitions live in e2e/step-definitions/security.steps.ts.
780190d to
2755af3
Compare
|
Coverage Report for CI Build 24691210895Coverage increased (+0.07%) to 42.588%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |



Summary
Test plan
🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes