diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c456b5..331c056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 restarts). Arbitrary `frankenphp`-block configuration is still possible via the stock `FRANKENPHP_CONFIG` env var. +### Fixed +- **Login flow responses are no longer cacheable** — the login page, + failed logins, redirects, and rate-limit/error pages now send strict + anti-caching headers (`Cache-Control: no-store, no-cache, + must-revalidate, proxy-revalidate, max-age=0, s-maxage=0` plus + `Pragma`, `Expires`, `Surrogate-Control`, and `Vary: *`), the login + form's `fetch()` bypasses the HTTP cache, and the example Caddyfile + guards every `forward_auth` block with matching `header_down` rules. + This prevents browsers — notably older Safari — from replaying a stale + pre-auth response on refresh (previously: log in successfully, refresh, + and land back on the login page). Successful (2xx) responses are + deliberately excluded: they are consumed by the proxy's `forward_auth` + check and never reach the browser. + ## [1.0.0] — v1.0 Release ### Security diff --git a/docs/Caddyfile b/docs/Caddyfile index 92ce41e..2fe0a7a 100644 --- a/docs/Caddyfile +++ b/docs/Caddyfile @@ -1,9 +1,34 @@ +# preauth example Caddyfile + +# --- anti-caching guard for the login flow --- +# The login page, failed logins, redirects, and rate-limit pages must never +# be stored or replayed by a browser or intermediate cache. If they are, +# an aggressive cache (notably older Safari) can resurrect a stale pre-auth +# response — appearing to log a user back out after a refresh. preauth +# sends these headers itself; mirroring them here with `header_down` keeps +# the guarantee at the edge. Import this snippet inside every `forward_auth` +# block: +# +# forward_auth preauth { ...; import preauth_no_store } +# +# Note: 2xx auth responses are consumed by Caddy's forward_auth check and +# never reach the browser, and the protected service's own responses are +# not affected — so the cache headers of your services are left alone. +(preauth_no_store) { + header_down Cache-Control "no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0" + header_down Pragma "no-cache" + header_down Expires "0" + header_down Surrogate-Control "no-store" + header_down Vary "*" +} + # example of securing full service # TODO replace domain and service name and port service.example.com { forward_auth preauth { uri {uri} copy_headers Remote-User + import preauth_no_store } reverse_proxy service-container:80 } @@ -16,6 +41,7 @@ protected.example.com { forward_auth /secure/* preauth { uri {uri} copy_headers Remote-User + import preauth_no_store } reverse_proxy protected-service:9000 } @@ -39,6 +65,7 @@ git.example.com { forward_auth preauth { uri {uri} copy_headers Remote-User + import preauth_no_store } reverse_proxy gitea:3000 } diff --git a/readme.md b/readme.md index 58fa532..b3606cb 100644 --- a/readme.md +++ b/readme.md @@ -70,13 +70,24 @@ service.example.com { forward_auth preauth { uri {uri} copy_headers Remote-User + + # keep the login flow out of browser/proxy caches + header_down Cache-Control "no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0" + header_down Pragma "no-cache" + header_down Expires "0" + header_down Surrogate-Control "no-store" + header_down Vary "*" } reverse_proxy your-service:80 } ``` See `docs/Caddyfile` for more examples, including path-specific protection -and central auth subdomain configuration. +and central auth subdomain configuration. The `header_down` lines above are +optional — preauth already sends these headers itself — but they guarantee +at the edge that no part of the login flow is ever cached. (2xx auth +responses are consumed by `forward_auth` and never reach the browser, so +your service's own cache headers are unaffected.) ### 5. Generate backup codes (optional) @@ -237,6 +248,14 @@ passes through a priority-ordered chain of listeners: - **Rate limiting**: Per-IP, compound sliding window, cannot be disabled - **Security headers**: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS +- **No cacheable login flow**: The login page, failed logins, redirects, + and rate-limit pages are sent with strict anti-caching headers + (`no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0, + s-maxage=0` plus `Pragma`, `Expires`, `Surrogate-Control`, and + `Vary: *`), and the login form's `fetch()` opts out of the HTTP cache. + Successful (2xx) responses are deliberately excluded — they are + consumed by the proxy's `forward_auth` check and never reach the + browser, so a protected service's own caching is not affected. ### Cache diff --git a/src/Listener/SecurityHeadersListener.php b/src/Listener/SecurityHeadersListener.php index b0dd34e..33aa058 100644 --- a/src/Listener/SecurityHeadersListener.php +++ b/src/Listener/SecurityHeadersListener.php @@ -63,5 +63,25 @@ final readonly class SecurityHeadersListener /* HSTS — enforce HTTPS for one year (app is designed for HTTPS behind a proxy) */ $headers->set('Strict-Transport-Security', 'max-age=31536000'); + + /* Prevent any part of the login flow from being cached: the login + * page, failed logins, redirects, and rate-limit/error pages must + * never be stored or replayed by the browser or an intermediate + * cache — older Safari builds in particular may otherwise resurrect + * a stale pre-auth response, appearing to log the user out after a + * refresh or showing a previous session after logging in again. + * + * Only non-2xx responses are touched: the 2xx responses that grant + * access ("already authenticated" or public) are consumed by the + * reverse proxy's forward_auth check before reaching the browser, + * and the protected service's own cache headers must remain + * untouched. */ + if (! $response->isSuccessful()) { + $headers->set('Cache-Control', 'no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0'); + $headers->set('Pragma', 'no-cache'); + $headers->set('Expires', '0'); + $headers->set('Surrogate-Control', 'no-store'); + $headers->set('Vary', '*'); + } } } diff --git a/templates/_script.html.twig b/templates/_script.html.twig index 8c2a4ee..3258172 100644 --- a/templates/_script.html.twig +++ b/templates/_script.html.twig @@ -19,6 +19,8 @@ form.addEventListener('submit', (event) => { fetch(window.location.href, { method: 'GET', headers: { 'X-Preauth': data }, + // never serve this request from, or store it in, the HTTP cache + cache: 'no-store', }).then((response) => { {% if env.debug > 2 -%} console.log(response); @@ -28,7 +30,8 @@ form.addEventListener('submit', (event) => { {% if env.debug > 2 -%} console.log('got redirect response'); {% endif -%} - window.location.href = response.headers.get('Location'); + // replace() keeps the login page out of history and the back-forward cache + window.location.replace(response.headers.get('Location')); } else if (response.headers.get('Content-Type')?.toLowerCase().includes('application/json') ?? false) { {# got json, update the page #} {% if env.debug > 2 -%} diff --git a/tests/Functional/CacheControlFlowTest.php b/tests/Functional/CacheControlFlowTest.php new file mode 100644 index 0000000..ceeb507 --- /dev/null +++ b/tests/Functional/CacheControlFlowTest.php @@ -0,0 +1,188 @@ +disableReboot(); + + return $client; + } + + private function validTotpCode(): string + { + return TOTP::createFromSecret(self::TOTP_SECRET)->now(); + } + + private function encodePayload(array $data): string + { + $json = json_encode($data, JSON_THROW_ON_ERROR); + return rtrim(strtr(base64_encode($json), '+/', '-_'), '='); + } + + private function assertNotCacheable(Response $response): void + { + self::assertTrue($response->headers->hasCacheControlDirective('no-cache')); + self::assertTrue($response->headers->hasCacheControlDirective('no-store')); + self::assertTrue($response->headers->hasCacheControlDirective('must-revalidate')); + self::assertTrue($response->headers->hasCacheControlDirective('proxy-revalidate')); + self::assertSame('0', $response->headers->getCacheControlDirective('max-age')); + self::assertSame('0', $response->headers->getCacheControlDirective('s-maxage')); + self::assertSame('no-cache', $response->headers->get('Pragma')); + self::assertSame('0', $response->headers->get('Expires')); + self::assertSame('no-store', $response->headers->get('Surrogate-Control')); + self::assertSame('*', $response->headers->get('Vary')); + } + + private function assertCacheable(Response $response): void + { + self::assertFalse($response->headers->hasCacheControlDirective('no-store')); + self::assertNull($response->headers->get('Pragma')); + self::assertNull($response->headers->get('Surrogate-Control')); + } + + /* ── login flow: nothing may be cached ────────────────────────────── */ + + public function testLoginPageIsNotCacheable(): void + { + $client = static::createClient(); + $client->request('GET', '/'); + + $response = $client->getResponse(); + self::assertSame(401, $response->getStatusCode()); + $this->assertNotCacheable($response); + } + + public function testLoginPageFetchBypassesHttpCache(): void + { + $client = static::createClient(); + $client->request('GET', '/'); + + $content = $client->getResponse()->getContent(); + // the inline login script must opt out of the HTTP cache and must + // not leave the login page in history / the back-forward cache + self::assertStringContainsString("cache: 'no-store'", $content); + self::assertStringContainsString('window.location.replace(', $content); + } + + public function testFailedLoginIsNotCacheable(): void + { + $client = static::createClient(); + + $crawler = $client->request('GET', '/'); + $nonce = $crawler->filter('input[name="nonce"]')->attr('value'); + + $client->request('GET', '/', [], [], [ + 'HTTP_X-Preauth' => $this->encodePayload([ + 'id' => 'alice', 'token' => '000000', 'nonce' => $nonce, 'json' => true, + ]), + ]); + + $response = $client->getResponse(); + self::assertSame(401, $response->getStatusCode()); + $this->assertNotCacheable($response); + } + + public function testSuccessfulLoginRedirectIsNotCacheable(): void + { + $client = static::createClient(); + + $crawler = $client->request('GET', '/'); + $nonce = $crawler->filter('input[name="nonce"]')->attr('value'); + + $client->request('GET', '/', [], [], [ + 'HTTP_X-Preauth' => $this->encodePayload([ + 'id' => 'alice', 'token' => $this->validTotpCode(), 'nonce' => $nonce, 'json' => true, + ]), + ]); + + $response = $client->getResponse(); + self::assertSame(303, $response->getStatusCode()); + $this->assertNotCacheable($response); + // the redirect target must still be present + self::assertTrue($response->headers->has('Location')); + } + + public function testLoginPageOnAnotherHostIsNotCacheable(): void + { + // the listener applies to every main response, not only the primary + // host; subdomain redirection itself is covered by InterceptListener + // unit tests + $client = static::createClient(); + $client->request('GET', 'https://other.example.com/'); + + $response = $client->getResponse(); + self::assertSame(401, $response->getStatusCode()); + $this->assertNotCacheable($response); + } + + public function testRateLimitedResponseIsNotCacheable(): void + { + $client = static::createClient(); + + // the login limiter is raised for tests, so exercise the public + // limiter instead (test config: PUBLIC_BURST_COUNT=3) + for ($i = 0; $i < 4; $i++) { + $client->request('GET', '/public/repo'); + } + + $response = $client->getResponse(); + self::assertSame(429, $response->getStatusCode()); + $this->assertNotCacheable($response); + } + + /* ── 2xx grants: must stay untouched ──────────────────────────────── */ + + public function testAuthenticatedAccessResponseIsNotModifiedByAntiCachingHeaders(): void + { + $client = static::createClient(); + + // login and keep the cookie + $crawler = $client->request('GET', '/'); + $nonce = $crawler->filter('input[name="nonce"]')->attr('value'); + $client->request('GET', '/', [], [], [ + 'HTTP_X-Preauth' => $this->encodePayload([ + 'id' => 'dave', 'token' => $this->validTotpCode(), 'nonce' => $nonce, 'json' => true, + ]), + ]); + self::assertSame(303, $client->getResponse()->getStatusCode()); + + // subsequent authenticated requests return a 200 "grant" response + $client->request('GET', 'https://localhost/dashboard'); + + $response = $client->getResponse(); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('dave', $response->headers->get('Remote-User')); + // 2xx responses are consumed by forward_auth and never reach the + // browser — they must not carry the login-flow anti-caching headers + $this->assertCacheable($response); + } + + public function testPublicAccessResponseIsNotModifiedByAntiCachingHeaders(): void + { + $client = static::createClient(); + $client->request('GET', '/public/repo'); + + $response = $client->getResponse(); + self::assertSame(200, $response->getStatusCode()); + $this->assertCacheable($response); + } +} diff --git a/tests/Unit/Listener/SecurityHeadersListenerTest.php b/tests/Unit/Listener/SecurityHeadersListenerTest.php new file mode 100644 index 0000000..1175b5e --- /dev/null +++ b/tests/Unit/Listener/SecurityHeadersListenerTest.php @@ -0,0 +1,207 @@ +createStub(DomainInterface::class); + $domainManager->method('getAuthSubdomain')->willReturn($authSubdomain); + + return new SecurityHeadersListener($domainManager); + } + + private function makeEvent( + Response $response, + ?Request $request = null, + int $requestType = HttpKernelInterface::MAIN_REQUEST, + ): ResponseEvent { + return new ResponseEvent( + $this->createStub(HttpKernelInterface::class), + $request ?? Request::create('https://example.com/', 'GET'), + $requestType, + $response, + ); + } + + /** + * The emitted Cache-Control is normalized by Symfony (directives are + * reordered), so assert on directives rather than the exact string. + */ + private function assertNoStoreHeaders(Response $response): void + { + self::assertTrue($response->headers->hasCacheControlDirective('no-cache')); + self::assertTrue($response->headers->hasCacheControlDirective('no-store')); + self::assertTrue($response->headers->hasCacheControlDirective('must-revalidate')); + self::assertTrue($response->headers->hasCacheControlDirective('proxy-revalidate')); + self::assertSame('0', $response->headers->getCacheControlDirective('max-age')); + self::assertSame('0', $response->headers->getCacheControlDirective('s-maxage')); + self::assertSame('no-cache', $response->headers->get('Pragma')); + self::assertSame('0', $response->headers->get('Expires')); + self::assertSame('no-store', $response->headers->get('Surrogate-Control')); + self::assertSame('*', $response->headers->get('Vary')); + } + + private function assertNoAntiCachingHeaders(Response $response): void + { + self::assertFalse($response->headers->hasCacheControlDirective('no-store')); + self::assertNull($response->headers->get('Pragma')); + self::assertNull($response->headers->get('Expires')); + self::assertNull($response->headers->get('Surrogate-Control')); + self::assertNull($response->headers->get('Vary')); + } + + /* ── non-2xx: the login flow must not be cacheable ────────────────── */ + + public function testLoginPageResponseIsNotCacheable(): void + { + $listener = $this->makeListener(); + $response = new Response('
', Response::HTTP_UNAUTHORIZED); + $event = $this->makeEvent($response); + + $listener->onKernelResponse($event); + + $this->assertNoStoreHeaders($response); + } + + public function testRedirectResponseIsNotCacheable(): void + { + $listener = $this->makeListener(); + $response = new Response('', Response::HTTP_SEE_OTHER, [ + 'Location' => 'https://example.com/dashboard', + ]); + $event = $this->makeEvent($response); + + $listener->onKernelResponse($event); + + $this->assertNoStoreHeaders($response); + // the redirect target must survive + self::assertSame('https://example.com/dashboard', $response->headers->get('Location')); + } + + public function testRateLimitedResponseIsNotCacheable(): void + { + $listener = $this->makeListener(); + $response = new Response('