fix: never cache the login flow (stale pre-auth responses in Safari)
Tests / test (pull_request) Successful in 1m8s

The login page, failed logins, redirects, and rate-limit/error pages could
be stored by the browser (Symfony's default 'no-cache, private' still
permits storage — it only requires revalidation). Older Safari builds may
then replay a stale pre-auth response on refresh, appearing to log the
user back out, or show a previous session after logging in again.

- SecurityHeadersListener: send strict anti-caching headers on non-2xx
  responses only (no-store/no-cache/must-revalidate/proxy-revalidate,
  max-age=0, s-maxage=0 + Pragma, Expires, Surrogate-Control, Vary: *).
  2xx grants (already-authenticated / public access) are consumed by
  Caddy's forward_auth check and never reach the browser, and protected
  services' own cache headers must stay untouched.
- templates/_script.html.twig: fetch() with cache: 'no-store'; follow
  redirects with location.replace() to keep the login page out of history
  and the back-forward cache.
- docs/Caddyfile: reusable (preauth_no_store) snippet imported into every
  forward_auth block, using header_down so the guarantee holds at the edge
  (verified: replaces conflicting upstream values, leaves service
  responses alone).
- tests: unit coverage for the listener and functional coverage for the
  full HTTP kernel (login/failure/redirect/rate-limit not cacheable;
  200 grants untouched); asserts the rendered page carries the JS changes.
- readme/CHANGELOG updates.
This commit is contained in:
2026-09-14 19:31:44 -04:00
parent 2f7ae31ba1
commit 5258e175a1
7 changed files with 480 additions and 2 deletions
+14
View File
@@ -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
+27
View File
@@ -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
}
+20 -1
View File
@@ -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
+20
View File
@@ -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', '*');
}
}
}
+4 -1
View File
@@ -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 -%}
+188
View File
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional;
use OTPHP\TOTP;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* End-to-end checks that the login flow carries strict anti-caching headers
* on everything the browser can see, while 2xx grants ("already
* authenticated" / public access) — which the reverse proxy consumes in its
* forward_auth check and never forwards to the browser — are left untouched.
*/
final class CacheControlFlowTest extends WebTestCase
{
private const string TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
protected static function createClient(array $options = [], array $server = []): KernelBrowser
{
$client = parent::createClient($options, $server);
$client->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);
}
}
@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Listener;
use App\Listener\SecurityHeadersListener;
use App\Service\DomainInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
final class SecurityHeadersListenerTest extends TestCase
{
private function makeListener(?string $authSubdomain = null): SecurityHeadersListener
{
$domainManager = $this->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('<form>login</form>', 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('<h1>teapot</h1>', Response::HTTP_I_AM_A_TEAPOT);
$event = $this->makeEvent($response);
$listener->onKernelResponse($event);
$this->assertNoStoreHeaders($response);
}
public function testServerErrorResponseIsNotCacheable(): void
{
$listener = $this->makeListener();
$response = new Response('error', Response::HTTP_INTERNAL_SERVER_ERROR);
$event = $this->makeEvent($response);
$listener->onKernelResponse($event);
$this->assertNoStoreHeaders($response);
}
/* ── 2xx: authenticated / public grants stay untouched ────────────── */
public function testSuccessfulAuthenticatedResponseIsNotTouched(): void
{
$listener = $this->makeListener();
$response = new Response('hi alice', Response::HTTP_OK, [
'Remote-User' => 'alice',
'Content-Type' => 'text/plain',
]);
$event = $this->makeEvent($response);
$listener->onKernelResponse($event);
// "already authenticated" responses are consumed by the reverse
// proxy's forward_auth check and never reach the browser, so they
// must not carry the anti-caching headers (or they could leak onto
// the protected service's own responses in custom configurations)
$this->assertNoAntiCachingHeaders($response);
self::assertSame('alice', $response->headers->get('Remote-User'));
}
public function testSuccessfulResponseKeepsItsOwnCacheHeaders(): void
{
$listener = $this->makeListener();
$response = new Response('ok', Response::HTTP_OK, [
'Cache-Control' => 'public, max-age=60',
]);
$event = $this->makeEvent($response);
$listener->onKernelResponse($event);
// the service's caching decisions are its own business;
// Symfony normalizes directive order, so assert semantically
self::assertTrue($response->headers->hasCacheControlDirective('public'));
self::assertSame('60', $response->headers->getCacheControlDirective('max-age'));
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
}
/* ── sub-requests ─────────────────────────────────────────────────── */
public function testSubRequestsAreSkipped(): void
{
$listener = $this->makeListener();
$response = new Response('login', Response::HTTP_UNAUTHORIZED);
$event = $this->makeEvent($response, null, HttpKernelInterface::SUB_REQUEST);
$listener->onKernelResponse($event);
self::assertFalse($response->headers->hasCacheControlDirective('no-store'));
self::assertNull($response->headers->get('X-Frame-Options'));
}
/* ── the pre-existing security headers ────────────────────────────── */
public function testSecurityHeadersAreApplied(): void
{
$listener = $this->makeListener();
$response = new Response('<form>login</form>', Response::HTTP_UNAUTHORIZED);
$event = $this->makeEvent($response);
$listener->onKernelResponse($event);
self::assertSame('nosniff', $response->headers->get('X-Content-Type-Options'));
self::assertSame('DENY', $response->headers->get('X-Frame-Options'));
self::assertSame('strict-origin-when-cross-origin', $response->headers->get('Referrer-Policy'));
self::assertSame('max-age=31536000', $response->headers->get('Strict-Transport-Security'));
}
public function testCspAllowsSameOriginConnectWhenInlineScriptIsUsed(): void
{
// not on the auth subdomain: the login form uses an inline fetch()
$listener = $this->makeListener('auth.example.com');
$response = new Response('login', Response::HTTP_UNAUTHORIZED);
$event = $this->makeEvent($response);
$listener->onKernelResponse($event);
self::assertStringContainsString("connect-src 'self';", $response->headers->get('Content-Security-Policy'));
}
public function testCspDoesNotAllowConnectWhenOnAuthSubdomain(): void
{
// on the auth subdomain the form POSTs normally — no inline fetch
$listener = $this->makeListener('auth.example.com');
$response = new Response('login', Response::HTTP_UNAUTHORIZED);
$request = Request::create('https://auth.example.com/', 'GET');
$event = $this->makeEvent($response, $request);
$listener->onKernelResponse($event);
self::assertStringNotContainsString('connect-src', $response->headers->get('Content-Security-Policy'));
}
}