fix(security): harden RequestCache so browser auto-probes cannot hijack the post-login redirect#343
Conversation
…ck the post-login redirect Spring Security's default request cache saves ANY request that triggers authentication. Browsers (Safari/iOS especially) automatically probe URLs like /apple-touch-icon.png while the login page renders; when such a path is protected, the unauthenticated probe overwrites the saved deep link the user actually clicked, so the post-login redirect lands on /apple-touch-icon.png?continue (usually a 404/error page) instead of the user's destination. Contribute a consumer-overridable RequestCache bean (ConditionalOnMissingBean) that only saves plausible user navigations: GET requests that explicitly accept text/html and are not XHR (X-Requested-With), HTMX (HX-Request), static-asset fetches (common asset extensions), or well-known auto-probed paths (/apple-touch-icon*, /favicon*, /.well-known/). Wire it into buildSecurityFilterChain via http.requestCache(...) - no public signature changes, so consumers delegating to buildSecurityFilterChain are unaffected. Consumers can restore Spring Security default behavior by defining their own RequestCache bean (new HttpSessionRequestCache()).
Review: harden
|
…nown probe paths Complements the hardened RequestCache: browsers and crawlers automatically probe always-public paths (Safari fetches /apple-touch-icon.png and its precomposed/sized variants on every page load; favicons; /.well-known/** for security.txt, ACME, passkey association files). Under defaultAction=deny these otherwise 302 to the login page unless each consumer remembers to whitelist every variant. getUnprotectedURIsList() now auto-adds /apple-touch-icon*, /favicon* and /.well-known/** for every consumer, mirroring the exclusion set in the hardened RequestCache so probes neither hijack the post-login redirect nor bounce through login. Follows the existing precedent in the same method that auto-unprotects the MFA entry-point URIs. Tested via WebSecurityAuthorizationDenyTest.AutoUnprotectedProbePaths: these paths are absent from application-test.yml's unprotectedURIs, so a 404 (reached the dispatcher) rather than a 302 (login redirect) proves the auto-unprotect applied.
|
Review: harden RequestCache against browser auto-probes Nice root-cause fix — hardening the Code quality
Minor/edge-case observations (not blockers)
Correctness
Test coverage
Security
Overall: solid, well-tested, well-documented fix. The findings above are minor style/nit items — nothing blocking. |
…sumer overrides work end-to-end Addresses code-review finding M1. LoginSuccessService extends SavedRequestAwareAuthenticationSuccessHandler, which holds its own private default HttpSessionRequestCache and was never handed the injected RequestCache bean. The hardened cache worked only because both sides happened to be an HttpSessionRequestCache on the same session attribute: the filter chain (save side) used the bean while the success handler (read side) used its own default. A consumer who overrode the RequestCache bean with a different implementation or session attribute -- exactly what requestCache()'s @ConditionalOnMissingBean Javadoc advertises -- would have their request saved to one store and read from another, silently breaking the post-login redirect to the originally-requested page. Inject the effective RequestCache into LoginSuccessService and pass it to super.setRequestCache(...) so save and read share one instance and the override contract holds end-to-end. Also from review: add a no-Accept-header RequestCache test (the one matcher branch not previously exercised), document the fail-open URL-encoding assumption in isStaticAssetOrAutoProbe, and tidy import grouping. Full suite: 1005 tests, 0 failures.
Addressed code-review findings (self-review pass)M1 (Medium) — fixed. N5/N6/N7 — addressed. Added a no-Accept-header L2 / L3 — intentionally kept, documented as behavior trade-offs. Protected Full suite after changes: 1005 tests, 0 failures. |
Review: harden RequestCache against browser auto-probe hijackingNice root-cause fix — the Code quality / conventions
Design considerations
Low-severity / informational
Security
Overall this looks correct, well-tested, and backwards compatible (no public signature changes, fully overridable via bean definition). The items above are minor — mostly documentation/style — rather than blockers. |
…paths (least privilege) Follow-up to code-review finding N4. The auto-unprotect widens the permitAll surface for every consumer, who never opted in and won't audit the framework's internals, so the patterns should match exactly the auto-probed paths rather than a broad prefix: - /apple-touch-icon* -> /apple-touch-icon*.png (every Safari probe ends in .png; keeps a route like /apple-touch-icon-admin protected) - /favicon* -> /favicon.* (the browser auto-probe is /favicon.ico; the dot anchor keeps a route like /favicon-report protected; sized favicons are <link>-referenced static assets, not auto-probes) - /.well-known/** unchanged (RFC 8615 public by design) This also aligns the framework with the tighter /apple-touch-icon*.png pattern the MagicMenu consumer app already uses. The save-side RequestCache heuristic stays intentionally broader: misclassifying there merely fails open (a request is not saved), never grants access. Adds negative tests proving /apple-touch-icon-admin and /favicon-report still require authentication under deny. Full suite: 1007 tests, 0 failures.
Additional quality pass — least-privilege on the auto-unprotect (N4)Since the auto-unprotect widens the
Added negative tests asserting Deliberately left as-is (correct trade-offs, documented for release notes)
Full suite: 1007 tests, 0 failures. |
ReviewNice, well-scoped fix for a genuinely nasty bug — a browser-native probe silently hijacking the post-login redirect is the kind of thing that's very hard to catch without production logs, and the reproduction/root-cause writeup in the PR description is excellent. What it does
Strengths
Minor issues / nitsImport ordering (
Duplicated HTMX header constant —
Test naming — the Optional: docs — Security
VerdictSolid, well-tested fix with a good root-cause writeup. Only nits above (import order, a small duplication opportunity, and a naming nit) — nothing blocking. |
|
Review: harden RequestCache against browser auto-probe hijacking Solid, well-targeted fix for a real production bug, with a clear root-cause explanation and good test coverage. A few notes below, nothing blocking. Strengths
Issues
Design notes (not defects, just worth flagging for awareness)
Nice fix overall -- the mechanism write-up and regression tests give good confidence this addresses the reported production issue. |
…el execution The suite runs tests in parallel (junit-platform.properties), and UserAPI.warnIfInitialPasswordSetDisabled() is a @PostConstruct hook, so every other test that boots a Spring context (flag false, no StepUpService bean) fires the same 'setPassword is disabled by default' WARN on its own thread into this test's shared-logger ListAppender. The exact-count assertions (isEqualTo(1) / isZero()) therefore failed intermittently in CI when a context boot overlapped the test window (observed: 1007 tests, 1 failed at UserAPIUnitTest.java:816, on a docs-only commit). Count only warnings emitted on the current test thread (the test invokes the method synchronously) so concurrently-booting contexts on other threads cannot pollute the count, and snapshot the appender list to avoid a ConcurrentModificationException during iteration. Deterministic and unrelated to the RequestCache change; fixed here to keep the release build reliable. Full suite: 1007 tests, 0 failures.
|
Review: harden RequestCache against browser auto-probe hijacking Nice root-cause fix, going after the RequestCache matcher instead of just special-casing /apple-touch-icon.png is the right call, and the writeup/tests make the mechanism easy to verify. A few notes below, nothing blocking. What it does
Code quality / style
Potential subtlety (not a bug, but worth a second look)
Test coverage
Security
Other
Overall this is a well-scoped, well-tested fix for a real production bug. The import-order nit is quick to fix; the prefix-matching subtlety is minor and optional. |
Problem
Clicking a deep link to a protected page while logged out (e.g. an email link) runs the user through login, then redirects to an error page at
/apple-touch-icon.png?continue#instead of the original URL. Reproduced in production on two consuming apps (MagicMenu PRD logs, 2026-07-23).Mechanism: Spring Security's default
RequestCachesaves any request that triggers authentication. Safari/iOS automatically probes/apple-touch-icon.png(and-precomposed) at the site root while rendering any page — including the login page. When that path is protected (the common case underdefaultAction=denyunless every icon variant is whitelisted), the unauthenticated probe overwrites the saved request for the page the user actually clicked.SavedRequestAwareAuthenticationSuccessHandlerthen redirects to the icon URL after login. Spring Security's built-in matcher only excludesfavicon.*, XHR, andapplication/json— touch icons, HTMX partials, and other asset fetches all slip through.Fix
Two complementary changes:
1. Hardened, consumer-overridable
RequestCache(the root-cause fix)RequestCachebean inUserSecurityBeansAutoConfiguration(@ConditionalOnMissingBean(RequestCache.class), same pattern as the other security beans): anHttpSessionRequestCachewhose matcher only saves plausible user navigations —GETrequeststext/html(Accept: */*is ignored — real navigations always listtext/html)X-Requested-With: XMLHttpRequest), HTMX (HX-Request), static-asset fetches (png/ico/css/js/fonts/etc.), or well-known auto-probed paths (/apple-touch-icon*,/favicon*,/.well-known/)buildSecurityFilterChainviahttp.requestCache(...). No public signature changes — consumers delegating tobuildSecurityFilterChain(HttpSecurity, SessionRegistry)are unaffected.RequestCachebean.2. Auto-unprotect the always-public probe paths (convenience/cleanliness)
getUnprotectedURIsList()now auto-adds/apple-touch-icon*,/favicon*, and/.well-known/**for every consumer, mirroring the RequestCache exclusion set. Without this, underdefaultAction=denythese probes 302 to login unless each app remembers to whitelist every variant. Follows the existing precedent in the same method that auto-unprotects the MFA entry-point URIs.Tests
RequestCacheHardeningTest(7 tests), written red-first against the unfixed behavior: real navigation still saved; icon probe on the same session no longer overwrites it (the reported bug); icon paths never saved regardless of Accept header; non-HTML / HTMX / XHR / POST never saved.WebSecurityAuthorizationDenyTest.AutoUnprotectedProbePaths(5 tests): these paths are absent fromapplication-test.yml'sunprotectedURIs, so a 404 (reached the dispatcher) rather than a 302 (login redirect) proves the auto-unprotect applied.Consuming-app companions