security: remaining SUF fixes (SUF-02 step-up, SUF-05 reset token, SUF-06 hardening)#334
Conversation
SUF-05 (CWE-598): the reset flow carries the token in the page URL. Add a scoped interceptor that sets Referrer-Policy: no-referrer and Cache-Control: no-store on the configured reset URIs so the token is not leaked via the Referer header or written to caches. Non-breaking: the token-in-body savePassword contract is unchanged; residual address-bar/history exposure remains (documented). SUF-06 (code hardening): showChangePasswordPage no longer mints an HttpSession for the anonymous token-validation request (session amplification), and audits an invalid token as Failure rather than Success. Audit rotation intentionally stays disabled by default (rotated archives are excluded from GDPR/audit queries).
…d (SUF-02) POST /user/setPassword adds a durable password to a passwordless (passkey-only) account with no current credential to verify, so a session-only actor could add one. Introduce a StepUpService SPI: when a consumer provides the bean it is required (failure -> HTTP 401); when none is present the endpoint is disabled by default (HTTP 403). user.security.allowInitialPasswordSetWithoutStepUp=true restores the prior session-only behavior. This is the interim guard; the full WebAuthn step-up primitive is tracked separately. Behavior change: setPassword is now default-disabled (see MIGRATION).
…ault change Extend the [Unreleased] CHANGELOG with SUF-02 (setPassword step-up / default disabled), SUF-05 (reset-page Referrer-Policy/Cache-Control), and SUF-06 code hardening; flag the setPassword default-disabled behavior change prominently. MIGRATION: document the StepUpService SPI, the setPassword default-disabled behavior, and the allowInitialPasswordSetWithoutStepUp opt-in (with an action- required note). CONFIG: document the StepUpService SPI + the opt-in property.
There was a problem hiding this comment.
Pull request overview
This PR completes the remaining SUF security hardening items (SUF-02/05/06) by introducing a step-up SPI for initial password creation on passwordless accounts, adding reset-flow privacy/no-cache headers, and hardening anonymous reset-token validation to avoid session minting and to audit invalid tokens as failures.
Changes:
- SUF-02: Add
StepUpServiceSPI and gatePOST /user/setPassword(disabled-by-default unless a step-up bean is present or an explicit opt-in property is set). - SUF-05: Add a scoped interceptor that sets
Referrer-Policy: no-referrerandCache-Control: no-storeon reset-related URIs. - SUF-06: Avoid creating an
HttpSessionduring anonymous reset-token validation and mark invalid-token audits asFailure; add/adjust tests and documentation.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/test/java/com/digitalsanctuary/spring/user/controller/UserActionControllerTest.java | Adds tests for anonymous-session avoidance, audit-status labeling, and reset privacy headers. |
| src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java | Adds unit coverage for the new /user/setPassword step-up/disabled-by-default behavior. |
| src/main/resources/messages/dsspringusermessages.properties | Adds i18n messages for step-up-required and disabled-by-default setPassword responses. |
| src/main/java/com/digitalsanctuary/spring/user/web/WebInterceptorConfig.java | Registers the reset-flow security-headers interceptor on the configured reset URIs. |
| src/main/java/com/digitalsanctuary/spring/user/web/PasswordResetSecurityHeadersInterceptor.java | Implements the Referrer-Policy and Cache-Control headers for reset pages. |
| src/main/java/com/digitalsanctuary/spring/user/security/StepUpService.java | Introduces the step-up SPI used to protect initial password creation on passwordless accounts. |
| src/main/java/com/digitalsanctuary/spring/user/controller/UserActionController.java | Stops minting sessions for anonymous token validation and audits invalid tokens as failures. |
| src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java | Enforces step-up/disabled-by-default gating for POST /user/setPassword (SUF-02). |
| MIGRATION.md | Documents the setPassword behavior change, step-up SPI, and the opt-in compatibility flag. |
| CONFIG.md | Documents StepUpService and user.security.allowInitialPasswordSetWithoutStepUp. |
| CHANGELOG.md | Summarizes SUF-02/05/06 changes and explicitly calls out the setPassword default behavior change. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * @param request the current HTTP request, so the implementation can read a step-up assertion/token supplied by the | ||
| * client (headers, body, or a prior challenge stored in the session) |
Review: security/suf-remaining (SUF-02, SUF-05, SUF-06)Overall this is a well-scoped, well-documented set of fixes — good tests-first coverage, clear CHANGELOG/MIGRATION/CONFIG updates, and each change stays within its stated "pragmatic, non-breaking" (or clearly-flagged breaking) scope. A few things worth considering before merge: Security
Test coverage
Code quality
Bugs / correctnessNothing that looks broken — traced through the PerformanceNo concerns — the changes are a couple of conditional branches and a stateless header-writing interceptor on low-traffic auth endpoints. Nice work overall — the docs (CHANGELOG/CONFIG/MIGRATION) are thorough and the behavior-change callout is prominent and actionable. |
…ion flakiness Tests run with concurrent methods+classes and random order (junit-platform.properties). The _notAuthenticated tests resolve @AuthenticationPrincipal from the thread-local SecurityContext and expect it empty; under thread reuse a context set by another concurrently-running test could leak onto the thread, resolving a non-null principal and flipping the expected 200 to 400 (seen intermittently on CI, not locally). Clear the context in @beforeeach and @AfterEach, matching the existing convention in UserServiceTest / WebAuthnAuthenticationSuccessHandlerTest / UserEmailServiceTest. Test-only; no production change.
Review: SUF-02 / SUF-05 / SUF-06 hardeningSolid, well-scoped PR — the docs (CHANGELOG/CONFIG/MIGRATION) are thorough and the trade-offs are clearly called out. A few things worth a look before merge. Potential bugs / design issues
Test coverage
Minor / nits
Security
PerformanceNothing notable — the interceptor only sets two headers on two specific configured URIs, and Nice work overall — the fail-closed default for SUF-02 and the careful non-breaking scoping on SUF-05/06 are the right calls. |
Summary
Addresses the remaining findings from the security assessment (SUF-02, SUF-05, SUF-06 code hardening) that were deferred out of #333. Each was scoped to the pragmatic, non-breaking approach; the one deliberate behavior change (SUF-02 disabling
setPasswordby default) is called out below and documented inMIGRATION.md. Full suite green (982 tests, 0 failures),./gradlew checkpasses, and the demo app builds/tests cleanly against the SNAPSHOT (301 tests, 0 failures).Fixes
SUF-02 — Passwordless
setPasswordstep-up (Security: initial password creation on passwordless accounts lacks step-up and session handling (SUF-02) #328). Adding an initial password to a passwordless (passkey-only) account had no proof of presence beyond an authenticated session. New SPIStepUpService: if a consumer provides the bean it is required (failure →HTTP 401); if none is present the endpoint is disabled by default (HTTP 403).user.security.allowInitialPasswordSetWithoutStepUp=truerestores the prior session-only behavior. This is the interim guard — the full WebAuthn step-up primitive is deliberately deferred as separate feature work.SUF-05 — Reset token leaked via Referer/cache (Security: password-reset token retained in the change-password page URL (SUF-05) #331). A scoped interceptor now sets
Referrer-Policy: no-referrerandCache-Control: no-storeon the configured reset URIs, so the token (which appears in the reset page URL) is not sent in theRefererheader or written to caches. Non-breaking: the token-in-bodysavePasswordcontract is unchanged. Residual (accepted): the token still briefly appears in the address bar/history — removing that requires the breaking token→session exchange, which was intentionally not taken.SUF-06 code hardening (Security/docs: audit log rotation disabled by default; CONFIG.md contradicts the actual default (SUF-06) #332).
showChangePasswordPageno longer mints anHttpSessionfor the anonymous token-validation request, and audits an invalid token asFailure(was alwaysSuccess). Intentionally not changed: audit-log rotation stays disabled by default (rotated archives are excluded from GDPR/audit queries — a deliberate tradeoff); rate-limiting anonymous endpoints remains delegated to Bucket4J.POST /user/setPasswordis now disabled by default (SUF-02). It returnsHTTP 403unless aStepUpServicebean is provided (then step-up is required; failures →401), oruser.security.allowInitialPasswordSetWithoutStepUp=trueis set. Consumers whose passwordless users set an initial password must choose one of those options — seeMIGRATION.md.Testing
./gradlew checkgreen). New coverage:setPasswordstep-up guard (disabled-by-default / opt-in flag /StepUpServicegrant+deny), reset-page security headers, and the anonymous-session + audit-status hygiene onshowChangePasswordPage(all test-first, red→green)../gradlew test --refresh-dependenciesresolved5.0.2-SNAPSHOT→ 301 tests, 0 failures (API-breakage guard).setPasswordflow now403s by default, so a full Playwright pass of that flow requires the demo to setallowInitialPasswordSetWithoutStepUp=trueor provide aStepUpService(the documented migration action). Reset-flow E2E (SUF-05/06) is non-breaking.Scope / deferred (tracked)
Refs #328
Refs #331
Refs #332