20 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
lyra e2780ca5f6 fix: allow same-origin fetch in CSP when inline login script is used
Tests / test (pull_request) Successful in 49s
Sync GitHub / sync (push) Successful in 9s
Tests / test (push) Successful in 1m1s
Push Develop / docker (push) Successful in 4m47s
Push Docker / docker (push) Successful in 7m35s
When subdomain redirection is off, the login form is served inline on
the protected host and submission happens via a same-origin fetch() call
in _script.html.twig. The CSP default-src 'none' was blocking that
fetch (connect-src falls back to default-src).

Add connect-src 'self' to the CSP only when the request is not on the
auth subdomain (i.e. when the inline script is present). On the auth
subdomain the form POSTs normally with no inline script, so the stricter
policy still applies there.

This is the least-privilege relaxation: only same-origin connections,
only on pages that need them.
2026-08-17 11:27:46 -04:00
andrew 7a68c933ce Merge pull request 'fix: handle host-prefixed root path in PublicPathMatcher' (#8) from fix/public-path-host-root into main
Push Develop / docker (push) Successful in 4m44s
Sync GitHub / sync (push) Successful in 7s
Tests / test (push) Successful in 45s
Push Docker / docker (push) Successful in 4m43s
Reviewed-on: #8
Reviewed-by: Andrew <andrew@digitaladapt.com>
2026-08-13 16:28:05 -04:00
andrew 55f8e9e84c Merge branch 'main' into fix/public-path-host-root
Sync GitHub / sync (push) Successful in 9s
Tests / test (pull_request) Successful in 1m16s
2026-08-13 15:43:54 -04:00
lyra e3cd8c6739 fix: handle host-prefixed root path in PublicPathMatcher
Sync GitHub / sync (push) Successful in 6s
Tests / test (pull_request) Successful in 1m3s
The host-prefix regex required at least one character after the slash
(/\+.+/), so a pattern like 'code.example.com/' was silently dropped
instead of matching the root path '/'. Changed \+.+ to \+.* so the
trailing slash alone is accepted as the path '/'.

Added tests covering the exact bug scenario from PUBLIC_PATHS
config: 'code.digitaladapt.com/,code.digitaladapt.com/public/**'
2026-08-13 14:30:52 -04:00
lyra 235a7866b3 fix: remove incorrect CSS escaping on color values
Tests / test (pull_request) Successful in 1m5s
Push Develop / docker (push) Successful in 6m43s
Sync GitHub / sync (push) Successful in 8s
Tests / test (push) Successful in 54s
The Twig |e('css') filter was escaping '#' (0x23) to '\23 ' in hex
color values (bg_color, fg_color, error_color), causing browsers to
not recognize them as valid CSS colors. These are admin-configured
environment variables, not user input, so CSS escaping is unnecessary.
2026-08-13 12:07:20 -04:00
lyra c7585e720a feat: add self-bootstrapping dev server script (bin/dev.sh)
Tests / test (pull_request) Successful in 50s
Push Develop / docker (push) Successful in 8m39s
Sync GitHub / sync (push) Successful in 7s
Tests / test (push) Successful in 57s
Manages a local PHP dev server for end-to-end development and testing.
Binds to 0.0.0.0:8773, accessible via Caddy at
https://preauth.lyra-dev.devgnome.com.

Features:
- Self-bootstrapping: installs PHP 8.4 + extensions (including APCu,
  which is critical for nonce cache, rate limiter, and session storage),
  Composer, and project dependencies if missing. Survives terminal
  resets/reboots.
- Enables apc.enable_cli=1 for console commands (matches Dockerfile)
- Subcommands: start, stop, status, restart
- Sets APP_SHARE_DIR to var/share for filesystem session persistence
- Clears dev cache on start

No database needed — preauth uses APCu + filesystem cache exclusively.

Port assignment: P-R-E = 7-7-3 → 8773
2026-08-13 11:56:55 -04:00
21 changed files with 1839 additions and 838 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
Executable
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# PreAuth Dev Server Script
#
# Manages a local PHP dev server for end-to-end development and testing.
# Binds to 0.0.0.0 so the app is accessible via a reverse proxy (Caddy) for
# browser-based visual verification.
#
# PreAuth is a TOTP-based authentication gateway. It uses APCu for nonce/cache
# and filesystem for session persistence — no database needed. The dev server
# runs with APP_ENV=dev and APP_DEBUG=1 for live troubleshooting.
#
# Self-bootstrapping: the `start` command checks for required system packages
# (PHP, extensions, tools), Composer, and project dependencies — installing
# them automatically if missing. This means the script works even after a
# terminal reset/reboot, embracing the self-cleaning container design.
#
# Usage:
# bin/dev.sh start Start the dev server (auto-installs deps if needed)
# bin/dev.sh stop Stop the dev server
# bin/dev.sh status Check if the dev server is running
# bin/dev.sh restart Stop and start the dev server
#
# Port assignment (P-R-E = 7-7-3):
# 8773 → https://preauth.lyra-dev.devgnome.com
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
# ── Configuration ───────────────────────────────────────────────────────────
PORT=8773
HOST="0.0.0.0"
ENV="dev"
DEV_SECRET="dev_secret_not_for_production_use_only"
PID_FILE="var/.dev-server.pid"
LOG_FILE="var/log/dev-server.log"
# Required PHP extensions (checked via php -m)
REQUIRED_PHP_EXTS=(
ctype
iconv
mbstring
apcu
dom
SimpleXML
xml
)
# Apt packages for PHP + extensions
# 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
php8.4-common # ctype, iconv
php8.4-mbstring
php8.4-xml # dom, SimpleXML, xml
php8.4-opcache
php8.4-readline
php8.4-apcu # APCu — critical for nonce cache, rate limiter, sessions
)
# System tools needed
SYSTEM_TOOLS=(
git
unzip
curl
)
# Resolve project root (script lives in bin/)
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Ensure var directory structure exists
mkdir -p var/log var/share
# ── Helpers ─────────────────────────────────────────────────────────────────
is_running() {
if [[ ! -f "$PID_FILE" ]]; then
return 1
fi
local pid
pid="$(cat "$PID_FILE")"
if [[ -z "$pid" ]] || ! kill -0 "$pid" 2>/dev/null; then
return 1
fi
return 0
}
print_status() {
if is_running; then
local pid
pid="$(cat "$PID_FILE")"
echo "✅ PreAuth dev server is RUNNING"
echo " PID: $pid"
echo " URL: http://localhost:${PORT}"
echo " Exposed: http://${HOST}:${PORT}"
echo " Dev URL: https://preauth.lyra-dev.devgnome.com"
echo " Logs: ${LOG_FILE}"
else
echo "⛔ PreAuth dev server is STOPPED"
fi
}
# ── Bootstrap ───────────────────────────────────────────────────────────────
# Ensures all system packages, Composer, and project dependencies are present.
# Idempotent — if everything is already installed, checks are fast no-ops.
# This is what makes the script survive terminal resets/reboots.
bootstrap() {
local needed_packages=()
# ── Check system tools ──
for tool in "${SYSTEM_TOOLS[@]}"; do
if ! command -v "$tool" &>/dev/null; then
needed_packages+=("$tool")
fi
done
# ── Check PHP and required extensions ──
local php_needs_install=false
if ! command -v php &>/dev/null; then
php_needs_install=true
else
for ext in "${REQUIRED_PHP_EXTS[@]}"; do
if ! php -m 2>/dev/null | grep -iq "^${ext}$"; then
php_needs_install=true
break
fi
done
fi
if [[ "$php_needs_install" == "true" ]]; then
needed_packages+=("${PHP_APT_PACKAGES[@]}")
fi
# ── Install missing packages ──
if [[ ${#needed_packages[@]} -gt 0 ]]; then
echo "→ Installing missing system packages: ${needed_packages[*]}"
sudo apt-get update -qq
sudo apt-get install -y -qq "${needed_packages[@]}"
fi
# ── Ensure APCu is enabled for CLI ──
# PreAuth's console commands need APCu; the Dockerfile sets apc.enable_cli=1
local apcu_ini="/etc/php/8.4/mods-available/apcu.ini"
if [[ -f "$apcu_ini" ]] && ! grep -q 'apc.enable_cli' "$apcu_ini" 2>/dev/null; then
echo "→ Enabling APCu CLI support…"
echo 'apc.enable_cli=1' | sudo tee -a "$apcu_ini" >/dev/null
fi
# ── Ensure Composer is available ──
if ! command -v composer &>/dev/null; then
echo "→ Installing Composer…"
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
sudo chmod +x /usr/local/bin/composer
fi
# ── Ensure project dependencies are installed ──
if [[ ! -d "vendor/" ]]; then
echo "→ Installing Composer dependencies…"
APP_ENV=dev composer install --no-interaction
fi
}
# ── Commands ────────────────────────────────────────────────────────────────
start() {
if is_running; then
echo "⚠️ Dev server is already running (PID $(cat "$PID_FILE"))"
print_status
exit 0
fi
echo "→ Starting PreAuth dev server on ${HOST}:${PORT}"
# Self-bootstrap: ensure all dependencies are present
bootstrap
echo "→ Clearing dev cache…"
APP_ENV="$ENV" \
APP_DEBUG=1 \
APP_SECRET="$DEV_SECRET" \
php bin/console cache:clear 2>&1 | tail -3
echo "→ Starting PHP dev server…"
APP_ENV="$ENV" \
APP_DEBUG=1 \
APP_SECRET="$DEV_SECRET" \
APP_SHARE_DIR="${PROJECT_ROOT}/var/share" \
nohup php -S "${HOST}:${PORT}" -t public/ > "$LOG_FILE" 2>&1 &
local pid=$!
echo "$pid" > "$PID_FILE"
# Give it a moment to boot
sleep 2
if is_running; then
echo ""
print_status
else
echo "❌ Failed to start dev server. Check logs:"
echo " ${LOG_FILE}"
tail -20 "$LOG_FILE" 2>/dev/null || true
rm -f "$PID_FILE"
exit 1
fi
}
stop() {
if ! is_running; then
echo "⚠️ Dev server is not running."
rm -f "$PID_FILE"
exit 0
fi
local pid
pid="$(cat "$PID_FILE")"
echo "→ Stopping dev server (PID ${pid})…"
kill "$pid" 2>/dev/null || true
# Wait for graceful shutdown
local count=0
while kill -0 "$pid" 2>/dev/null && [[ $count -lt 10 ]]; do
sleep 0.5
count=$((count + 1))
done
# Force kill if still alive
if kill -0 "$pid" 2>/dev/null; then
echo "→ Process didn't exit gracefully, sending SIGKILL…"
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$PID_FILE"
echo "✅ Dev server stopped."
}
restart() {
stop
sleep 1
start
}
# ── Main ────────────────────────────────────────────────────────────────────
usage() {
echo "Usage: bin/dev.sh {start|stop|status|restart}"
echo ""
echo "Commands:"
echo " start Start the dev server (auto-installs deps if needed)"
echo " stop Stop the dev server"
echo " status Check if the dev server is running"
echo " restart Restart the dev server"
exit 1
}
case "${1:-}" in
start) start ;;
stop) stop ;;
status) print_status ;;
restart) restart ;;
*) usage ;;
esac
+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
+44 -5
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Listener;
use App\Service\DomainInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
@@ -15,6 +16,11 @@ use Symfony\Component\HttpKernel\Event\ResponseEvent;
*/
final readonly class SecurityHeadersListener
{
public function __construct(
private DomainInterface $domainManager,
) {
}
#[AsEventListener(priority: 0)]
public function onKernelResponse(ResponseEvent $event): void
{
@@ -36,13 +42,46 @@ final readonly class SecurityHeadersListener
/* Content-Security-Policy — the login page uses inline styles
* and scripts (via Twig includes), so we allow 'unsafe-inline'
* for those. No external resources are loaded. */
$headers->set(
'Content-Security-Policy',
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';"
);
* for those. No external resources are loaded.
*
* When subdomain redirection is off (or the request is not on
* the auth subdomain), the login form is served inline on the
* protected host and submission is performed via a same-origin
* fetch() call in _script.html.twig. That fetch is blocked by
* the default 'none' policy, so we add connect-src 'self' only
* in that case — the least privilege needed to make the form
* work. On the auth subdomain the form POSTs normally and no
* inline script is included, so the stricter policy applies. */
$inlineScript = $this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost();
$csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';";
if ($inlineScript) {
$csp .= " connect-src 'self';";
}
$headers->set('Content-Security-Policy', $csp);
/* 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', '*');
}
}
}
+1 -1
View File
@@ -79,7 +79,7 @@ final readonly class PublicPathMatcher implements PublicPathMatcherInterface
$host = null;
$path = $entry;
if (preg_match('/^([a-z0-9.-]+)(\/.+)$/i', $entry, $m)) {
if (preg_match('/^([a-z0-9.-]+)(\/.*)$/i', $entry, $m)) {
$host = strtolower($m[1]);
$path = $m[2];
}
+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 -%}
+2 -2
View File
@@ -1,10 +1,10 @@
<style id="preauth-style">
* { margin: 0; padding: 0.25em; }
html { background-color: {{ env.bg_color|e('css') }}; color: {{ env.fg_color|e('css') }}; display: table;
html { background-color: {{ env.bg_color }}; color: {{ env.fg_color }}; display: table;
font-family: sans-serif; font-size: 1.5em; height: 100%; padding: 0; width: 100%; }
body { display: table-cell; vertical-align: middle; }
h1 { font-size: 2.5em; font-weight: normal; text-align: center; }
p { color: {{ env.error_color|e('css') }}; text-align: center; }
p { color: {{ env.error_color }}; text-align: center; }
form { align-items: baseline; display: flex; flex-wrap: wrap; justify-content: center; }
form div { width: 45%; min-width: 300px; }
div.right { text-align: right; margin-top: 1em; padding-bottom: 0 }
+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'));
}
}
@@ -176,6 +176,25 @@ final class PublicPathMatcherTest extends TestCase
self::assertFalse($matcher->matches('other.host', '/public/repo'));
}
public function testDomainPrefixedRootPathMatchesRoot(): void
{
// host/ — the trailing slash is the entire path, nothing after it
$matcher = new PublicPathMatcher('code.example.com/');
self::assertTrue($matcher->matches('code.example.com', '/'));
self::assertFalse($matcher->matches('code.example.com', '/public'));
self::assertFalse($matcher->matches('other.example.com', '/'));
}
public function testDomainPrefixedRootWithOtherPatterns(): void
{
// The exact scenario from the bug report
$matcher = new PublicPathMatcher('code.example.com/,code.example.com/public/**');
self::assertTrue($matcher->matches('code.example.com', '/'));
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
self::assertFalse($matcher->matches('code.example.com', '/private'));
self::assertFalse($matcher->matches('other.example.com', '/'));
}
public function testDomainPrefixIsCaseInsensitive(): void
{
$matcher = new PublicPathMatcher('Code.Example.COM/public/**');