security: fixes for SUF-01, SUF-03, SUF-04 and SUF-06 (docs)#333
Conversation
…F-03) The ordinary self-service account deletion/disable flow only logged out the caller's current request. Other active sessions kept carrying the cached DSUserDetails (authorities and the enabled flag are captured at login and not re-checked per request), so a concurrent session stayed authorized until natural expiry. Both the default soft-delete and hard-delete were affected. deleteOrDisableUser() now calls SessionInvalidationService.invalidateUserSessions(user) at the service layer, so every caller is covered (not just UserAPI.deleteAccount), mirroring the existing GDPR deletion path (GdprAPI.logoutUser). Adds two service-level tests proving all sessions are revoked for both deletion modes. Refs #329
…ge (SUF-04) /user/updatePassword verified the current password with a bare passwordEncoder.matches() and was not wired into the brute-force lockout used elsewhere: it did not check lock state, did not record failed guesses, and did not reset the counter on success. A session-holding actor could therefore make unlimited current-password guesses without ever tripping the configured lockout. updatePassword now mirrors WebAuthnManagementAPI.requireCurrentPasswordIfSet: - reject a locked account up front with HTTP 423 (LOCKED) before hashing - record each wrong old password via LoginAttemptService.loginFailed() - clear the counter via loginAttemptService.loginSucceeded() on success Adds the message.update-password.account-locked bundle key and three unit tests (failed-attempt recorded, counter reset on success, locked account rejected with 423 without touching the password). Refs #330
CONFIG.md claimed user.audit.maxFileSizeMb "Defaults to 10" and that "Rotation is enabled by default", contradicting the actual shipped default of 0 (rotation disabled) in dsspringuserconfig.properties and AuditConfig. This misled operators into believing a disk-growth safety net was active when it was not. Correct the entry to state the real default (0/disabled, active file grows unbounded) and explain why rotation is deliberately opt-in: audit queries (GDPR export/investigations) read only the active file, so rotated archives are excluded from those results. Points operators concerned about unbounded growth to enable rotation alongside external retention or a database-backed sink. Note: the remaining SUF-06 code hardening (the anonymous changePassword GET publishing an audit event before the token-validity check and minting a session per request, plus rate limiting) is left open on #332. Refs #332
…nks (SUF-01) AppUrlResolver validated X-Forwarded-Host against user.security.trustedHosts but trusted request.getServerName() (derived from the Host header on common servlet containers) unconditionally. With appUrl and trustedHosts both empty by default, a spoofed Host header could poison password-reset and verification links, leaking the bearer token (CWE-640). Non-breaking hardening: - When trustedHosts is configured, the ordinary server name must also be in the allow-list; a non-allow-listed host now falls back to the first (canonical) trusted host instead of being emitted, with the request port reset to the scheme default so an internal port cannot leak. - Blank entries in trustedHosts are filtered so an empty property does not bind as a bogus single-entry allow-list. - When neither appUrl nor trustedHosts is set, behavior is unchanged but a startup warning is logged. Opt-in strict mode: - New user.security.requireCanonicalAppUrl (default false) fails startup unless appUrl or a non-empty trustedHosts is configured. Documented as becoming the default in the next major version. Updates the AppUrlResolver test that encoded the pre-fix behavior (falling back to the raw server name), and adds resolver + config unit tests. Documents the settings in CONFIG.md and dsspringuserconfig.properties. Refs #327
ReviewSolid, well-scoped security PR — three targeted fixes (host-header link poisoning, session revocation on delete/disable, lockout on password-change) plus a docs correction, each with dedicated tests. The SUF-01 —
|
| if (!trustedHosts.isEmpty() && (host == null || !trustedHosts.contains(host.toLowerCase(Locale.ROOT)))) { | ||
| String canonical = trustedHosts.get(0); | ||
| log.warn("AppUrlResolver: request host '{}' is not in user.security.trustedHosts; using canonical trusted host '{}' for the email link", | ||
| sanitizeForLog(host), canonical); |
There was a problem hiding this comment.
Pull request overview
This PR addresses several security-assessment findings by hardening (1) how security email links derive their base URL (mitigating Host/X-Forwarded-Host poisoning), (2) session revocation behavior when accounts are deleted/disabled, and (3) brute-force lockout integration for authenticated password changes. It also corrects audit-rotation documentation and adds configuration/docs for an opt-in “fail fast” mode around canonical email-link authority.
Changes:
- Harden
AppUrlResolverso that whenuser.security.trustedHostsis configured, bothX-Forwarded-Hostand the ordinary request host must be allow-listed; otherwise fall back to the first trusted host and reset the port to scheme default. - Invalidate all sessions when a user is deleted or disabled via
UserService.deleteOrDisableUser(). - Wire
/user/updatePasswordinto account lockout (423 Lockedwhen locked; failed guesses recorded; successful re-auth clears the counter) and add corresponding messages/docs/tests.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| src/main/java/com/digitalsanctuary/spring/user/util/AppUrlResolver.java | Enforces trusted-host allowlist on non-forwarded host and falls back to canonical trusted host to prevent Host-header link poisoning. |
| src/test/java/com/digitalsanctuary/spring/user/util/AppUrlResolverTest.java | Adds regression tests covering canonical fallback and port non-leak behavior when hosts aren’t allow-listed. |
| src/main/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfiguration.java | Adds opt-in strict startup mode (requireCanonicalAppUrl) and warning when neither appUrl nor trustedHosts is configured. |
| src/test/java/com/digitalsanctuary/spring/user/security/UserSecurityBeansAutoConfigurationTest.java | Unit tests for strict vs non-strict startup behavior around canonical URL configuration. |
| src/main/java/com/digitalsanctuary/spring/user/service/UserService.java | Revokes all user sessions at the service layer during delete/disable to prevent stale authenticated sessions. |
| src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java | Verifies session invalidation is invoked for both hard-delete and soft-disable paths. |
| src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java | Integrates password update into lockout semantics (lock check, failed attempt recording, success reset). |
| src/test/java/com/digitalsanctuary/spring/user/api/UserAPIUnitTest.java | Adds unit tests asserting lockout behavior for update-password (failed, success, locked). |
| src/main/resources/messages/dsspringusermessages.properties | Adds a localized message for locked-account password update attempts. |
| src/main/resources/config/dsspringuserconfig.properties | Documents trustedHosts behavior change and introduces requireCanonicalAppUrl defaulted to false. |
| CONFIG.md | Corrects audit rotation defaults and documents canonical email-link authority configuration options. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…rd (SUF-04) checkIfValidOldPassword() always returns false when no password is set, so the SUF-04 lockout integration let any authenticated (or session-hijacking) caller lock a passwordless (passkey-only / OAuth-only) account out of every auth method by repeatedly POSTing /user/updatePassword. Guard passwordless accounts up front - before the lockout check and without touching the failed-attempt counter - returning 400 and directing the user to the set-password flow, mirroring WebAuthnManagementAPI.requireCurrentPasswordIfSet and setPassword(). Adds a unit test proving the counter is never fed for a passwordless account and an end-to-end UserApiTest that drives real lockout wiring (atomic increment, threshold, isLocked enforcement) through the full Spring context.
…sable commits (SUF-03) deleteOrDisableUser() revoked sessions as its first statement, before the delete/disable committed. A login landing between the session scan and the commit authenticated against the still-present/enabled row and registered a new session the scan had already passed; because DSUserDetails caches the enabled flag at login, that session survived the delete/disable - reopening the bypass SUF-03 closed. Move revocation into an after-commit hook (new shared runAfterCommit helper, also used by publishEventAfterCommit) so it runs only once the change is durable, and not at all if the transaction rolls back. The only residual gap is SessionRegistry's own documented getAllPrincipals()/expireNow() window. Updates the after-commit tests to assert sessions are not revoked before commit and are revoked after.
…ppUrl binding (SUF-01) Fills coverage gaps in the existing SUF-01 defenses: - AppUrlResolver: blank/whitespace trustedHosts filtering at the resolver level, and the ordinary server-name guard for case-insensitive and IPv6-literal hosts (previously only the X-Forwarded-Host branch was covered). - CoreBeanOverrideTest: exercises appUrlResolver() through real @value binding via ApplicationContextRunner - requireCanonicalAppUrl fail-fast, appUrl/trustedHosts start paths, @ConditionalOnMissingBean override. - UserSecurityBeansAutoConfigurationTest: the strict-mode start tests now assert the resolver actually uses the configured appUrl/trustedHosts, not just non-null. Test-only; no production changes.
ReviewWent through the diff in detail ( SUF-01 (
SUF-03 (
SUF-04 (
SUF-06 (docs)
Test coverage: comprehensive — IPv6 host handling, case-insensitive matching, blank-entry filtering, port-leak prevention, and both the unit-mock and full-Spring-context angles for the lockout change. No gaps I could find for the stated scope. Not addressed (per PR description, tracked separately): SUF-02, SUF-05, and SUF-06's code-hardening items remain open on their own issues — reasonable to ship the four fixes here independently rather than block on the rest. Nice work — the "test-first, red→green" framing shows in how tightly each test maps to the specific attacker scenario being closed. |
…Url default Add a CHANGELOG [Unreleased] section for the SUF security-hardening series and correct the surrounding docs: - CHANGELOG: new [Unreleased] entry for the ordinary-host allow-list guard plus the requireCanonicalAppUrl opt-in (SUF-01), session revocation deferred to after commit (SUF-03), and updatePassword lockout + passwordless rejection (SUF-04). Also removed a stranded duplicate [Unreleased] block whose content is already under [4.3.2] (a release-time changelog-duplication artifact). - MIGRATION: /user/updatePassword is no longer described as unchanged (it now enforces lockout and rejects passwordless accounts with 400); documented the requireCanonicalAppUrl opt-in and corrected the trusted-host fallback text. - CONFIG: corrected the account-lockout property prefixes (spring.security.* -> user.security.*) and noted that /user/updatePassword participates in lockout. requireCanonicalAppUrl default stays false in this minor release; documented as planned to become the default in a future major version.
ReviewSolid, well-scoped security PR — each fix is test-first, mirrors existing conventions in the codebase (e.g. SUF-01 —
|
Summary
Addresses four findings from the security assessment of
main. Each fix is test-first (red→green); the full suite is green (962 tests, 0 failures) and./gradlew checkpasses.Sanitized per public-repo disclosure hygiene — defect + fix, no exploit recipes (see the linked issues).
Fixes
SUF-01 — Host-header link poisoning (Security: password-reset/verification links can derive authority from an untrusted Host header (SUF-01) #327).
AppUrlResolvervalidatedX-Forwarded-Hostagainstuser.security.trustedHostsbut trusted the ordinaryrequest.getServerName()(Host header) unconditionally. Now, whentrustedHostsis configured, the ordinary host must also be allow-listed; a non-allow-listed host falls back to the first (canonical) trusted host with the port reset to the scheme default. Adds an opt-inuser.security.requireCanonicalAppUrl(defaultfalse, planned to become the default next major) that fails startup unlessappUrlortrustedHostsis set; otherwise a startup warning is logged. Non-breaking.SUF-03 — Sessions not revoked on account delete/disable (Security: account deletion/disable does not revoke the user's other active sessions (SUF-03) #329).
UserService.deleteOrDisableUser()now callsSessionInvalidationService.invalidateUserSessions(user)at the service layer (covering every caller, both soft-disable and hard-delete), mirroring the GDPR deletion path.SUF-04 — Password change bypassed lockout (Security: authenticated password change is not wired into account lockout / rate limiting (SUF-04) #330).
/user/updatePasswordnow participates in the same brute-force lockout as login/WebAuthn: locked accounts are rejected with HTTP 423, wrong current-password guesses callLoginAttemptService.loginFailed(), and success clears the counter.SUF-06 (docs only) — Audit rotation default (Security/docs: audit log rotation disabled by default; CONFIG.md contradicts the actual default (SUF-06) #332). Corrected
CONFIG.md, which claimed rotation defaults to 10 MB / enabled; the real default is0(disabled), deliberately opt-in because rotated archives are excluded from GDPR/audit queries.Not included (tracked on their issues)
setPasswordstep-up needs a WebAuthn step-up primitive (feature work; being planned separately).303.Validation
./gradlew check— green (962 tests, 0 failures/errors/skips).Closes #327
Closes #329
Closes #330
Refs #332