fix: never cache the login flow (stale pre-auth responses in Safari)
Tests / test (pull_request) Successful in 1m8s
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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', '*');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 -%}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user