From 81698d725d85c27758c61d802e8d19ea73c58cea Mon Sep 17 00:00:00 2001 From: DariusIII Date: Tue, 10 Mar 2026 15:03:03 +0100 Subject: [PATCH] Fix caching for auth pages --- .../NoCacheForAuthenticatedUsers.php | 81 ++++++++++-------- .../NoCacheForAuthenticatedUsersTest.php | 82 +++++++++++++++++++ 2 files changed, 131 insertions(+), 32 deletions(-) create mode 100644 tests/Unit/Http/Middleware/NoCacheForAuthenticatedUsersTest.php diff --git a/app/Http/Middleware/NoCacheForAuthenticatedUsers.php b/app/Http/Middleware/NoCacheForAuthenticatedUsers.php index cbdc7f04e..1982e0260 100644 --- a/app/Http/Middleware/NoCacheForAuthenticatedUsers.php +++ b/app/Http/Middleware/NoCacheForAuthenticatedUsers.php @@ -10,12 +10,12 @@ use Illuminate\Support\Facades\Auth; use Symfony\Component\HttpFoundation\Response; /** - * Middleware to prevent caching of authenticated user pages. + * Middleware to prevent caching of authenticated and auth-flow pages. * * This prevents issues where: * - CDNs (like Cloudflare) cache user-specific pages - * - Reverse proxies cache authenticated pages showing wrong user data - * - Browsers cache authenticated pages + * - Flash messages on login/register flows are served from stale HTML + * - Browsers reuse cached auth forms after redirects or CAPTCHA validation * - One user could see another user's data due to cache poisoning */ class NoCacheForAuthenticatedUsers @@ -27,37 +27,54 @@ class NoCacheForAuthenticatedUsers { $response = $next($request); - // For authenticated users, add headers to prevent caching of personal data - if (Auth::check()) { - // Prevent any caching of authenticated user responses - // 'private' tells CDNs like Cloudflare not to cache this response - $response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate, private, max-age=0, s-maxage=0'); - $response->headers->set('Pragma', 'no-cache'); - $response->headers->set('Expires', 'Thu, 01 Jan 1970 00:00:00 GMT'); - - // Cloudflare-specific: Tell Cloudflare to bypass cache for this response - // CDN-Cache-Control is respected by Cloudflare and other CDNs - $response->headers->set('CDN-Cache-Control', 'no-store'); - - // Cloudflare also respects this header - $response->headers->set('Cloudflare-CDN-Cache-Control', 'no-store'); - - // Add Vary header to ensure caches differentiate by user session - $existingVary = $response->headers->get('Vary', ''); - $varyHeaders = array_filter(array_map('trim', explode(',', $existingVary))); - - // Add Cookie to Vary header if not already present - if (! in_array('Cookie', $varyHeaders, true)) { - $varyHeaders[] = 'Cookie'; - } - // Add Authorization to Vary header for API requests - if ($request->bearerToken() && ! in_array('Authorization', $varyHeaders, true)) { - $varyHeaders[] = 'Authorization'; - } - - $response->headers->set('Vary', implode(', ', $varyHeaders)); + if ($this->shouldPreventCaching($request)) { + $this->applyNoCacheHeaders($request, $response); } return $response; } + + /** + * Keep session-backed auth flows out of CDN/browser caches. + */ + private function shouldPreventCaching(Request $request): bool + { + return Auth::check() || $request->routeIs( + 'login', + 'login.post', + 'register', + 'register.post', + 'logout', + 'forgottenpassword', + 'password.request', + '2fa.verify', + '2fa.post', + '2faVerify' + ); + } + + private function applyNoCacheHeaders(Request $request, Response $response): void + { + // 'private' tells shared caches that this response must not be reused. + $response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate, private, max-age=0, s-maxage=0'); + $response->headers->set('Pragma', 'no-cache'); + $response->headers->set('Expires', 'Thu, 01 Jan 1970 00:00:00 GMT'); + + // Cloudflare-specific headers to reinforce cache bypass at the edge. + $response->headers->set('CDN-Cache-Control', 'no-store'); + $response->headers->set('Cloudflare-CDN-Cache-Control', 'no-store'); + + $existingVary = $response->headers->get('Vary', ''); + $varyHeaders = array_filter(array_map('trim', explode(',', $existingVary))); + + if (! in_array('Cookie', $varyHeaders, true)) { + $varyHeaders[] = 'Cookie'; + } + + if ($request->bearerToken() && ! in_array('Authorization', $varyHeaders, true)) { + $varyHeaders[] = 'Authorization'; + } + + $response->headers->set('Vary', implode(', ', $varyHeaders)); + } } diff --git a/tests/Unit/Http/Middleware/NoCacheForAuthenticatedUsersTest.php b/tests/Unit/Http/Middleware/NoCacheForAuthenticatedUsersTest.php new file mode 100644 index 000000000..c0e8bfcf0 --- /dev/null +++ b/tests/Unit/Http/Middleware/NoCacheForAuthenticatedUsersTest.php @@ -0,0 +1,82 @@ +once() + ->andReturn(false); + + $response = $this->handleRequestForRoute('register'); + + $this->assertResponseIsNoStore($response); + } + + public function test_it_does_not_add_no_cache_headers_for_other_guest_routes(): void + { + Auth::shouldReceive('check') + ->once() + ->andReturn(false); + + $response = $this->handleRequestForRoute('home'); + + $this->assertStringNotContainsString('no-store', (string) $response->headers->get('Cache-Control')); + $this->assertNull($response->headers->get('CDN-Cache-Control')); + $this->assertNull($response->headers->get('Cloudflare-CDN-Cache-Control')); + $this->assertNull($response->headers->get('Vary')); + } + + public function test_it_keeps_authenticated_pages_uncached(): void + { + Auth::shouldReceive('check') + ->once() + ->andReturn(true); + + $response = $this->handleRequestForRoute('home'); + + $this->assertResponseIsNoStore($response); + } + + private function handleRequestForRoute(string $routeName): Response + { + $path = $routeName === 'home' ? '/' : '/'.$routeName; + $request = Request::create($path, 'GET'); + + $route = new Route(['GET'], ltrim($path, '/'), static fn (): Response => new Response('OK')); + $route->name($routeName); + $request->setRouteResolver(static fn (): Route => $route); + + return (new NoCacheForAuthenticatedUsers)->handle( + $request, + static fn (): Response => new Response('OK') + ); + } + + private function assertResponseIsNoStore(Response $response): void + { + $cacheControl = (string) $response->headers->get('Cache-Control'); + + $this->assertStringContainsString('no-store', $cacheControl); + $this->assertStringContainsString('must-revalidate', $cacheControl); + $this->assertStringContainsString('private', $cacheControl); + $this->assertStringContainsString('max-age=0', $cacheControl); + $this->assertStringContainsString('s-maxage=0', $cacheControl); + $this->assertSame('no-cache', $response->headers->get('Pragma')); + $this->assertSame('Thu, 01 Jan 1970 00:00:00 GMT', $response->headers->get('Expires')); + $this->assertSame('no-store', $response->headers->get('CDN-Cache-Control')); + $this->assertSame('no-store', $response->headers->get('Cloudflare-CDN-Cache-Control')); + $this->assertStringContainsString('Cookie', (string) $response->headers->get('Vary')); + } +}