14 Commits
Author SHA1 Message Date
andrew 054b8ef48f Merge pull request 'fix: never cache the login flow (stale pre-auth responses in Safari)' (#12) from fix/login-flow-cache-control into main
Sync GitHub / sync (push) Successful in 10s
Tests / test (push) Successful in 1m35s
Push Develop / docker (push) Successful in 6m3s
Push Docker / docker (push) Successful in 8m45s
Reviewed-on: #12
Reviewed-by: Andrew <andrew@digitaladapt.com>
2026-09-14 21:56:59 -04:00
lyra 5258e175a1 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.
2026-09-14 19:31:44 -04:00
andrew 2f7ae31ba1 Merge pull request 'feat: upgrade Symfony 7.4 → 8.1, drop runtime/frankenphp-symfony' (#11) from feat/symfony-8.1-upgrade-plan into main
Sync GitHub / sync (push) Successful in 8s
Tests / test (push) Successful in 1m21s
Push Develop / docker (push) Successful in 4m49s
Reviewed-on: #11
Reviewed-by: Andrew <andrew@digitaladapt.com>
2026-09-12 16:51:29 -04:00
lyra baf976a8e6 feat: restore worker recycle limit via Caddyfile max_requests (default 500)
Tests / test (pull_request) Successful in 1m4s
The removed runtime/frankenphp-symfony package force-restarted the
worker after FRANKENPHP_LOOP_MAX requests (default 500) - memory-leak
paranoia the built-in 8.1 runner intentionally doesn't reimplement.

Restore the behavior natively so the upgrade is a no-op operationally:
- Caddyfile: global frankenphp block with max_requests {$MAX_REQUESTS}
  - per-thread graceful restarts, other threads keep serving
- Dockerfile: ARG/ENV MAX_REQUESTS=500 bakes the same default the old
  package had, overridable at docker build or runtime (-e, 0 disables)
- Stock FRANKENPHP_CONFIG env var remains the full-config escape hatch
- Documented in readme env table, docs/example.env, CHANGELOG, and the
  upgrade plan (phase-4 staging note now checks thread recycling)
2026-09-08 06:15:20 -04:00
lyra af4d2a4ac7 docs: record Symfony 8.1 upgrade results, update stack references
Tests / test (pull_request) Successful in 1m7s
- Plan doc: mark Phases 0-3 + worker-mode state audit complete with
  verified results; resolve open questions (all src/ services are final
  readonly - no ResetInterface needed under kernel-reusing runner)
- CHANGELOG: Unreleased entries for the 8.1 bump and the
  runtime/frankenphp-symfony removal (incl. FRANKENPHP_LOOP_MAX note)
- ROADMAP: current stack now Symfony 8.1; webauthn research note updated
- bin/dev.sh: correct stale PHP requirement comment
2026-09-07 22:46:22 -04:00
lyra c743a1baac feat: upgrade Symfony 7.4 -> 8.1, drop runtime/frankenphp-symfony
- All symfony/* constraints bumped 7.4.* -> 8.1.* (resolved to 8.1.2-8.1.6)
- Removed runtime/frankenphp-symfony: symfony/runtime 8.1 handles
  FrankenPHP worker mode natively via FrankenPhpWorkerRunner; deleted
  the extra.runtime class override so GenericRuntime auto-detects it
- phpunit 13.3.2, twig 3.28, otphp 11.5 ride along
- Deprecation sweep on 7.4 was clean (failOnDeprecation suite, zero hits)
- Verified: bin/console about boots on 8.1.6; 295 tests / 612 assertions
  green after the bump
2026-09-07 22:40:17 -04:00
lyra 3f1778cd6b docs: add Symfony 7.4 -> 8.1 upgrade plan
Covers deprecation sweep gate, composer.json constraint bumps to 8.1.*,
and removal of runtime/frankenphp-symfony (FrankenPHP worker mode is
handled natively by symfony/runtime in 8.x). Includes config recipe
re-sync, Docker/CI notes, phased rollout with rollback, and open
questions (kernel-reset audit for worker mode).
2026-09-07 22:26:00 -04:00
andrew 33181f11d8 testing branch is safe now that git hook protections have been setup
Sync GitHub / sync (push) Successful in 10s
Tests / test (push) Successful in 1m10s
Push Develop / docker (push) Successful in 4m44s
Push Docker / docker (push) Successful in 4m53s
2026-08-30 22:24:50 -04:00
andrew bb2cc3ce49 only test and push select branches
Sync GitHub / sync (push) Successful in 8s
Tests / test (push) Successful in 1m9s
Push Develop / docker (push) Successful in 4m48s
2026-08-27 21:55:07 -04:00
andrew e4f54769e6 use main branch workflow
Sync GitHub / sync (push) Successful in 7s
Tests / test (push) Successful in 1m1s
Push Develop / docker (push) Successful in 4m45s
Push Docker / docker (push) Successful in 10m28s
2026-08-25 20:22:12 -04:00
andrew b75a16a781 Merge remote-tracking branch 'origin/fix/ci-github-rate-limit' into develop
Sync GitHub / sync (push) Successful in 8s
Push Develop / docker (push) Successful in 4m55s
Tests / test (push) Successful in 59s
Push Docker / docker (push) Successful in 4m42s
2026-08-21 16:11:31 -04:00
andrew 9111958bcf Merge branch 'main' into develop 2026-08-21 16:10:30 -04:00
andrew 95dc6bf0ce Merge branch 'main' into fix/ci-github-rate-limit
Sync GitHub / sync (push) Successful in 6s
2026-08-20 16:27:52 -04:00
lyra 472abfdf89 fix(ci): cache composer deps and authenticate to GitHub
Tests / test (pull_request) Successful in 1m21s
Tests / test (push) Successful in 1m30s
Push Develop / docker (push) Successful in 5m12s
Sync GitHub / sync (push) Successful in 9s
The test workflow was hitting GitHub's unauthenticated API rate
limit (60 req/hour) when downloading 95 packages via composer
install --prefer-dist, causing 429 Too Many Requests errors.

Two fixes applied:
1. Cache Composer's download cache (~/.composer/cache) keyed on
   composer.lock hash, so repeated CI runs don't re-download
   packages at all.
2. Configure GitHub OAuth token via SYNC_GITHUB_TOKEN secret to
   raise the rate limit to 5,000 req/hour for cache misses.
2026-08-17 12:03:14 -04:00
18 changed files with 1529 additions and 831 deletions
-1
View File
@@ -4,7 +4,6 @@ on:
push:
branches:
- 'main'
- 'develop'
jobs:
docker:
+5 -1
View File
@@ -3,7 +3,11 @@ name: Sync GitHub
on:
push:
branches:
- '**'
- 'main'
- 'feat*'
- 'fix*'
- 'cleanup*'
- 'chore*'
jobs:
sync:
+22 -2
View File
@@ -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
+41
View File
@@ -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.28.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
+14
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+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
}
+8
View File
@@ -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)
+229
View File
@@ -0,0 +1,229 @@
# Upgrade Plan: Symfony 7.4 → 8.1
**Status:** ✅ Implemented on branch `feat/symfony-8.1-upgrade-plan`
(Phases 03 & state audit complete; Phases 45 = staging + release)
**Target:** Symfony `8.1.*` (all symfony components)
**Was:** Symfony `7.4.*`**resolved 8.1.28.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 45 (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.
+21 -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)
@@ -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
+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'));
}
}