Skip to content

feat(auth-service): nonce-based CSP and 5 security cucumber scenarios#100

Open
aspiers wants to merge 1 commit intomainfrom
hyper-security-scenarios
Open

feat(auth-service): nonce-based CSP and 5 security cucumber scenarios#100
aspiers wants to merge 1 commit intomainfrom
hyper-security-scenarios

Conversation

@aspiers
Copy link
Copy Markdown
Contributor

@aspiers aspiers commented Apr 20, 2026

Summary

  • Replaces `script-src 'unsafe-inline'` with a per-response nonce in the auth service CSP. All inline scripts ePDS ships (login page, choose-handle page, preview index) are threaded through to stamp the nonce.
  • Closes a soft info leak: `/metrics` on the auth service now returns 401 when `PDS_ADMIN_PASSWORD` is unset, instead of serving uptime/RSS/DB counters unauthenticated.
  • Enables 5 previously-pending scenarios in `features/security.feature`: two CSRF checks (targeting the server-rendered recovery form, since the login form submits via JS to better-auth which has its own CSRF), the security-headers table, the CSP nonce check, and the metrics 401.
  • Extracts the inline `security-headers` middleware into its own module with 7 unit tests covering the nonce contract, baseline headers, and dynamic `img-src` for branded client origins.

Test plan

  • `pnpm typecheck` clean
  • `pnpm lint` clean
  • `pnpm test` — 483 tests pass (+7 new)
  • `pnpm format:check` clean
  • Cucumber dry-run parses `features/security.feature` with no undefined steps
  • E2E on Railway PR preview: all 5 newly-enabled security scenarios pass
  • Visual smoke of login page + choose-handle page on Railway preview: inline scripts execute (no CSP violation in devtools console)

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features
    • Metrics endpoint now enforces HTTP Basic authentication when an admin password is configured; unauthenticated access returns 401 Unauthorized instead of exposing metrics without protection.
    • Strengthened security for inline scripts through improved Content Security Policy implementation that prevents unauthorized script injection while supporting legitimate application scripts with response-specific security tokens.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 20, 2026

🦋 Changeset detected

Latest 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

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 20, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
epds-demo Ready Ready Preview, Comment Apr 20, 2026 9:27pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 20, 2026

Warning

Rate limit exceeded

@aspiers has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 48 minutes and 9 seconds before requesting another review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a188ff94-b504-4e39-b545-2b9b173a980d

📥 Commits

Reviewing files that changed from the base of the PR and between 780190d and 2755af3.

📒 Files selected for processing (11)
  • .changeset/csp-nonce-and-metrics-auth.md
  • e2e/step-definitions/security.steps.ts
  • features/security.feature
  • packages/auth-service/src/__tests__/security-headers.test.ts
  • packages/auth-service/src/index.ts
  • packages/auth-service/src/lib/security-headers.ts
  • packages/auth-service/src/routes/choose-handle.ts
  • packages/auth-service/src/routes/login-page.ts
  • packages/auth-service/src/routes/preview.ts
  • packages/shared/src/index.ts
  • packages/shared/src/preview-ui.ts
📝 Walkthrough

Walkthrough

This 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

Cohort / File(s) Summary
Changeset & Documentation
.changeset/csp-nonce-and-metrics-auth.md
Documents two auth-service behavior changes: CSP script-src now uses per-response nonce instead of 'unsafe-inline', and /metrics endpoint returns 401 Unauthorized when PDS_ADMIN_PASSWORD is unset.
CSP Security Headers Middleware
packages/auth-service/src/middleware/security-headers.ts, packages/auth-service/src/__tests__/security-headers.test.ts
New middleware module that generates per-response cryptographic nonces, stores them on res.locals.cspNonce, constructs Content-Security-Policy with script-src nonce directive (without unsafe-inline), and conditionally augments img-src based on client_id or auth flow lookup via DB. Comprehensive test suite verifies header contracts and nonce injection.
Auth Service Core & Metrics
packages/auth-service/src/index.ts
Refactors inline security headers into new shared middleware, removes duplicated header logic. Updates /metrics endpoint to immediately return 401 when PDS_ADMIN_PASSWORD is unset; refactors Basic auth credential checking to pre-compute expected header and validate directly.
Route Handlers with Nonce Injection
packages/auth-service/src/routes/login-page.ts, packages/auth-service/src/routes/choose-handle.ts, packages/auth-service/src/routes/preview.ts
Updated route handlers and render functions to accept and pass cspNonce from res.locals.cspNonce into template rendering calls; inline <script> tags now conditionally include nonce attribute when nonce is provided.
Shared UI & Preview Script Generation
packages/shared/src/preview-ui.ts, packages/shared/src/index.ts
Refactored inline client-id wiring script into reusable previewClientIdScriptHtml(cspNonce?: string) function; updated renderPreviewIndexPage to accept optional cspNonce and generate scripts dynamically. Added new export in shared index.
E2E Test Coverage
features/security.feature, e2e/step-definitions/security.steps.ts
Activated security test scenarios (removed @pending tags) covering CSRF token validation on recovery page, security header assertions with updated Strict-Transport-Security value, CSP checks for nonce presence and absence of unsafe-inline, and unauthenticated /metrics returning 401. New step definitions implement HTTP fetch-based captures and response assertions.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 A nonce in each script keeps injections at bay,
Metrics locked tight when the password won't stay,
Templates now decorated with cryptographic care,
CSP guards inline code with nonces to spare! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: introducing nonce-based CSP and enabling security-related cucumber test scenarios in the auth service.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch hyper-security-scenarios

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@railway-app
Copy link
Copy Markdown

