diff --git a/.changeset/csp-nonce-and-metrics-auth.md b/.changeset/csp-nonce-and-metrics-auth.md new file mode 100644 index 00000000..14b958d2 --- /dev/null +++ b/.changeset/csp-nonce-and-metrics-auth.md @@ -0,0 +1,11 @@ +--- +'ePDS': minor +--- + +Auth service tightens its Content-Security-Policy and locks down the metrics endpoint. + +**Affects:** Operators + +**Operators:** the auth service's `Content-Security-Policy` response header now uses a per-response nonce on the `script-src` directive instead of `'unsafe-inline'`. The resulting policy looks like `default-src 'self'; script-src 'self' 'nonce-'; style-src 'self' 'unsafe-inline'; img-src 'self' data: [client-origin]; connect-src 'self'`. All inline `` +} + /** Accept a supported preview override, otherwise preserve the configured policy. */ export function resolvePreviewOtpCharset( requested: string | undefined, diff --git a/packages/auth-service/src/routes/account-login.ts b/packages/auth-service/src/routes/account-login.ts index 786b795f..df4dfe02 100644 --- a/packages/auth-service/src/routes/account-login.ts +++ b/packages/auth-service/src/routes/account-login.ts @@ -17,7 +17,7 @@ import { Router, type Request, type Response } from 'express' import { escapeHtml, maskEmail, createLogger } from '@certified-app/shared' import { fromNodeHeaders } from 'better-auth/node' import type { AuthServiceContext } from '../context.js' -import { buildOtpInputFilter, buildOtpInputProps } from '../otp-input.js' +import { buildOtpInputProps, renderOtpInputFilterScript } from '../otp-input.js' import type { BetterAuthInstance } from '../better-auth.js' import { POWERED_BY_CSS, POWERED_BY_HTML } from '../lib/page-helpers.js' import { @@ -48,7 +48,12 @@ export function createAccountLoginRouter( /* not logged in, continue */ } - res.type('html').send(renderLoginForm({ csrfToken: res.locals.csrfToken })) + res.type('html').send( + renderLoginForm({ + csrfToken: res.locals.csrfToken, + cspNonce: res.locals.cspNonce as string, + }), + ) }) // POST /account/send-otp - send OTP via better-auth, show OTP form @@ -59,6 +64,7 @@ export function createAccountLoginRouter( res.status(400).send( renderLoginForm({ csrfToken: res.locals.csrfToken, + cspNonce: res.locals.cspNonce as string, error: 'Email is required.', }), ) @@ -79,6 +85,7 @@ export function createAccountLoginRouter( renderOtpForm({ email, csrfToken: res.locals.csrfToken, + cspNonce: res.locals.cspNonce as string, otpLength: ctx.config.otpLength, otpCharset: ctx.config.otpCharset, }), @@ -97,6 +104,7 @@ export function createAccountLoginRouter( csrfToken: res.locals.csrfToken, otpLength: ctx.config.otpLength, otpCharset: ctx.config.otpCharset, + cspNonce: res.locals.cspNonce as string, error: 'Email and code are required.', }), ) @@ -131,6 +139,7 @@ export function createAccountLoginRouter( csrfToken: res.locals.csrfToken, otpLength: ctx.config.otpLength, otpCharset: ctx.config.otpCharset, + cspNonce: res.locals.cspNonce as string, error: errMsg, }), ) @@ -140,7 +149,11 @@ export function createAccountLoginRouter( return router } -function renderLoginForm(opts: { csrfToken: string; error?: string }): string { +function renderLoginForm(opts: { + csrfToken: string + cspNonce: string + error?: string +}): string { return ` @@ -171,7 +184,7 @@ function renderLoginForm(opts: { csrfToken: string; error?: string }): string { ${POWERED_BY_HTML} - ${renderEmailTypoGuardScript('form-account-send-otp', 'email')} + ${renderEmailTypoGuardScript('form-account-send-otp', 'email', opts.cspNonce)} ` } @@ -179,13 +192,13 @@ function renderLoginForm(opts: { csrfToken: string; error?: string }): string { function renderOtpForm(opts: { email: string csrfToken: string + cspNonce: string otpLength: number otpCharset: 'numeric' | 'alphanumeric' error?: string }): string { const maskedEmail = maskEmail(opts.email) const inputProps = buildOtpInputProps(opts.otpLength, opts.otpCharset) - const inputFilter = buildOtpInputFilter(opts.otpCharset) return ` @@ -217,7 +230,6 @@ function renderOtpForm(opts: { autocapitalize="${inputProps.autocapitalize}" placeholder="${inputProps.placeholder}" class="otp-input" - oninput="this.value=this.value.replace(${inputFilter.toString()},'')${opts.otpCharset === 'alphanumeric' ? '.toUpperCase()' : ''}" style="letter-spacing: ${Math.max(2, Math.round(32 / opts.otpLength))}px"> @@ -230,6 +242,7 @@ function renderOtpForm(opts: { ${POWERED_BY_HTML} + ${renderOtpInputFilterScript('otp', opts.otpCharset, opts.cspNonce)} ` } diff --git a/packages/auth-service/src/routes/choose-handle.ts b/packages/auth-service/src/routes/choose-handle.ts index 73e502ca..ba635661 100644 --- a/packages/auth-service/src/routes/choose-handle.ts +++ b/packages/auth-service/src/routes/choose-handle.ts @@ -234,6 +234,7 @@ export function createChooseHandleRouter( branding.customCss, branding.customFaviconUrl, branding.customFaviconUrlDark, + res.locals.cspNonce as string, ), ) }) @@ -317,6 +318,7 @@ export function createChooseHandleRouter( branding.customCss, branding.customFaviconUrl, branding.customFaviconUrlDark, + res.locals.cspNonce as string, ), ) return @@ -352,6 +354,7 @@ export function createChooseHandleRouter( branding.customCss, branding.customFaviconUrl, branding.customFaviconUrlDark, + res.locals.cspNonce as string, ), ) return @@ -369,6 +372,7 @@ export function createChooseHandleRouter( branding.customCss, branding.customFaviconUrl, branding.customFaviconUrlDark, + res.locals.cspNonce as string, ), ) return @@ -386,6 +390,7 @@ export function createChooseHandleRouter( branding.customCss, branding.customFaviconUrl, branding.customFaviconUrlDark, + res.locals.cspNonce as string, ), ) return @@ -490,12 +495,13 @@ export function createChooseHandleRouter( export function renderChooseHandlePage( handleDomain: string, - error?: string, - csrfToken?: string, - showRandomButton?: boolean, - customCss?: string | null, - customFaviconUrl?: string | null, - customFaviconUrlDark?: string | null, + error: string | undefined, + csrfToken: string | undefined, + showRandomButton: boolean, + customCss: string | null, + customFaviconUrl: string | null, + customFaviconUrlDark: string | null, + cspNonce: string, ): string { // role=alert only on the populated branch: it is static at render // time, so the default assertive announcement is what we want. The @@ -588,7 +594,7 @@ export function renderChooseHandlePage( ${POWERED_BY_HTML} - ` +` + +/** + * Inline ` +} + +/** + * Back-compat: the no-nonce variant of the inline script tag. Callers on + * services that set a CSP with `script-src 'nonce-...'` must use + * {@link previewClientIdScriptHtml} instead, passing the request nonce. + */ +export const PREVIEW_CLIENT_ID_SCRIPT_HTML = previewClientIdScriptHtml()