Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
054b8ef48f | ||
|
|
5258e175a1 | ||
|
|
2f7ae31ba1 | ||
|
|
baf976a8e6 | ||
|
|
af4d2a4ac7 | ||
|
|
c743a1baac | ||
|
|
3f1778cd6b | ||
|
|
33181f11d8 | ||
|
|
bb2cc3ce49 | ||
|
|
e4f54769e6 | ||
|
|
b75a16a781 | ||
|
|
9111958bcf | ||
|
|
95dc6bf0ce | ||
|
|
472abfdf89 |
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'develop'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
|
||||
@@ -3,7 +3,11 @@ name: Sync GitHub
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
- 'main'
|
||||
- 'feat*'
|
||||
- 'fix*'
|
||||
- 'cleanup*'
|
||||
- 'chore*'
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
|
||||
@@ -4,11 +4,13 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'develop'
|
||||
- 'feat*'
|
||||
- 'fix*'
|
||||
- 'cleanup*'
|
||||
- 'chore*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'develop'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -26,6 +28,24 @@ jobs:
|
||||
coverage: xdebug
|
||||
ini-values: apc.enable_cli=1
|
||||
|
||||
# Authenticate to GitHub to raise API rate limit from 60 → 5,000 req/hour.
|
||||
# Uses the same token that publish.yaml uses to sync to GitHub.
|
||||
- name: Configure GitHub OAuth token
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.SYNC_GITHUB_TOKEN }}
|
||||
run: composer config --global github-oauth.github.com "$GITHUB_TOKEN"
|
||||
|
||||
# Cache Composer's download cache so repeated CI runs don't re-download
|
||||
# packages at all. Keyed on composer.lock hash — cache busts automatically
|
||||
# when dependencies change.
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.composer/cache
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('composer.lock') }}
|
||||
restore-keys: |
|
||||
composer-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress
|
||||
|
||||
|
||||
@@ -25,6 +25,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- New `PublicPathMatcher` service for path pattern matching.
|
||||
- New `PublicAccessListener` (priority 84) in the request pipeline.
|
||||
|
||||
### Changed
|
||||
- **Upgraded Symfony 7.4 → 8.1** — All `symfony/*` components bumped to
|
||||
`8.1.*` (resolved to 8.1.2–8.1.6). The 7.4 deprecation sweep was clean
|
||||
(test suite runs with `failOnDeprecation`), so the major-version jump
|
||||
required no application code changes. See
|
||||
`docs/symfony-8.1-upgrade-plan.md`.
|
||||
|
||||
### Removed
|
||||
- **`runtime/frankenphp-symfony`** — No longer needed: `symfony/runtime`
|
||||
8.1 handles FrankenPHP worker mode natively via its built-in
|
||||
`FrankenPhpWorkerRunner`. The `extra.runtime` override in
|
||||
`composer.json` was removed so the runtime auto-detects FrankenPHP.
|
||||
The old package's `FRANKENPHP_LOOP_MAX` env var is no longer read;
|
||||
an equivalent recycle limit is restored via the new `MAX_REQUESTS`
|
||||
setting below.
|
||||
|
||||
### Added
|
||||
- **`MAX_REQUESTS` worker-thread recycle limit** — The `Caddyfile` now
|
||||
sets FrankenPHP's native `max_requests` from the `MAX_REQUESTS`
|
||||
environment variable: each PHP worker thread is gracefully restarted
|
||||
after N requests while others keep serving, containing slow memory
|
||||
growth across long uptime. The image default is **500** (matching the
|
||||
previous `runtime/frankenphp-symfony` default), baked in as a Docker
|
||||
build arg and overridable at runtime (`MAX_REQUESTS=0` disables
|
||||
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,3 +1,17 @@
|
||||
{
|
||||
frankenphp {
|
||||
# Restart each PHP worker thread after this many requests, containing
|
||||
# slow memory growth across long uptime. Preserves the 7.4-era default
|
||||
# loop count of runtime/frankenphp-symfony (500) after the Symfony 8.1
|
||||
# upgrade. Set MAX_REQUESTS=0 to disable restarts. The Dockerfile bakes
|
||||
# in the default of 500 via build arg; override at runtime with:
|
||||
# docker run -e MAX_REQUESTS=5000 ...
|
||||
# For full control, the stock FRANKENPHP_CONFIG env var can inject any
|
||||
# directive under this block instead.
|
||||
max_requests {$MAX_REQUESTS}
|
||||
}
|
||||
}
|
||||
|
||||
http://
|
||||
root public/
|
||||
rewrite index.php
|
||||
|
||||
@@ -45,6 +45,15 @@ ENV APP_DEBUG=0
|
||||
ENV APP_ENV=prod
|
||||
ENV APP_SHARE_DIR=/data/preauth
|
||||
|
||||
# worker thread lifecycle: restart each PHP thread after N requests to
|
||||
# contain slow memory growth. Matches the previous default loop count of
|
||||
# runtime/frankenphp-symfony (removed in the Symfony 8.1 upgrade).
|
||||
# Expose as a build arg so images can bake in a different default;
|
||||
# MAX_REQUESTS=0 disables restarts. Runtime override: the same env var is
|
||||
# read by the Caddyfile placeholder.
|
||||
ARG MAX_REQUESTS=500
|
||||
ENV MAX_REQUESTS=$MAX_REQUESTS
|
||||
|
||||
# load application into final image
|
||||
WORKDIR /app
|
||||
COPY --from=build /data/preauth /data/preauth
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ authentication — it's a gate that prevents outsiders from even seeing
|
||||
what service is running.
|
||||
|
||||
- **Location:** `projects/preauth/`
|
||||
- **Framework:** Symfony 7.4 (PHP ≥ 8.4)
|
||||
- **Framework:** Symfony 8.1 (PHP ≥ 8.4)
|
||||
- **Serving:** FrankenPHP (Docker image)
|
||||
- **Cache:** Dual-layer — APCu (in-memory) + file-based persistence
|
||||
- **Auth:** TOTP (single secret) + single-use backup codes
|
||||
@@ -329,7 +329,7 @@ struggle with TOTP apps.
|
||||
command or initial-setup flow to register a passkey).
|
||||
|
||||
- [ ] Research `web-auth/webauthn-framework` integration with Symfony
|
||||
7.4 and FrankenPHP
|
||||
8.1 and FrankenPHP
|
||||
- [ ] Design passkey registration flow (console command? first-visit
|
||||
setup? separate registration endpoint?)
|
||||
- [ ] Implement challenge generation and storage (extend existing
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ REQUIRED_PHP_EXTS=(
|
||||
)
|
||||
|
||||
# Apt packages for PHP + extensions
|
||||
# Note: preauth uses Symfony 7.4 which requires PHP >=8.1.
|
||||
# Note: preauth uses Symfony 8.1 which requires PHP >=8.4.
|
||||
# We install PHP 8.4 (available in Debian 13/Trixie) for consistency.
|
||||
PHP_APT_PACKAGES=(
|
||||
php8.4-cli
|
||||
|
||||
+12
-16
@@ -8,18 +8,17 @@
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"bacon/bacon-qr-code": "^3.1.1",
|
||||
"runtime/frankenphp-symfony": "^1.0.0",
|
||||
"spomky-labs/otphp": "^11.4.2",
|
||||
"symfony/cache": "7.4.*",
|
||||
"symfony/console": "7.4.*",
|
||||
"symfony/cache": "8.1.*",
|
||||
"symfony/console": "8.1.*",
|
||||
"symfony/flex": "^2.11",
|
||||
"symfony/framework-bundle": "7.4.*",
|
||||
"symfony/mime": "7.4.*",
|
||||
"symfony/rate-limiter": "7.4.*",
|
||||
"symfony/runtime": "7.4.*",
|
||||
"symfony/twig-bundle": "7.4.*",
|
||||
"symfony/uid": "7.4.*",
|
||||
"symfony/yaml": "7.4.*"
|
||||
"symfony/framework-bundle": "8.1.*",
|
||||
"symfony/mime": "8.1.*",
|
||||
"symfony/rate-limiter": "8.1.*",
|
||||
"symfony/runtime": "8.1.*",
|
||||
"symfony/twig-bundle": "8.1.*",
|
||||
"symfony/uid": "8.1.*",
|
||||
"symfony/yaml": "8.1.*"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
@@ -66,18 +65,15 @@
|
||||
"symfony/symfony": "*"
|
||||
},
|
||||
"extra": {
|
||||
"runtime": {
|
||||
"class": "Runtime\\FrankenPhpSymfony\\Runtime"
|
||||
},
|
||||
"symfony": {
|
||||
"allow-contrib": false,
|
||||
"require": "7.4.*"
|
||||
"require": "8.1.*"
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "*",
|
||||
"phpunit/phpunit": "^13.2",
|
||||
"symfony/browser-kit": "7.4.*",
|
||||
"symfony/css-selector": "7.4.*"
|
||||
"symfony/browser-kit": "8.1.*",
|
||||
"symfony/css-selector": "8.1.*"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+719
-806
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
@@ -24,6 +24,14 @@
|
||||
# once blocked, do we respond with "I'm a teapot", false to use "Too many requests"
|
||||
#TEAPOT=true # default enabled, boolean
|
||||
|
||||
# --- server / worker options ---
|
||||
|
||||
# (container/deployment only) restart each FrankenPHP worker thread after
|
||||
# this many requests, containing memory growth across long uptime;
|
||||
# matching the default from the old runtime/frankenphp-symfony package.
|
||||
# 0 disables restarts. consumed by the Caddyfile, not the PHP app.
|
||||
#MAX_REQUESTS=500 # default 500
|
||||
|
||||
# --- remote-user header ---
|
||||
# Controls the value sent in the Remote-User header on successful auth.
|
||||
# session: the session id (default, backward-compatible)
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
# Upgrade Plan: Symfony 7.4 → 8.1
|
||||
|
||||
**Status:** ✅ Implemented on branch `feat/symfony-8.1-upgrade-plan`
|
||||
(Phases 0–3 & state audit complete; Phases 4–5 = staging + release)
|
||||
**Target:** Symfony `8.1.*` (all symfony components)
|
||||
**Was:** Symfony `7.4.*` → **resolved 8.1.2–8.1.6**
|
||||
**Prepared:** 2026-09-07
|
||||
|
||||
---
|
||||
|
||||
## Implementation results
|
||||
|
||||
| Phase | Result |
|
||||
|-------|--------|
|
||||
| 0 Deprecation sweep | ✅ Clean — suite runs with `failOnDeprecation=true`, zero hits on 7.4; the 8.x jump needed **no app code changes**. |
|
||||
| 1 Composer bump | ✅ `runtime/frankenphp-symfony` removed, `extra.runtime` deleted, all `symfony/*` at `8.1.*` (framework-bundle 8.1.6, twig-bundle 8.1.2); ride-alongs PHPUnit 13.3.2, Twig 3.28, otphp 11.5. Boots on **v8.1.6**. |
|
||||
| 2 Config refresh | ✅ `config/reference.php` is gitignored, auto-regenerated by Flex. Prod `cache:clear`+`cache:warmup`, `lint:container`/`lint:yaml`/`lint:twig` all pass. |
|
||||
| 3 Tests | ✅ **295 tests / 612 assertions green** on 8.1; php-cs-fixer 0 fixable files. |
|
||||
| State audit | ✅ All `src/` services are `final readonly` with ctor-injected deps — no mutable state, kernel reuse under `FrankenPhpWorkerRunner` is safe. |
|
||||
| Loop-max parity | ✅ `Caddyfile` sets `max_requests {$MAX_REQUESTS}`; default **500** baked into the image via Docker build arg (matches old package default), runtime-overridable. See §2 note. |
|
||||
|
||||
Phases 4–5 (staging smoke + release) are pending — everything else in
|
||||
this document describes what was planned **and is now done**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why we can leapfrog 8.0
|
||||
|
||||
Symfony 7.4 and 8.0 were released simultaneously (Nov 2025) and are
|
||||
feature-identical — 8.0 is simply 7.4 with the deprecated code removed.
|
||||
Because preauth is **already on 7.4**, we are on the last LTS bridge
|
||||
release. The only gating question for 8.x is whether we still trigger
|
||||
any deprecations. If `composer test` runs clean under 7.4 with
|
||||
`SYMFONY_DEPRECATIONS_HELPER` strict, upgrading straight to 8.1 is safe
|
||||
and avoids a double-bump of `composer.json` / `composer.lock`.
|
||||
|
||||
Symfony 8.1 (May 2026 cycle) also brings a runtime improvement we
|
||||
directly benefit from (see §3).
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- ✅ PHP: Symfony 8.x requires PHP **>= 8.4**; composer.json already
|
||||
requires `>= 8.4`, Docker and CI run 8.5. No PHP work needed.
|
||||
- ⚠️ Deprecations: must be inventoried and fixed before the version bump
|
||||
(see Phase 0).
|
||||
|
||||
## 2. The `runtime/frankenphp-symfony` removal
|
||||
|
||||
We currently use the community runtime package for FrankenPHP worker
|
||||
mode, wired in two places in `composer.json`:
|
||||
|
||||
```json
|
||||
"require": {
|
||||
"runtime/frankenphp-symfony": "^1.0.0",
|
||||
},
|
||||
"extra": {
|
||||
"runtime": {
|
||||
"class": "Runtime\\FrankenPhpSymfony\\Runtime"
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
As of Symfony 7.4+, `symfony/runtime` ships its own
|
||||
`Symfony\Component\Runtime\Runner\FrankenPhpWorkerRunner`, and **in 8.1
|
||||
the runtime handles FrankenPHP worker mode natively** (including new
|
||||
8.1 support for returning a `Response` from worker mode). The
|
||||
community package is redundant.
|
||||
|
||||
**Actions:**
|
||||
|
||||
1. `composer remove runtime/frankenphp-symfony` (as part of the 8.1 bump
|
||||
in §4 — do it in the same `composer update` to keep one lockfile diff).
|
||||
2. Delete the entire `extra.runtime` block from `composer.json` so the
|
||||
default `Symfony\Component\Runtime\GenericRuntime` is used; the
|
||||
built-in `FrankenPhpWorkerRunner` is auto-selected when
|
||||
`frankenphp_handle_request()` exists (i.e. inside FrankenPHP worker
|
||||
mode). Falling back to plain `APP_RUNTIME=Symfony\...\Runtime` env
|
||||
override is possible but should not be needed.
|
||||
3. Verify `symfony.lock` — Flex should drop the
|
||||
`runtime/frankenphp-symfony` entry automatically on removal.
|
||||
4. `public/index.php` needs **no change** — it already just returns the
|
||||
Kernel closure via `autoload_runtime.php`.
|
||||
|
||||
**Note on loop_max:** the old package exposed
|
||||
`FRANKENPHP_LOOP_MAX` (default 500). The built-in runner does not
|
||||
read that env var. We never set it, so behavior is unchanged — but
|
||||
check staging memory usage under worker mode and, if ever needed,
|
||||
control restarts via FrankenPHP's own `worker ... num N` / max-requests
|
||||
options in the Caddyfile instead.
|
||||
|
||||
## 3. composer.json changes
|
||||
|
||||
### `require`
|
||||
|
||||
| Package | From | To |
|
||||
|--------------------------|------------|---------|
|
||||
| `symfony/cache` | `7.4.*` | `8.1.*` |
|
||||
| `symfony/console` | `7.4.*` | `8.1.*` |
|
||||
| `symfony/framework-bundle`| `7.4.*` | `8.1.*` |
|
||||
| `symfony/mime` | `7.4.*` | `8.1.*` |
|
||||
| `symfony/rate-limiter` | `7.4.*` | `8.1.*` |
|
||||
| `symfony/runtime` | `7.4.*` | `8.1.*` |
|
||||
| `symfony/twig-bundle` | `7.4.*` | `8.1.*` |
|
||||
| `symfony/uid` | `7.4.*` | `8.1.*` |
|
||||
| `symfony/yaml` | `7.4.*` | `8.1.*` |
|
||||
| ~~`runtime/frankenphp-symfony`~~ | `^1.0.0` | **removed** |
|
||||
|
||||
`symfony/flex` (`^2.11`), `bacon/bacon-qr-code` (^3) and
|
||||
`spomky-labs/otphp` (^11) are compatible with 8.x — no change expected,
|
||||
but let composer confirm during the update.
|
||||
|
||||
### `require-dev`
|
||||
|
||||
| Package | From | To |
|
||||
|--------------------------|---------|---------|
|
||||
| `symfony/browser-kit` | `7.4.*` | `8.1.*` |
|
||||
| `symfony/css-selector` | `7.4.*` | `8.1.*` |
|
||||
|
||||
`phpunit/phpunit ^13.2` and `friendsofphp/php-cs-fixer` already support
|
||||
PHP 8.5 / Symfony 8.
|
||||
|
||||
### `extra`
|
||||
|
||||
```diff
|
||||
"extra": {
|
||||
- "runtime": {
|
||||
- "class": "Runtime\\FrankenPhpSymfony\\Runtime"
|
||||
- },
|
||||
"symfony": {
|
||||
"allow-contrib": false,
|
||||
- "require": "7.4.*"
|
||||
+ "require": "8.1.*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### One-shot command
|
||||
|
||||
```bash
|
||||
composer update \
|
||||
"symfony/*" \
|
||||
--with-all-dependencies
|
||||
# plus explicit remove of runtime/frankenphp-symfony beforehand
|
||||
```
|
||||
|
||||
(Or edit composer.json, then `composer update` wholesale — the repo has
|
||||
few non-Symfony deps, so a full update is low-risk.)
|
||||
|
||||
## 4. Config / recipes to re-sync
|
||||
|
||||
After the bump, run `composer recipes:update` (or
|
||||
`symfony console recipes:update`) and review diffs for:
|
||||
|
||||
- `symfony/framework-bundle` — check `config/packages/framework.yaml`
|
||||
for new/changed defaults (session, cache, http_method_override, etc.).
|
||||
Our `config/reference.php` dump is generated from 7.4 config; it
|
||||
**must be regenerated** after upgrade
|
||||
(`bin/console config:dump-reference` equivalents) or it will document
|
||||
stale defaults.
|
||||
- `symfony/twig-bundle`, `symfony/rate-limiter` — verify
|
||||
`config/packages/*.yaml` against new reference defaults.
|
||||
- `symfony/runtime` — new recipe may update `public/index.php`; accept
|
||||
only if it's a no-op for our shape.
|
||||
|
||||
Also review `bundles.php` (only Framework + Twig today — no removals
|
||||
expected in 8.x) and `config/preload.php`.
|
||||
|
||||
## 5. Code-level risk review
|
||||
|
||||
Preauth deliberately avoids the Security component (custom listeners +
|
||||
`ConfigBag`), which removes the biggest 8.0 BC-break surface
|
||||
(`security.yaml` reshaping, authenticator changes). Remaining surface:
|
||||
|
||||
- **Listeners** (`src/Listener/*`): built on HttpKernel events — stable
|
||||
API, but `KernelEvents` signatures gained native types in 8.0; our
|
||||
listeners already declare types, verify covariance after upgrade.
|
||||
- **`Kernel.php`**: confirm no overridden methods whose signatures
|
||||
changed in 8.0 (MicroKernelTrait is stable; likely no-op).
|
||||
- **`symfony/console`** (GenerateBackupCodesCommand): 8.0 removed
|
||||
command `setName()`/aliases-in-constructor legacy paths — we use
|
||||
`#[AsCommand]`, fine. `Command::execute()` must return `int` — verify.
|
||||
- **`spomky-labs/otphp`** and **`bacon/bacon-qr-code`**: third-party;
|
||||
confirm versions resolved are marked Symfony-8 compatible.
|
||||
- **PHPUnit 13**: no changes needed, but watch for deprecations printed
|
||||
after the Symfony bump (new `trigger_deprecation` calls in 8.1).
|
||||
|
||||
Canonical checklist: read `symfony/symfony` **UPGRADE-8.0.md** and
|
||||
**UPGRADE-8.1.md** sections for the components we require
|
||||
(cache, console, framework-bundle, mime, rate-limiter, runtime,
|
||||
twig-bundle, uid, yaml) and tick each item against this codebase.
|
||||
|
||||
## 6. Docker / CI
|
||||
|
||||
- `Dockerfile`: no base-image change needed
|
||||
(`dunglas/frankenphp:php8.5-trixie` + `php:8.5-trixie` builder).
|
||||
Rebuild after composer.lock update; remove nothing — FrankenPHP itself
|
||||
stays.
|
||||
- `Caddyfile`: unchanged (worker mode config is FrankenPHP-side, not
|
||||
runtime-package-side).
|
||||
- `.gitea/workflows/tests.yaml`: PHP 8.5 already — unchanged.
|
||||
- `composer dump-env prod --empty` step stays.
|
||||
|
||||
## 7. Rollout plan
|
||||
|
||||
| Phase | Step | Exit criteria |
|
||||
|-------|------|---------------|
|
||||
| 0 | **Deprecation sweep on 7.4**: run `SYMFONY_DEPRECATIONS_HELPER=max[total]=0 composer test` (or phpunit directly) + run the app in dev with the profiler/log; fix every direct deprecation. | Zero deprecations from `App\` code; only acceptable vendor ones documented. |
|
||||
| 1 | **composer bump**: branch `feat/symfony-8.1`; edit composer.json per §3–§4; `composer remove runtime/frankenphp-symfony`; `composer update`; re-sync recipes. | Installs clean on PHP 8.5; `bin/console about` shows 8.1.x. |
|
||||
| 2 | **Config refresh**: regenerate `config/reference.php`; review framework/twig/rate-limiter defaults; commit config changes. | `cache:clear` + warmup pass in dev & prod envs. |
|
||||
| 3 | **Tests**: full phpunit suite + php-cs-fixer; fix failures (expected: minor — event/type related). | Suite green in CI. |
|
||||
| 4 | **Staging smoke**: build image, run under FrankenPHP worker mode; verify TOTP login flow, backup codes, rate limiting (burst + teapot mode), public paths, central-auth subdomain flow; watch memory across >500 requests to confirm threads recycle via the Caddyfile `max_requests` setting (see §2 note). | No state leaks across worker requests; worker threads recycle at the configured request count; healthcheck passes. |
|
||||
| 5 | **Docs + release**: update readme/DESIGN_CONSIDERATIONS ("symfony 8.1, built-in FrankenPHP runtime"); tag a minor release per CHANGELOG conventions. | Release published; image rebuilt & pushed. |
|
||||
|
||||
**Rollback:** the upgrade is a single composer.lock + config diff.
|
||||
Rollback = `git revert` the bump commit + redeploy previous image tag.
|
||||
No data/schema migrations are involved (no database).
|
||||
|
||||
## 8. Open questions — resolved during implementation
|
||||
|
||||
- [x] ~~Confirm none of our listeners/services relied on implicit behavior
|
||||
of `Runtime\FrankenPhpSymfony\Runner`.~~ **Resolved:** audited every
|
||||
class in `src/` — all are `final readonly` with constructor-injected
|
||||
dependencies and no mutable state. No `ResetInterface` needed; kernel
|
||||
reuse across worker requests is safe.
|
||||
- [x] ~~Decide whether to pin `symfony/*` as `8.1.*` or `^8.1`.~~
|
||||
**Resolved:** kept minor-pinned `8.1.*`, matching repo convention.
|
||||
- [x] ~~Regenerate `config/reference.php` — scripted or manual dump?~~
|
||||
**Resolved:** it's gitignored and auto-regenerated by Flex on
|
||||
`composer update`; already refreshed for 8.1 during the bump.
|
||||
@@ -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)
|
||||
|
||||
@@ -113,6 +124,7 @@ for the complete reference.
|
||||
|----------|---------|-------------|
|
||||
| `IP_TTL` | `0` | Seconds to allow all traffic from an IP after login (0 = disabled). |
|
||||
| `TEAPOT` | `1` | Respond with 418 instead of 429 when rate-limited (boolean). |
|
||||
| `MAX_REQUESTS` | `500` | Restart each FrankenPHP worker thread after this many requests to contain memory growth (`0` = unlimited). Maps to the Caddyfile `max_requests` directive. |
|
||||
|
||||
### Remote-User Header
|
||||
|
||||
@@ -236,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