railway-app bot commented Apr 20, 2026

🚅 Deployed to the ePDS-pr-100 environment in ePDS

Service Status Web Updated (UTC)
@certified-app/demo untrusted ✅ Success (View Logs) Web Apr 20, 2026 at 9:28 pm
@certified-app/auth-service ✅ Success (View Logs) Web Apr 20, 2026 at 9:27 pm
@certified-app/pds-core ✅ Success (View Logs) Web Apr 20, 2026 at 9:27 pm
@certified-app/demo ✅ Success (View Logs) Web Apr 20, 2026 at 9:27 pm

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Include 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 | 🟠 Major

Pass the CSP nonce to preview handle pages too.

/preview/choose-handle and /preview/choose-handle-picker still render renderChooseHandlePage() without res.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 | 🟠 Major

Move the OTP oninput handler into the nonce-stamped script.

With 'unsafe-inline' removed from script-src, CSP blocks the inline oninput="..." 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 | 🔴 Critical

Use timing-safe comparison for admin password authentication.

Line 76 compares the Authorization header against a secret-derived value using !==. Replace with timingSafeEqual() (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: Use Headers.getSetCookie() for multi-valued Set-Cookie.

Node's Headers.get('set-cookie') joins multiple Set-Cookie values with , — but because cookie Expires attributes themselves contain commas, the combined string is ambiguous and easy to misparse. For the epds_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 dedicated getSetCookie() accessor (Node 20+/undici), which returns string[].

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/login 404 fallback to /health silently weakens the CSP scenario.

If the preview route is disabled in the environment under test, the step silently probes /health instead. Since the middleware applies CSP globally, the nonce/unsafe-inline assertions 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) of cspNonce in the attribute.

cspNonce is injected raw into a double-quoted HTML attribute. In practice auth-service generates it via crypto.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 (or escapeHtml) 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

📥 Commits

Reviewing files that changed from the base of the PR and between 7229c22 and 780190d.

📒 Files selected for processing (11)
  • .changeset/csp-nonce-and-metrics-auth.md
  • e2e/step-definitions/security.steps.ts
  • features/security.feature
  • packages/auth-service/src/__tests__/security-headers.test.ts
  • packages/auth-service/src/index.ts
  • packages/auth-service/src/middleware/security-headers.ts
  • packages/auth-service/src/routes/choose-handle.ts
  • packages/auth-service/src/routes/login-page.ts
  • packages/auth-service/src/routes/preview.ts
  • packages/shared/src/index.ts
  • packages/shared/src/preview-ui.ts

Comment on lines +32 to +51
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)
})
})
})
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

See more on https://sonarcloud.io/project/issues?id=hypercerts-org_ePDS&issues=AZ2svrwTMEXeMQGE_I6L&open=AZ2svrwTMEXeMQGE_I6L&pullRequest=100


[failure] 46-46: Refactor this code to not nest functions more than 4 levels deep.

See more on https://sonarcloud.io/project/issues?id=hypercerts-org_ePDS&issues=AZ2svrwTMEXeMQGE_I6M&open=AZ2svrwTMEXeMQGE_I6M&pullRequest=100

🤖 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.

Comment on lines +73 to +76
const authHeader = req.headers.authorization
const expected =
'Basic ' + Buffer.from('admin:' + adminPassword).toString('base64')
if (!authHeader || authHeader !== expected) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

Comment on lines +37 to +40
if (!clientId && req.query.request_uri) {
clientId =
db.getAuthFlowByRequestUri(req.query.request_uri as string)?.clientId ??
undefined
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Check if the file exists and examine the security-headers middleware
fd -t f security-headers.ts

Repository: 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 -60

Repository: 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 -80

Repository: hypercerts-org/ePDS

Length of output: 3890


🏁 Script executed:

# Find the getAuthFlowByRequestUri method definition
rg "getAuthFlowByRequestUri" packages/shared/src/db.ts -A 5

Repository: 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 -60

Repository: 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.

Comment on lines 306 to 312
res.locals.csrfToken,
showRandomButton,
customCss,
res.locals.cspNonce as string,
),
)
return
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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

  1. 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.

  2. 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

  1. User submits POST /auth/choose-handle with a handle; the PDS internal service is momentarily unreachable.
  2. The fetch() to /_internal/check-handle throws; the catch (err) block executes.
  3. renderChooseHandlePage(handleDomain, 'Could not verify...', csrfToken, showRandomButton, customCss) is called with 5 args; cspNonce is undefined.
  4. Template emits a bare script tag with no nonce attribute.
  5. Browser receives Content-Security-Policy with script-src nonce-abc123.
  6. Browser blocks the inline script: CSP violation logged to console, no nonce match.
  7. All handle-picker JS is inert: availability check, random handle, validation, and double-submit guard are all broken.
  8. 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.
@sonarqubecloud
Copy link
Copy Markdown

@coveralls-official
Copy link
Copy Markdown

Coverage Report for CI Build 24691210895

Coverage increased (+0.07%) to 42.588%

Details

  • Coverage increased (+0.07%) from the base build.
  • Patch coverage: 8 uncovered changes across 1 file (8 of 16 lines covered, 50.0%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
packages/auth-service/src/index.ts 8 0 0.0%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 2277
Covered Lines: 987
Line Coverage: 43.35%
Relevant Branches: 1339
Covered Branches: 553
Branch Coverage: 41.3%
Branches in Coverage %: Yes
Coverage Strength: 3.69 hits per line

💛 - Coveralls

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant