fix: escape passkey notification label and defer registration event to after-commit - #371
Conversation
…ter-commit Follow-up hardening from a post-release review of the 5.3.3 WebAuthn work. - Escape the client-supplied credential label before it reaches the passkey-registered notification template. The template renders it with th:utext (so the message's own <strong> markup survives), so an unescaped label could inject HTML into the very email that warns the owner of an unrecognized passkey. UserEmailService now HTML-escapes it. - Move WebAuthnCredentialRegistrationListener to @TransactionalEventListener(AFTER_COMMIT, fallbackExecution=true). The event is published inside the @transactional save() before commit, so a commit failure (e.g. a label longer than the 64-char column) previously sent a notification and recorded an audit entry for a registration that never persisted. - Clarify CONFIG.md on custom StepUpService precedence: setPassword consults the bean whenever present, while the passkey delete/rename/remove-password endpoints require user.security.stepUp.enabled=true. This matches the deliberate per-endpoint behavior in the code (WebAuthnManagementAPI keys on the property to avoid newly enforcing step-up on 5.3.1 SPI adopters). Adds direct unit tests for the label escaping and null fallback, a no-recipient no-op test, and a guard test pinning the AFTER_COMMIT phase. Claude-Session: https://claude.ai/code/session_01KL5qvKVGQLLQDjHToy34vj
There was a problem hiding this comment.
🟢 Approval recommended
The changes are low-risk, align with the stated intent (escaping + after-commit listener), and are covered by targeted new tests.
Pull request overview
This PR is a small post-release hardening follow-up for the framework’s WebAuthn (passkey) enrollment audit + notification flow: it prevents HTML injection in the “new passkey added” email by escaping the user-supplied credential label, and ensures the enrollment listener only reacts after the enrolling transaction commits to avoid phantom audit/notification on rollback. It also updates the configuration documentation to accurately describe StepUpService precedence and which endpoints consult it.
Changes:
- Escape the passkey label (
HtmlUtils.htmlEscape) before passing it into the Thymeleafth:utextnotification template. - Convert the WebAuthn credential registration listener to an
@TransactionalEventListenerrunningAFTER_COMMIT(withfallbackExecution=true) and update/add tests to pin this behavior. - Clarify
CONFIG.mdwording around customStepUpServicebehavior across endpoints.
File summaries
| File | Description |
|---|---|
| src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java | Escapes the passkey label before rendering in the HTML email template. |
| src/main/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListener.java | Defers listener execution to after transaction commit via @TransactionalEventListener. |
| src/test/java/com/digitalsanctuary/spring/user/service/UserEmailServiceTest.java | Adds tests validating label escaping, null-label fallback, and no-op when user has no email. |
| src/test/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListenerTest.java | Updates tests for the new handler method and adds an assertion pinning AFTER_COMMIT + fallbackExecution. |
| CONFIG.md | Updates documentation to reflect actual endpoint-by-endpoint StepUpService consultation behavior. |
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The enrollment gate is a filter-chain authorizeHttpRequests rule, so its
denial is raised before any controller and never reaches
WebAuthnManagementAPIAdvice. It therefore returned a bare 403, the only one
of the four step-up-gated operations not returning 401 + JSON
{"error":"step-up-required"}, leaving a client unable to tell "re-run your
login ceremony and retry" from an ordinary authorization failure.
Add StepUpEnrollmentAccessDeniedHandler, scoped to POST /webauthn/register
via defaultAccessDeniedHandlerFor, that renders an AuthorizationDeniedException
as 401 step-up-required and delegates everything else (e.g. CSRF) to the
default handler. Scoping leaves the existing MFA missing-authority handler
in place for every other path. The enforcement mechanism (the authorization
manager) is unchanged; only the denial response is.
Updates WebAuthnEnrollmentGateIntegrationTest to assert the 401 +
step-up-required contract and to prove the fresh-factor case is not
step-up-denied.
Claude-Session: https://claude.ai/code/session_01KL5qvKVGQLLQDjHToy34vj
|
Review Reviewed the diff, the touched files in full context (WebSecurityConfig, FreshFactorAuthorizationManager, WebAuthnStepUpRequiredException, GenericResponse, the mail template/message bundle, WebAuthnManagementAPI), and the new/updated tests. Overall this is a well-reasoned, low-risk follow-up: each fix traces back to a concrete failure scenario, and the severity downgrades in the description hold up against the code. Verified correct
Minor suggestions (non-blocking)
Neither of these blocks merging - they are gaps in an otherwise solid test suite for the actual bugs being fixed. No security, correctness, or performance issues found beyond what is already addressed by this PR. |
Follow-up from a five-agent review of #371. The step-up enrollment denial handler was not actually scoped to POST /webauthn/register, plus smaller hardening the review surfaced. - Scope the step-up denial handler correctly. A lone defaultAccessDeniedHandlerFor(handler, matcher) does not scope in Spring Security 7.1: with a single mapping and no explicit accessDeniedHandler, ExceptionHandlingConfigurer.createDefaultAccessDeniedHandler returns the handler directly and discards the matcher, making the step-up handler the app-wide default (every AuthorizationDeniedException, including the DELETE /webauthn/register/** denyAll and deny-mode anyRequest, returned 401 step-up-required); and when MFA installs its own handler the mapping is ignored outright, making the feature a no-op. setupMfa now returns its DelegatingMissingAuthorityAccessDeniedHandler instead of installing it, and the access-denied handler is built once as a RequestMatcherDelegatingAccessDeniedHandler: enrollment POST -> step-up handler, everything else -> the MFA handler when configured, else 403. - Guard the audit publish in WebAuthnCredentialRegistrationListener. It is the path the method comment calls "unconditional," but after AFTER_COMMIT an exception there is swallowed by Spring's adapter with no trace. Wrap it in the same try/catch + log the mail path already has. - StepUpEnrollmentAccessDeniedHandler: enforce the non-null delegate with @nonnull (was a deferred NPE), skip writing when the response is already committed, and document that the AuthorizationDeniedException-is-step-up classification holds only while the freshness gate is the sole authz rule on the path. Tests: - New StepUpEnrollmentAccessDeniedHandlerTest covers both branches (401 step-up JSON for AuthorizationDeniedException, delegate for other denials), the committed-response guard, and null-delegate rejection. - WebAuthnEnrollmentGateIntegrationTest adds two regression guards through the real filter chain: a denied DELETE /webauthn/register/** stays a bare 403 (not step-up), and a CSRF failure on the enrollment path stays 403. These fail against the old unscoped wiring. - Full suite: 1264 tests, 0 failures. Claude-Session: https://claude.ai/code/session_018iUyT6zqNZZpAdmzkXNwLW
ReviewWent through the full diff plus the surrounding call sites ( Fix 1 — HTML-escape the passkey labelConfirmed the vulnerability was real: Fix 2 —
|
…-required in 5.3.4 (#373) #371 changed the stale/factorless-session denial on POST /webauthn/register from a bare HTTP 403 (the filter-chain authorization rule's default) to HTTP 401 with a JSON body carrying error code "step-up-required", matching the passkey delete/rename endpoints so a client can interpret it. That is a consumer-facing response-contract change with no migration note; a client that branched on the 403 for this endpoint (as the demo app's webauthn-register.js did) silently falls back to a generic error. Add a 5.3.3 -> 5.3.4 callout to the enrollment-gating section directing such clients to also treat a 401 with error code step-up-required as the stale-session case. Surfaced by the pre-release demo-app integration test (chromium-step-up E2E). Claude-Session: https://claude.ai/code/session_01KL5qvKVGQLLQDjHToy34vj
Post-release review follow-up on the 5.3.3 WebAuthn step-up + credential-registration audit work (#365, #367). A second adversarial pass (a fresh review plus an independent Fable agent working the threat model from code) revised the review's severities down and left a few small, low-risk fixes worth shipping.
Changes
1. Escape the passkey label in the notification email (was reported High, actually Medium).
webauthn-credential-registered.htmlrenders the client-supplied credentiallabelwithth:utext(unescaped, so the message's own<strong>markup survives). The label flows unsanitized from the WebAuthn registration record, is capped only by the 64-char DB column, and the email is on by default. A crafted label could inject an HTML link into the very email that warns the owner of an unrecognized passkey.UserEmailService.sendPasskeyRegisteredNotificationnowHtmlUtils.htmlEscapes the label before it reaches the template. The separate audit-log path is already sanitized byFileAuditLogWriter.sanitizeField, so it is unchanged.2. Defer the registration listener to
AFTER_COMMIT(was reported High, actually Low; fixes a distinct phantom-notification bug).WebAuthnCredentialRegisteredEventis published inside the@Transactional save()before commit, and the listener was a synchronousApplicationListener. A commit failure (e.g. a label longer than the 64-char column) therefore sent a "new passkey" email and wrote an audit entry for a registration that never persisted. The listener is now@TransactionalEventListener(phase = AFTER_COMMIT, fallbackExecution = true).3. Return
401+step-up-requiredJSON on passkey enrollment denial (was reported Medium consistency gap).The enrollment gate is a filter-chain
authorizeHttpRequestsrule, so its denial is raised before any controller and never reachesWebAuthnManagementAPIAdvice. It returned a bare 403, the only one of the four step-up-gated operations not returning401+{"error":"step-up-required"}, leaving a client unable to tell "re-run your login ceremony and retry" from an ordinary authorization failure. NewStepUpEnrollmentAccessDeniedHandler, scoped toPOST /webauthn/registerviadefaultAccessDeniedHandlerFor, renders anAuthorizationDeniedExceptionas the 401 step-up contract and delegates everything else (e.g. CSRF) to the default handler. The scoping leaves the existing MFA missing-authority handler in place for every other path. Enforcement (the authorization manager) is unchanged; only the denial response is.4. Clarify
CONFIG.mdon customStepUpServiceprecedence (Medium doc-vs-code).The old text claimed a custom bean applies "whatever
enabledis set to." That is true forsetPassword(keyed on bean presence, the original 5.3.1 SPI surface) but not for the passkey delete/rename/remove-password endpoints, which key onuser.security.stepUp.enabled=trueby design (WebAuthnManagementAPI:274-277) so 5.3.1 SPI adopters don't get step-up newly enforced on upgrade. Doc now states the per-endpoint behavior. No code change: the divergence is deliberate.Not changed (deliberately)
The review's headline "High" (enrollment gate accepts any factor) was downgraded to Low after tracing the code: for password accounts the credential-altering ops are gated by the current-password path, not step-up, so an attacker holding the password already owns the account; for passwordless passkey-only accounts the only mintable factor is WEBAUTHN. The one genuine net-new gap (passwordless OAuth+passkey within the 600s enrollment window) is the residual already documented in
CONFIG.md, is audited and emailed, and the proposed "require configured factors" fix is both bypassable in the headline scenario and regresses legitimate second-device enrollment. Left as designed.Tests
UserEmailServiceTestcases: label is HTML-escaped, null label falls back to "Passkey", no-op when the recipient has no email.WebAuthnCredentialRegistrationListenerTestguard pinning theAFTER_COMMITphase andfallbackExecution=true.WebAuthnEnrollmentGateIntegrationTestupdated to assert the401+step-up-requiredcontract through the real filter chain, and that the fresh-factor case is not step-up-denied../gradlew testsuite passes locally, including the MFA missing-authority redirect tests that share the access-denied handler.https://claude.ai/code/session_01KL5qvKVGQLLQDjHToy34vj