Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b75a16a781 | ||
|
|
9111958bcf | ||
|
|
95dc6bf0ce | ||
|
|
472abfdf89 | ||
|
|
e2780ca5f6 | ||
|
|
7a68c933ce | ||
|
|
55f8e9e84c | ||
|
|
e3cd8c6739 | ||
|
|
235a7866b3 | ||
|
|
c7585e720a | ||
|
|
66b960ccea | ||
|
|
17c2d525ff | ||
|
|
29e471c536 | ||
|
|
5563999525 |
@@ -12,6 +12,11 @@ BURST_COUNT=10
|
|||||||
BURST_TIME=30
|
BURST_TIME=30
|
||||||
UPPER_COUNT=100
|
UPPER_COUNT=100
|
||||||
UPPER_TIME=3600
|
UPPER_TIME=3600
|
||||||
|
PUBLIC_PATHS=''
|
||||||
|
PUBLIC_BURST_COUNT=100
|
||||||
|
PUBLIC_BURST_TIME=60
|
||||||
|
PUBLIC_UPPER_COUNT=500
|
||||||
|
PUBLIC_UPPER_TIME=3600
|
||||||
TITLE='Pre-Authentication System'
|
TITLE='Pre-Authentication System'
|
||||||
BG_COLOR='#029386'
|
BG_COLOR='#029386'
|
||||||
FG_COLOR='#ffffff'
|
FG_COLOR='#ffffff'
|
||||||
|
|||||||
@@ -26,6 +26,24 @@ jobs:
|
|||||||
coverage: xdebug
|
coverage: xdebug
|
||||||
ini-values: apc.enable_cli=1
|
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
|
- name: Install dependencies
|
||||||
run: composer install --prefer-dist --no-progress
|
run: composer install --prefer-dist --no-progress
|
||||||
|
|
||||||
|
|||||||
+21
-1
@@ -5,7 +5,27 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased] — v1.1
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Public rate-limited access** — Select paths can now be made publicly
|
||||||
|
accessible without TOTP authentication, with separate per-IP rate limiting.
|
||||||
|
This is useful for exposing public content (e.g., public Gitea repositories)
|
||||||
|
while protecting server resources from bot traffic.
|
||||||
|
- New `PUBLIC_PATHS` env var: comma-separated path patterns with `*` (single
|
||||||
|
segment) and `**` (cross-segment) wildcard support. Optional host prefix
|
||||||
|
(e.g., `code.example.com/public/**`). When empty (default), the feature
|
||||||
|
is fully disabled.
|
||||||
|
- New `PUBLIC_BURST_COUNT` / `PUBLIC_BURST_TIME` env vars for burst rate
|
||||||
|
limiting (default: 100 requests per 60 seconds).
|
||||||
|
- New `PUBLIC_UPPER_COUNT` / `PUBLIC_UPPER_TIME` env vars for sustained
|
||||||
|
rate limiting (default: 500 requests per 3600 seconds).
|
||||||
|
- Authenticated users bypass the public rate limiter entirely.
|
||||||
|
- Over-limit responses include a `Retry-After` header.
|
||||||
|
- New `PublicPathMatcher` service for path pattern matching.
|
||||||
|
- New `PublicAccessListener` (priority 84) in the request pipeline.
|
||||||
|
|
||||||
|
## [1.0.0] — v1.0 Release
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- Made `Remote-User` header value configurable via `REMOTE_USER` environment
|
- Made `Remote-User` header value configurable via `REMOTE_USER` environment
|
||||||
|
|||||||
+40
-47
@@ -38,12 +38,16 @@ Client → Caddy → forward_auth → Preauth listeners (priority order) → 200
|
|||||||
If found → `200 OK` + `Remote-User` header → Caddy proxies to backend.
|
If found → `200 OK` + `Remote-User` header → Caddy proxies to backend.
|
||||||
2. **AllowListener** (priority 88) — If `IP_TTL` is enabled, checks for
|
2. **AllowListener** (priority 88) — If `IP_TTL` is enabled, checks for
|
||||||
valid IP-based session. If found → `200 OK` + `Remote-User`.
|
valid IP-based session. If found → `200 OK` + `Remote-User`.
|
||||||
3. **RejectListener** (priority 77) — Rate-limiting gate. If IP has
|
3. **PublicAccessListener** (priority 84) — If `PUBLIC_PATHS` is
|
||||||
|
configured and the request matches a public path pattern, applies
|
||||||
|
per-IP rate limiting. Within limit → `200 OK`. Over limit → `429`.
|
||||||
|
Authenticated users never reach this listener.
|
||||||
|
4. **RejectListener** (priority 77) — Rate-limiting gate. If IP has
|
||||||
exceeded login attempt threshold → `418 I'm a Teapot` (or `429`).
|
exceeded login attempt threshold → `418 I'm a Teapot` (or `429`).
|
||||||
4. **LoginListener** (priority 66) — Detects login attempts via
|
5. **LoginListener** (priority 66) — Detects login attempts via
|
||||||
`X-Preauth` header (base64url JSON) or POST form on auth subdomain.
|
`X-Preauth` header (base64url JSON) or POST form on auth subdomain.
|
||||||
Validates TOTP/backup codes through `LoginManager`.
|
Validates TOTP/backup codes through `LoginManager`.
|
||||||
5. **InterceptListener** (priority 55) — Fallback: if no listener has
|
6. **InterceptListener** (priority 55) — Fallback: if no listener has
|
||||||
set a response, either redirects to auth subdomain (central auth) or
|
set a response, either redirects to auth subdomain (central auth) or
|
||||||
renders the Twig login page with a fresh nonce.
|
renders the Twig login page with a fresh nonce.
|
||||||
|
|
||||||
@@ -76,8 +80,8 @@ Client → Caddy → forward_auth → Preauth listeners (priority order) → 200
|
|||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
|--------------|--------------------------------|
|
|--------------|--------------------------------|
|
||||||
| **Tests** | 222 |
|
| **Tests** | 293 |
|
||||||
| **Assertions** | 469 |
|
| **Assertions** | 605 |
|
||||||
| **Pass** | 222 (100%) |
|
| **Pass** | 222 (100%) |
|
||||||
| **Fail** | 0 |
|
| **Fail** | 0 |
|
||||||
| **Errors** | 0 |
|
| **Errors** | 0 |
|
||||||
@@ -109,12 +113,14 @@ Every class, method, and line in `src/` is covered.
|
|||||||
| `Data/Payload.php` | `Unit/Data/PayloadTest.php` | Unit |
|
| `Data/Payload.php` | `Unit/Data/PayloadTest.php` | Unit |
|
||||||
| `Enum/Scope.php` | `Unit/Enum/ScopeTest.php` | Unit |
|
| `Enum/Scope.php` | `Unit/Enum/ScopeTest.php` | Unit |
|
||||||
| `Listener/AcceptListener.php` | `Unit/Listener/AcceptListenerTest.php` | Unit |
|
| `Listener/AcceptListener.php` | `Unit/Listener/AcceptListenerTest.php` | Unit |
|
||||||
|
| `Listener/PublicAccessListener.php` | `Unit/Listener/PublicAccessListenerTest.php` | Unit |
|
||||||
| `Listener/AllowListener.php` | `Unit/Listener/AllowListenerTest.php` | Unit |
|
| `Listener/AllowListener.php` | `Unit/Listener/AllowListenerTest.php` | Unit |
|
||||||
| `Listener/InterceptListener.php` | `Unit/Listener/InterceptListenerTest.php` | Unit |
|
| `Listener/InterceptListener.php` | `Unit/Listener/InterceptListenerTest.php` | Unit |
|
||||||
| `Listener/LoginListener.php` | `Unit/Listener/LoginListenerTest.php` | Unit |
|
| `Listener/LoginListener.php` | `Unit/Listener/LoginListenerTest.php` | Unit |
|
||||||
| `Listener/RejectListener.php` | `Unit/Listener/RejectListenerTest.php` | Unit |
|
| `Listener/RejectListener.php` | `Unit/Listener/RejectListenerTest.php` | Unit |
|
||||||
| `Service/BackupCodeManager.php` | `Unit/Service/BackupCodeManagerTest.php` | Unit |
|
| `Service/BackupCodeManager.php` | `Unit/Service/BackupCodeManagerTest.php` | Unit |
|
||||||
| `Service/DomainManager.php` | `Unit/Service/DomainManagerTest.php` | Unit |
|
| `Service/DomainManager.php` | `Unit/Service/DomainManagerTest.php` | Unit |
|
||||||
|
| `Service/PublicPathMatcher.php` | `Unit/Service/PublicPathMatcherTest.php` | Unit |
|
||||||
| `Service/LoginManager.php` | `Unit/Service/LoginManagerTest.php` | Unit |
|
| `Service/LoginManager.php` | `Unit/Service/LoginManagerTest.php` | Unit |
|
||||||
| `Trait/CookieNameTrait.php` | `Unit/Trait/CookieNameTraitTest.php` | Unit |
|
| `Trait/CookieNameTrait.php` | `Unit/Trait/CookieNameTraitTest.php` | Unit |
|
||||||
| `Trait/GetTotpTrait.php` | `Unit/Trait/GetTotpTraitTest.php` | Unit |
|
| `Trait/GetTotpTrait.php` | `Unit/Trait/GetTotpTraitTest.php` | Unit |
|
||||||
@@ -122,6 +128,7 @@ Every class, method, and line in `src/` is covered.
|
|||||||
| `Trait/MakeNonceTrait.php` | `Unit/Trait/MakeNonceTraitTest.php` | Unit |
|
| `Trait/MakeNonceTrait.php` | `Unit/Trait/MakeNonceTraitTest.php` | Unit |
|
||||||
| `Trait/StringTrait.php` | `Unit/Trait/StringTraitTest.php` | Unit |
|
| `Trait/StringTrait.php` | `Unit/Trait/StringTraitTest.php` | Unit |
|
||||||
| *(All listeners + services)* | `Functional/AuthenticationFlowTest.php` | Functional |
|
| *(All listeners + services)* | `Functional/AuthenticationFlowTest.php` | Functional |
|
||||||
|
| *(Public access flow)* | `Functional/PublicAccessFlowTest.php` | Functional |
|
||||||
|
|
||||||
### Test Quality Assessment
|
### Test Quality Assessment
|
||||||
|
|
||||||
@@ -157,7 +164,7 @@ Every class, method, and line in `src/` is covered.
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### Phase 1 — Public but Rate-Limited Access ✦
|
### Phase 1 — Public but Rate-Limited Access ✅ Completed (v1.1)
|
||||||
|
|
||||||
**Goal:** Allow select services to be publicly accessible (no TOTP
|
**Goal:** Allow select services to be publicly accessible (no TOTP
|
||||||
required) but with aggressive per-IP rate limiting to prevent bot
|
required) but with aggressive per-IP rate limiting to prevent bot
|
||||||
@@ -169,53 +176,39 @@ bandwidth, forcing it back to fully private. The solution isn't more
|
|||||||
authentication — it's bandwidth/resource protection for public-facing
|
authentication — it's bandwidth/resource protection for public-facing
|
||||||
services.
|
services.
|
||||||
|
|
||||||
**Design:**
|
**Implementation:**
|
||||||
|
|
||||||
- New config variables:
|
- New config variables:
|
||||||
- `PUBLIC_MODE=false` — Enable public access for specific services
|
- `PUBLIC_PATHS` — Comma-separated path patterns with `*` (single
|
||||||
- `PUBLIC_RATE_LIMIT=10` — Max requests per minute from a single IP
|
segment) and `**` (cross-segment) wildcard support. Optional host
|
||||||
on public paths
|
prefix (e.g., `code.example.com/public/**`). When empty (default),
|
||||||
- `PUBLIC_RATE_WINDOW=60` — Sliding window in seconds
|
the feature is fully disabled.
|
||||||
- `PUBLIC_BURST=20` — Allow short bursts above the sustained rate
|
- `PUBLIC_BURST_COUNT` / `PUBLIC_BURST_TIME` — Burst rate limiting
|
||||||
|
(default: 100 requests per 60 seconds).
|
||||||
|
- `PUBLIC_UPPER_COUNT` / `PUBLIC_UPPER_TIME` — Sustained rate limiting
|
||||||
|
(default: 500 requests per 3600 seconds).
|
||||||
|
|
||||||
- New listener: **PublicListener** (priority 95, between AcceptListener
|
- New listener: **PublicAccessListener** (priority 84, after
|
||||||
and AllowListener):
|
AcceptListener and AllowListener, before RejectListener):
|
||||||
- Checks if the request matches a public path pattern (configured per
|
- Checks if the request path matches a configured public path pattern.
|
||||||
service via Caddy's `forward_auth` URI or a header like
|
- If public and within rate limit → `200 OK` (no `Remote-User` header).
|
||||||
`X-Preauth-Public: true`).
|
- If public and over rate limit → `429 Too Many Requests` with
|
||||||
- If public mode is enabled for this request, applies aggressive
|
`Retry-After` header.
|
||||||
per-IP rate limiting (separate from the login rate limiter).
|
- Authenticated users bypass this listener entirely (AcceptListener
|
||||||
- If within rate limit → `200 OK` (no `Remote-User` header, or a
|
or AllowListener returns 200 first).
|
||||||
`Remote-User: public` marker).
|
|
||||||
- If over rate limit → `429 Too Many Requests` with `Retry-After`
|
|
||||||
header.
|
|
||||||
|
|
||||||
- Caddy config would use different `forward_auth` snippets for public
|
- New service: **PublicPathMatcher** — Parses path patterns and matches
|
||||||
vs. protected services:
|
request paths with wildcard support.
|
||||||
```caddyfile
|
|
||||||
# Protected service — requires TOTP
|
|
||||||
bitwarden.example.com {
|
|
||||||
forward_auth preauth { copy_headers Remote-User }
|
|
||||||
reverse_proxy bitwarden:80
|
|
||||||
}
|
|
||||||
|
|
||||||
# Public but rate-limited service
|
|
||||||
git.example.com {
|
|
||||||
forward_auth preauth/public { copy_headers Remote-User }
|
|
||||||
reverse_proxy gitea:3000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Consider integration with Caddy's own rate limiting as a second layer
|
- Separate `public_limiter` compound rate limiter (independent from
|
||||||
of defense (rate limit at the reverse proxy before traffic even hits
|
the login attempt rate limiter).
|
||||||
preauth).
|
|
||||||
|
|
||||||
- [ ] Design public path detection mechanism (URI-based or header-based)
|
- [x] Design public path detection mechanism (path-based with wildcards)
|
||||||
- [ ] Implement `PublicListener` with separate rate limiter pool
|
- [x] Implement `PublicAccessListener` with separate rate limiter pool
|
||||||
- [ ] Add config variables and defaults
|
- [x] Add config variables and defaults
|
||||||
- [ ] Update Caddyfile example with public service snippet
|
- [x] Update Caddyfile example with public service snippet
|
||||||
- [ ] Tests for public mode (within limit, over limit, burst behavior)
|
- [x] Tests for public mode (within limit, over limit, burst behavior)
|
||||||
- [ ] Documentation in README
|
- [x] Documentation in README
|
||||||
|
|
||||||
### Phase 2 — Session Management & Audit
|
### Phase 2 — Session Management & Audit
|
||||||
|
|
||||||
|
|||||||
Executable
+265
@@ -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 7.4 which requires PHP >=8.1.
|
||||||
|
# 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
|
||||||
@@ -10,6 +10,8 @@ framework:
|
|||||||
adapters: cache.adapter.apcu
|
adapters: cache.adapter.apcu
|
||||||
sessionStorage:
|
sessionStorage:
|
||||||
adapters: cache.adapter.filesystem
|
adapters: cache.adapter.filesystem
|
||||||
|
publicRateLimitCache:
|
||||||
|
adapters: cache.adapter.apcu
|
||||||
|
|
||||||
# Unique name of your app: used to compute stable namespaces for cache keys.
|
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||||
prefix_seed: digitaladapt/preauth
|
prefix_seed: digitaladapt/preauth
|
||||||
|
|||||||
@@ -13,3 +13,17 @@ framework:
|
|||||||
login_limiter:
|
login_limiter:
|
||||||
policy: compound
|
policy: compound
|
||||||
limiters: [burst, upper]
|
limiters: [burst, upper]
|
||||||
|
|
||||||
|
public_burst:
|
||||||
|
policy: 'sliding_window'
|
||||||
|
limit: '%env(int:PUBLIC_BURST_COUNT)%'
|
||||||
|
interval: '%env(int:PUBLIC_BURST_TIME)% seconds'
|
||||||
|
cache_pool: 'publicRateLimitCache'
|
||||||
|
public_upper:
|
||||||
|
policy: 'sliding_window'
|
||||||
|
limit: '%env(int:PUBLIC_UPPER_COUNT)%'
|
||||||
|
interval: '%env(int:PUBLIC_UPPER_TIME)% seconds'
|
||||||
|
cache_pool: 'publicRateLimitCache'
|
||||||
|
public_limiter:
|
||||||
|
policy: compound
|
||||||
|
limiters: [public_burst, public_upper]
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ framework:
|
|||||||
adapters: cache.adapter.array
|
adapters: cache.adapter.array
|
||||||
sessionStorage:
|
sessionStorage:
|
||||||
adapters: cache.adapter.array
|
adapters: cache.adapter.array
|
||||||
|
publicRateLimitCache:
|
||||||
|
adapters: cache.adapter.array
|
||||||
|
|||||||
@@ -45,6 +45,16 @@ parameters:
|
|||||||
env(UPPER_COUNT): 10 # 10 per hour
|
env(UPPER_COUNT): 10 # 10 per hour
|
||||||
env(UPPER_TIME): 3600 # seconds (1 hour)
|
env(UPPER_TIME): 3600 # seconds (1 hour)
|
||||||
|
|
||||||
|
# --- public access (rate-limited, no auth required) ---
|
||||||
|
# Comma-separated path patterns for public access. Wildcards: * (single
|
||||||
|
# segment), ** (cross segments). Optional host prefix: host.com/path/**
|
||||||
|
# When empty (default), the feature is fully disabled.
|
||||||
|
env(PUBLIC_PATHS): ''
|
||||||
|
env(PUBLIC_BURST_COUNT): 100 # max requests per burst window per IP
|
||||||
|
env(PUBLIC_BURST_TIME): 60 # burst window in seconds
|
||||||
|
env(PUBLIC_UPPER_COUNT): 500 # max requests per sustained window per IP
|
||||||
|
env(PUBLIC_UPPER_TIME): 3600 # sustained window in seconds (1 hour)
|
||||||
|
|
||||||
# --- styling options ---
|
# --- styling options ---
|
||||||
env(TITLE): 'Pre-Authentication System'
|
env(TITLE): 'Pre-Authentication System'
|
||||||
env(BG_COLOR): '#029386' # teal
|
env(BG_COLOR): '#029386' # teal
|
||||||
@@ -77,6 +87,12 @@ parameters:
|
|||||||
app.remote_user_static: '%env(REMOTE_USER_STATIC)%'
|
app.remote_user_static: '%env(REMOTE_USER_STATIC)%'
|
||||||
app.remote_user_map: '%env(REMOTE_USER_MAP)%'
|
app.remote_user_map: '%env(REMOTE_USER_MAP)%'
|
||||||
|
|
||||||
|
app.public_paths: '%env(PUBLIC_PATHS)%'
|
||||||
|
app.public_burst_count: '%env(int:PUBLIC_BURST_COUNT)%'
|
||||||
|
app.public_burst_time: '%env(int:PUBLIC_BURST_TIME)%'
|
||||||
|
app.public_upper_count: '%env(int:PUBLIC_UPPER_COUNT)%'
|
||||||
|
app.public_upper_time: '%env(int:PUBLIC_UPPER_TIME)%'
|
||||||
|
|
||||||
app.error_message: '%env(ERROR_MESSAGE)%'
|
app.error_message: '%env(ERROR_MESSAGE)%'
|
||||||
app.teapot_title: '%env(TEAPOT_TITLE)%'
|
app.teapot_title: '%env(TEAPOT_TITLE)%'
|
||||||
app.too_many_title: '%env(TOO_MANY_TITLE)%'
|
app.too_many_title: '%env(TOO_MANY_TITLE)%'
|
||||||
|
|||||||
@@ -26,3 +26,25 @@ protected.example.com {
|
|||||||
auth.example.com {
|
auth.example.com {
|
||||||
reverse_proxy preauth
|
reverse_proxy preauth
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- public rate-limited access (v1.1) ---
|
||||||
|
# Configure PUBLIC_PATHS env var to specify which paths are public.
|
||||||
|
# Example: PUBLIC_PATHS=/public/**
|
||||||
|
# Unauthenticated visitors to public paths are rate-limited separately
|
||||||
|
# from login attempts. Authenticated users bypass the public rate limiter.
|
||||||
|
#
|
||||||
|
# This example protects all of Gitea except /public/** which is
|
||||||
|
# publicly accessible but rate-limited (e.g., 100 req/min, 500 req/hr).
|
||||||
|
git.example.com {
|
||||||
|
forward_auth preauth {
|
||||||
|
uri {uri}
|
||||||
|
copy_headers Remote-User
|
||||||
|
}
|
||||||
|
reverse_proxy gitea:3000
|
||||||
|
}
|
||||||
|
# In preauth's .env:
|
||||||
|
# PUBLIC_PATHS=/public/**
|
||||||
|
# PUBLIC_BURST_COUNT=100
|
||||||
|
# PUBLIC_BURST_TIME=60
|
||||||
|
# PUBLIC_UPPER_COUNT=500
|
||||||
|
# PUBLIC_UPPER_TIME=3600
|
||||||
|
|||||||
@@ -43,6 +43,18 @@
|
|||||||
#UPPER_COUNT=10 # 10 per hour
|
#UPPER_COUNT=10 # 10 per hour
|
||||||
#UPPER_TIME=3600 # seconds (1 hour)
|
#UPPER_TIME=3600 # seconds (1 hour)
|
||||||
|
|
||||||
|
# --- public access (rate-limited, no auth required) ---
|
||||||
|
# Comma-separated path patterns for public access. Wildcards:
|
||||||
|
# * matches any chars within one path segment (not crossing /)
|
||||||
|
# ** matches any chars including / (crosses path segments)
|
||||||
|
# Optional host prefix: host.example.com/path/**
|
||||||
|
# When empty (default), the feature is fully disabled.
|
||||||
|
#PUBLIC_PATHS=''
|
||||||
|
#PUBLIC_BURST_COUNT=100 # max requests per burst window per IP
|
||||||
|
#PUBLIC_BURST_TIME=60 # burst window in seconds
|
||||||
|
#PUBLIC_UPPER_COUNT=500 # max requests per sustained window per IP
|
||||||
|
#PUBLIC_UPPER_TIME=3600 # sustained window in seconds (1 hour)
|
||||||
|
|
||||||
# --- styling options ---
|
# --- styling options ---
|
||||||
|
|
||||||
#TITLE='Pre-Authentication System'
|
#TITLE='Pre-Authentication System'
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
# v1.1 Plan — Public Rate-Limited Access
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Allow preauth to provide rate-limited unauthenticated access to select
|
||||||
|
public paths. Authenticated users bypass the public rate limiter entirely.
|
||||||
|
Non-public paths continue to trigger the existing auth flow.
|
||||||
|
|
||||||
|
**Practical example:** Allow anyone to visit
|
||||||
|
`https://code.devgnome.com/public/*` in Gitea, but limit them to 100
|
||||||
|
requests/minute and 500 requests/hour per IP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
When a request arrives and the user is **not authenticated** (no valid
|
||||||
|
cookie or IP session), the new `PublicAccessListener` checks whether the
|
||||||
|
request path matches any configured public path pattern. If it does:
|
||||||
|
|
||||||
|
1. The public rate limiter is consulted (separate from the login limiter).
|
||||||
|
2. If within limits → `200 OK` (no `Remote-User` header). Caddy proxies
|
||||||
|
to the backend.
|
||||||
|
3. If over limits → `429 Too Many Requests` with a `Retry-After` header.
|
||||||
|
|
||||||
|
If the path does **not** match any public pattern, the request falls
|
||||||
|
through to the existing auth flow (RejectListener → LoginListener →
|
||||||
|
InterceptListener → login page or redirect).
|
||||||
|
|
||||||
|
**Authenticated users** never reach the `PublicAccessListener` because
|
||||||
|
`AcceptListener` (priority 99) or `AllowListener` (priority 88) will have
|
||||||
|
already set a `200` response before `PublicAccessListener` runs.
|
||||||
|
|
||||||
|
### Listener Priority Chain (updated)
|
||||||
|
|
||||||
|
```
|
||||||
|
Priority Listener Action
|
||||||
|
──────── ───────────────── ──────────────────────────────────────
|
||||||
|
99 AcceptListener Valid cookie → 200 OK
|
||||||
|
88 AllowListener Valid IP session → 200 OK
|
||||||
|
84 PublicAccessListener Public path + rate limit check → 200 or 429
|
||||||
|
77 RejectListener Login rate-limit gate → 418/429
|
||||||
|
66 LoginListener Login attempt handling
|
||||||
|
55 InterceptListener Fallback → redirect or login page
|
||||||
|
```
|
||||||
|
|
||||||
|
`PublicAccessListener` runs at priority 84 — after auth checks (so
|
||||||
|
authenticated users bypass it) but before `RejectListener` (so public
|
||||||
|
access is not subject to the login rate limiter).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `PUBLIC_PATHS` | `''` (disabled) | Comma-separated path patterns. Wildcard `*` supported. |
|
||||||
|
| `PUBLIC_BURST_COUNT` | `100` | Max requests per burst window per IP |
|
||||||
|
| `PUBLIC_BURST_TIME` | `60` | Burst window in seconds |
|
||||||
|
| `PUBLIC_UPPER_COUNT` | `500` | Max requests per sustained window per IP |
|
||||||
|
| `PUBLIC_UPPER_TIME` | `3600` | Sustained window in seconds (1 hour) |
|
||||||
|
|
||||||
|
**When `PUBLIC_PATHS` is empty (default), the feature is completely
|
||||||
|
disabled and has zero effect on existing behavior.**
|
||||||
|
|
||||||
|
### Path Pattern Syntax
|
||||||
|
|
||||||
|
- Patterns are matched against the request **path** only (query string
|
||||||
|
is ignored).
|
||||||
|
- Patterns must start with `/`.
|
||||||
|
- `*` matches any sequence of characters within a single path segment
|
||||||
|
(not crossing `/`).
|
||||||
|
- `**` matches any sequence of characters including `/` (crosses path
|
||||||
|
segments).
|
||||||
|
- No other regex or special characters are supported — patterns are
|
||||||
|
literal strings with `*` wildcards.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
| Pattern | Matches | Does NOT match |
|
||||||
|
|---------|---------|----------------|
|
||||||
|
| `/public` | `/public` | `/public/`, `/public/xyz` |
|
||||||
|
| `/public/*` | `/public/anything`, `/public/xyz` | `/public`, `/public/a/b` |
|
||||||
|
| `/public/**` | `/public/anything`, `/public/a/b/c` | `/public` |
|
||||||
|
| `/public` | `/public` | `/public/xyz` |
|
||||||
|
| `/api/*/status` | `/api/v1/status`, `/api/v2/status` | `/api/v1/v2/status` |
|
||||||
|
|
||||||
|
### Domain-Scoped Paths (when using auth subdomain)
|
||||||
|
|
||||||
|
When `SUBDOMAIN_REDIRECT=true` and `AUTH_SUBDOMAIN` is set, the user may
|
||||||
|
want public paths on specific subdomains only. In this case, `PUBLIC_PATHS`
|
||||||
|
can optionally include a domain prefix:
|
||||||
|
|
||||||
|
```
|
||||||
|
PUBLIC_PATHS='code.devgnome.com/public/**,auth.devgnome.com/health'
|
||||||
|
```
|
||||||
|
|
||||||
|
When no domain prefix is given, the path matches on **any** host. When a
|
||||||
|
domain prefix is given, it only matches on that specific host.
|
||||||
|
|
||||||
|
When **not** using an auth subdomain (the common case), paths without a
|
||||||
|
domain prefix match on all hosts. Domain-prefixed entries can still be
|
||||||
|
used to restrict to specific hosts.
|
||||||
|
|
||||||
|
### Rate Limiter
|
||||||
|
|
||||||
|
A new `public_limiter` compound rate limiter is added to
|
||||||
|
`rate_limiter.yaml`, following the same pattern as the existing
|
||||||
|
`login_limiter`. It uses a `publicRateLimitCache` pool (APCu in
|
||||||
|
production, array adapter in tests).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `src/Service/PublicPathMatcher.php` | Service that parses `PUBLIC_PATHS` and matches request paths against patterns |
|
||||||
|
| `src/Service/PublicPathMatcherInterface.php` | Interface for testability |
|
||||||
|
| `src/Listener/PublicAccessListener.php` | Listener that checks public paths and applies rate limiting |
|
||||||
|
|
||||||
|
## Modified Files
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `config/services.yaml` | Add `PUBLIC_PATHS` and related env vars + parameters |
|
||||||
|
| `config/packages/rate_limiter.yaml` | Add `public_burst`, `public_upper`, `public_limiter` |
|
||||||
|
| `config/packages/cache.yaml` | Add `publicRateLimitCache` pool |
|
||||||
|
| `config/packages/test/cache.yaml` | Add `publicRateLimitCache` pool (array adapter) |
|
||||||
|
| `src/ConfigBag.php` | Add `publicPaths()` method returning parsed path patterns |
|
||||||
|
| `tests/TestKernel.php` | Add `publicRateLimitCache` to reset exclusion list |
|
||||||
|
| `tests/Support/ListenerTestHelper.php` | Add helper for public rate limiter factory |
|
||||||
|
| `docs/example.env` | Document new env vars |
|
||||||
|
| `.env.test` | Add test defaults for public paths vars |
|
||||||
|
| `.env` | Add dev defaults for public paths vars |
|
||||||
|
| `docs/Caddyfile` | Add example of public + protected service config |
|
||||||
|
| `CHANGELOG.md` | Add v1.1 section |
|
||||||
|
| `ROADMAP.md` | Mark Phase 1 as in-progress / completed |
|
||||||
|
| `readme.md` | Document public access feature |
|
||||||
|
|
||||||
|
## New Test Files
|
||||||
|
|
||||||
|
| File | Coverage |
|
||||||
|
|------|----------|
|
||||||
|
| `tests/Unit/Service/PublicPathMatcherTest.php` | Pattern parsing, matching, wildcards, domain scoping |
|
||||||
|
| `tests/Unit/Listener/PublicAccessListenerTest.php` | Listener logic: public path match → 200, non-public → pass through, rate limited → 429, authenticated → not reached |
|
||||||
|
| `tests/Functional/PublicAccessFlowTest.php` | End-to-end: public path accessible, rate limit enforced, non-public path shows login, authenticated user bypasses public rate limit |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. **`PublicPathMatcher`** — Pure path matching logic, no dependencies.
|
||||||
|
Parse the `PUBLIC_PATHS` string into pattern entries (each with
|
||||||
|
optional host + path pattern). Convert `*`/`**` wildcards to regex.
|
||||||
|
Match a given (host, path) against all patterns.
|
||||||
|
|
||||||
|
2. **Config** — Add env vars to `services.yaml`, add rate limiter to
|
||||||
|
`rate_limiter.yaml`, add cache pool to `cache.yaml` + test cache.
|
||||||
|
|
||||||
|
3. **`ConfigBag`** — Add `publicPaths()` returning the raw string (the
|
||||||
|
`PublicPathMatcher` does the parsing). Or add the `PublicPathMatcher`
|
||||||
|
as a service that receives the raw string via autowiring.
|
||||||
|
|
||||||
|
4. **`PublicAccessListener`** — Inject `PublicPathMatcherInterface`,
|
||||||
|
`RateLimiterFactoryInterface` (target `public_limiter`), and
|
||||||
|
`ConfigBag`. On `RequestEvent`:
|
||||||
|
- If no public paths configured → return immediately.
|
||||||
|
- If request already has a response → return (auth listeners ran first).
|
||||||
|
- Check if (host, path) matches any public pattern.
|
||||||
|
- If no match → return (fall through to auth flow).
|
||||||
|
- If match → consume(1) from public rate limiter.
|
||||||
|
- If over limit → set 429 response with `Retry-After`.
|
||||||
|
- If within limit → set 200 response (plain text, no `Remote-User`).
|
||||||
|
|
||||||
|
5. **Tests** — Unit tests for `PublicPathMatcher` and
|
||||||
|
`PublicAccessListener`, functional tests for the full flow.
|
||||||
|
|
||||||
|
6. **Documentation** — Update all docs.
|
||||||
|
|
||||||
|
7. **Lint + Test** — Run php-cs-fixer + phpunit, fix any issues.
|
||||||
|
|
||||||
|
8. **Commit + Push + PR.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### Why priority 84?
|
||||||
|
|
||||||
|
- Must be **after** `AcceptListener` (99) and `AllowListener` (88) so
|
||||||
|
authenticated users never hit the public rate limiter.
|
||||||
|
- Must be **before** `RejectListener` (77) so public access is not
|
||||||
|
blocked by the login attempt rate limiter.
|
||||||
|
- Must be **before** `LoginListener` (66) so login attempts on public
|
||||||
|
paths are still processed (though this is an edge case — a login
|
||||||
|
attempt on a public path would set a response in `PublicAccessListener`
|
||||||
|
before `LoginListener` runs, which is correct: you don't need to login
|
||||||
|
to access a public path).
|
||||||
|
|
||||||
|
**Wait — actually this is a problem.** If someone sends an `X-Preauth`
|
||||||
|
header on a public path, `PublicAccessListener` would return 200 before
|
||||||
|
`LoginListener` can process the login. But that's actually fine — if the
|
||||||
|
path is public, they don't need to log in. If they want to authenticate,
|
||||||
|
they can visit a non-public path.
|
||||||
|
|
||||||
|
**Revised approach:** `PublicAccessListener` should only return 200 for
|
||||||
|
**GET/HEAD** requests to public paths, or all methods? For a gate like
|
||||||
|
this, all methods should be allowed on public paths — the backend
|
||||||
|
service (e.g., Gitea) handles its own authorization for write
|
||||||
|
operations.
|
||||||
|
|
||||||
|
### Why a separate rate limiter?
|
||||||
|
|
||||||
|
The existing `login_limiter` rate limits **login attempts** (failures).
|
||||||
|
The public rate limiter rate limits **all requests** to public paths.
|
||||||
|
They serve different purposes and need independent counters. Using the
|
||||||
|
same limiter would mean public traffic could exhaust the login attempt
|
||||||
|
budget, or vice versa.
|
||||||
|
|
||||||
|
### Why `Retry-After` header?
|
||||||
|
|
||||||
|
It's a standard HTTP header (RFC 7231) that tells clients how long to
|
||||||
|
wait before retrying. Legitimate clients (browsers, API consumers) and
|
||||||
|
crawlers respect it.
|
||||||
|
|
||||||
|
### Why no `Remote-User` header on public responses?
|
||||||
|
|
||||||
|
The `Remote-User` header tells the backend who the authenticated user
|
||||||
|
is. For public access, there is no authenticated user. Sending
|
||||||
|
`Remote-User: public` or similar could confuse the backend. The backend
|
||||||
|
should treat requests without `Remote-User` as anonymous.
|
||||||
|
|
||||||
|
### Path matching: query strings
|
||||||
|
|
||||||
|
Query strings are **ignored** for path matching. `/public?foo=bar`
|
||||||
|
matches the pattern `/public`. This is implemented by using
|
||||||
|
`$request->getPathInfo()` which returns the path without query string.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
1. **Empty `PUBLIC_PATHS`** → Feature disabled, zero impact on existing
|
||||||
|
behavior. All tests pass unchanged.
|
||||||
|
|
||||||
|
2. **Authenticated user visits a public path** → `AcceptListener` or
|
||||||
|
`AllowListener` returns 200 before `PublicAccessListener` runs. The
|
||||||
|
public rate limiter is never consulted.
|
||||||
|
|
||||||
|
3. **Public path rate limit exceeded** → 429 with `Retry-After` header.
|
||||||
|
The response uses the error template (same as login rate limit) but
|
||||||
|
always with 429 status (never teapot — teapot is for login failures).
|
||||||
|
|
||||||
|
4. **Non-public path on a host that has some public paths** → Falls
|
||||||
|
through to the normal auth flow. Login page or redirect.
|
||||||
|
|
||||||
|
5. **`PUBLIC_PATHS` with whitespace** → Trimmed during parsing.
|
||||||
|
`PUBLIC_PATHS='/public, /api'` is equivalent to `/public,/api`.
|
||||||
|
|
||||||
|
6. **Invalid patterns** (not starting with `/`) → Silently ignored
|
||||||
|
during parsing. Logged at debug level.
|
||||||
|
|
||||||
|
7. **Login attempt on a public path** → `PublicAccessListener` returns
|
||||||
|
200 before `LoginListener` runs. This is correct behavior — if the
|
||||||
|
path is public, no login is needed.
|
||||||
|
|
||||||
|
8. **Subdomain redirect mode + public paths** → If using an auth
|
||||||
|
subdomain, requests to the auth subdomain itself should never be
|
||||||
|
treated as public. The `PublicAccessListener` should skip requests
|
||||||
|
where `host === authSubdomain`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
### Unit Tests — `PublicPathMatcherTest`
|
||||||
|
|
||||||
|
- Empty string → no patterns → matches nothing
|
||||||
|
- Single path `/public` → matches exact, not `/public/`
|
||||||
|
- Wildcard `/public/*` → matches `/public/x`, not `/public`, not `/public/a/b`
|
||||||
|
- Double wildcard `/public/**` → matches `/public/a/b/c`
|
||||||
|
- Multiple patterns comma-separated
|
||||||
|
- Domain-prefixed pattern `host.example.com/public/**`
|
||||||
|
- Path without domain prefix matches any host
|
||||||
|
- Whitespace trimming
|
||||||
|
- Invalid patterns (no leading `/`) ignored
|
||||||
|
- Case sensitivity (paths are case-sensitive, hosts are case-insensitive)
|
||||||
|
|
||||||
|
### Unit Tests — `PublicAccessListenerTest`
|
||||||
|
|
||||||
|
- No public paths configured → returns without setting response
|
||||||
|
- Non-public path → returns without setting response
|
||||||
|
- Public path, within rate limit → sets 200 response
|
||||||
|
- Public path, rate limit exceeded → sets 429 response with Retry-After
|
||||||
|
- Public path, response already set by earlier listener → returns
|
||||||
|
- Auth subdomain request → skipped (even if path matches)
|
||||||
|
- Uses `ListenerTestHelper` for mock rate limiters and collaborators
|
||||||
|
|
||||||
|
### Functional Tests — `PublicAccessFlowTest`
|
||||||
|
|
||||||
|
- Public path accessible without authentication → 200
|
||||||
|
- Non-public path without auth → 401 (login page)
|
||||||
|
- Rate limit enforcement: multiple requests exceed burst → 429
|
||||||
|
- Authenticated user visits public path → 200 with Remote-User (bypasses public limiter)
|
||||||
|
- 429 response includes Retry-After header
|
||||||
|
- Query string ignored for path matching
|
||||||
|
- Wildcard matching works end-to-end
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation Updates
|
||||||
|
|
||||||
|
### README
|
||||||
|
|
||||||
|
New section: **"Public Rate-Limited Access"** under Configuration.
|
||||||
|
|
||||||
|
- Explain the feature and use case
|
||||||
|
- Document all env vars
|
||||||
|
- Show path pattern syntax with examples
|
||||||
|
- Show Caddyfile configuration for public + protected services
|
||||||
|
- Note that authenticated users bypass the public rate limiter
|
||||||
|
|
||||||
|
### CHANGELOG
|
||||||
|
|
||||||
|
New `[Unreleased]` → v1.1 section with all new features.
|
||||||
|
|
||||||
|
### ROADMAP
|
||||||
|
|
||||||
|
Mark Phase 1 items as completed.
|
||||||
|
|
||||||
|
### docs/example.env
|
||||||
|
|
||||||
|
Add all new env vars with comments.
|
||||||
|
|
||||||
|
### docs/Caddyfile
|
||||||
|
|
||||||
|
Add example showing a service with both public and protected paths.
|
||||||
@@ -22,6 +22,12 @@
|
|||||||
<!-- high rate limits so functional tests don't get blocked -->
|
<!-- high rate limits so functional tests don't get blocked -->
|
||||||
<server name="BURST_COUNT" value="10000" />
|
<server name="BURST_COUNT" value="10000" />
|
||||||
<server name="UPPER_COUNT" value="10000" />
|
<server name="UPPER_COUNT" value="10000" />
|
||||||
|
<!-- public access: enable for functional tests with low limits -->
|
||||||
|
<server name="PUBLIC_PATHS" value="/public/**" />
|
||||||
|
<server name="PUBLIC_BURST_COUNT" value="3" />
|
||||||
|
<server name="PUBLIC_BURST_TIME" value="60" />
|
||||||
|
<server name="PUBLIC_UPPER_COUNT" value="10000" />
|
||||||
|
<server name="PUBLIC_UPPER_TIME" value="3600" />
|
||||||
</php>
|
</php>
|
||||||
|
|
||||||
<testsuites>
|
<testsuites>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ For when you want a belt and suspenders.
|
|||||||
- **Caddy native** — Designed for Caddy's `forward_auth` directive
|
- **Caddy native** — Designed for Caddy's `forward_auth` directive
|
||||||
- **Docker-first** — Single container, persistent volumes, no database
|
- **Docker-first** — Single container, persistent volumes, no database
|
||||||
- **Rate limiting** — Per-IP burst and sustained limits (cannot be disabled)
|
- **Rate limiting** — Per-IP burst and sustained limits (cannot be disabled)
|
||||||
|
- **Public rate-limited access** — Optional, allow unauthenticated access
|
||||||
|
to specific paths with separate rate limiting (e.g., public Gitea repos)
|
||||||
- **Central auth** — Optional subdomain-based SSO across multiple services
|
- **Central auth** — Optional subdomain-based SSO across multiple services
|
||||||
- **IP-based bypass** — Optional, for services that don't handle cookies
|
- **IP-based bypass** — Optional, for services that don't handle cookies
|
||||||
- **Customizable** — Colors, labels, messages, and error text via env vars
|
- **Customizable** — Colors, labels, messages, and error text via env vars
|
||||||
@@ -138,6 +140,56 @@ Rate limiting **cannot be disabled**. It uses a compound sliding window:
|
|||||||
| `UPPER_COUNT` | `10` | Max attempts per upper window. |
|
| `UPPER_COUNT` | `10` | Max attempts per upper window. |
|
||||||
| `UPPER_TIME` | `3600` | Upper window in seconds (1 hour). |
|
| `UPPER_TIME` | `3600` | Upper window in seconds (1 hour). |
|
||||||
|
|
||||||
|
### Public Rate-Limited Access
|
||||||
|
|
||||||
|
Preauth can provide rate-limited unauthenticated access to select public
|
||||||
|
paths. This is useful for exposing public content (e.g., public repositories
|
||||||
|
in Gitea) without requiring TOTP authentication, while protecting server
|
||||||
|
resources from bot traffic.
|
||||||
|
|
||||||
|
When `PUBLIC_PATHS` is configured, requests to matching paths from
|
||||||
|
unauthenticated users are allowed through with a separate rate limiter.
|
||||||
|
Authenticated users bypass the public rate limiter entirely.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `PUBLIC_PATHS` | `''` (disabled) | Comma-separated path patterns. See below. |
|
||||||
|
| `PUBLIC_BURST_COUNT` | `100` | Max requests per burst window per IP. |
|
||||||
|
| `PUBLIC_BURST_TIME` | `60` | Burst window in seconds. |
|
||||||
|
| `PUBLIC_UPPER_COUNT` | `500` | Max requests per sustained window per IP. |
|
||||||
|
| `PUBLIC_UPPER_TIME` | `3600` | Sustained window in seconds (1 hour). |
|
||||||
|
|
||||||
|
**Path pattern syntax:**
|
||||||
|
|
||||||
|
- Patterns are matched against the request path only (query string ignored).
|
||||||
|
- Patterns must start with `/`.
|
||||||
|
- `*` matches one or more characters within a single path segment (not crossing `/`).
|
||||||
|
- `**` matches zero or more characters including `/` (crosses path segments).
|
||||||
|
- An optional host prefix can restrict a pattern to a specific host
|
||||||
|
(e.g., `code.example.com/public/**`).
|
||||||
|
|
||||||
|
| Pattern | Matches | Does NOT match |
|
||||||
|
|---------|---------|----------------|
|
||||||
|
| `/public` | `/public` | `/public/`, `/public/repo` |
|
||||||
|
| `/public/*` | `/public/repo` | `/public`, `/public/a/b` |
|
||||||
|
| `/public/**` | `/public/repo`, `/public/a/b/c` | `/public` |
|
||||||
|
| `host.com/api/**` | `host.com/api/v1/status` | `other.com/api/v1/status` |
|
||||||
|
|
||||||
|
**Example:** Allow public access to Gitea's `/public/` paths:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PUBLIC_PATHS=/public/**
|
||||||
|
PUBLIC_BURST_COUNT=100
|
||||||
|
PUBLIC_BURST_TIME=60
|
||||||
|
PUBLIC_UPPER_COUNT=500
|
||||||
|
PUBLIC_UPPER_TIME=3600
|
||||||
|
```
|
||||||
|
|
||||||
|
When a visitor exceeds the rate limit, they receive a `429 Too Many Requests`
|
||||||
|
response with a `Retry-After` header. When within limits, they receive a
|
||||||
|
`200 OK` response (with no `Remote-User` header). Authenticated users receive
|
||||||
|
`200 OK` with their `Remote-User` header as normal.
|
||||||
|
|
||||||
### Styling
|
### Styling
|
||||||
|
|
||||||
All UI text and colors are configurable:
|
All UI text and colors are configurable:
|
||||||
@@ -168,10 +220,12 @@ passes through a priority-ordered chain of listeners:
|
|||||||
|
|
||||||
1. **AcceptListener** (priority 99) — Checks for valid session cookie.
|
1. **AcceptListener** (priority 99) — Checks for valid session cookie.
|
||||||
2. **AllowListener** (priority 88) — Checks for valid IP-based session.
|
2. **AllowListener** (priority 88) — Checks for valid IP-based session.
|
||||||
3. **RejectListener** (priority 77) — Rate-limiting gate.
|
3. **PublicAccessListener** (priority 84) — If public paths are configured,
|
||||||
4. **LoginListener** (priority 66) — Processes login attempts.
|
allows rate-limited unauthenticated access to matching paths.
|
||||||
5. **InterceptListener** (priority 55) — Renders login page or redirects.
|
4. **RejectListener** (priority 77) — Rate-limiting gate.
|
||||||
6. **SecurityHeadersListener** (response) — Adds security headers.
|
5. **LoginListener** (priority 66) — Processes login attempts.
|
||||||
|
6. **InterceptListener** (priority 55) — Renders login page or redirects.
|
||||||
|
7. **SecurityHeadersListener** (response) — Adds security headers.
|
||||||
|
|
||||||
### Security Model
|
### Security Model
|
||||||
|
|
||||||
@@ -213,7 +267,7 @@ vendor/bin/php-cs-fixer fix
|
|||||||
vendor/bin/phpunit
|
vendor/bin/phpunit
|
||||||
```
|
```
|
||||||
|
|
||||||
The test suite includes 222 tests with 100% code coverage (lines, methods,
|
The test suite includes 293 tests with 100% code coverage (lines, methods,
|
||||||
and classes). Both unit tests and functional tests (full HTTP kernel flow)
|
and classes). Both unit tests and functional tests (full HTTP kernel flow)
|
||||||
are included.
|
are included.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Listener;
|
||||||
|
|
||||||
|
use App\Service\DomainInterface;
|
||||||
|
use App\Service\PublicPathMatcherInterface;
|
||||||
|
use App\Trait\HasLoggerTrait;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||||
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||||
|
use Twig\Environment;
|
||||||
|
use Twig\Error\LoaderError;
|
||||||
|
use Twig\Error\RuntimeError;
|
||||||
|
use Twig\Error\SyntaxError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows rate-limited unauthenticated access to configured public paths.
|
||||||
|
*
|
||||||
|
* Runs at priority 84 — after AcceptListener (99) and AllowListener (88)
|
||||||
|
* so authenticated users bypass this listener entirely, but before
|
||||||
|
* RejectListener (77) and LoginListener (66) so public traffic is not
|
||||||
|
* subject to the login rate limiter.
|
||||||
|
*
|
||||||
|
* When the request path matches a configured public path pattern:
|
||||||
|
* - If within rate limit → 200 OK (no Remote-User header)
|
||||||
|
* - If over rate limit → 429 Too Many Requests with Retry-After header
|
||||||
|
*
|
||||||
|
* Non-matching paths fall through to the normal auth flow.
|
||||||
|
*/
|
||||||
|
final readonly class PublicAccessListener
|
||||||
|
{
|
||||||
|
use HasLoggerTrait;
|
||||||
|
|
||||||
|
private RateLimiterFactoryInterface $rateLimiter;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private PublicPathMatcherInterface $pathMatcher,
|
||||||
|
private DomainInterface $domainManager,
|
||||||
|
private Environment $twig,
|
||||||
|
#[Target('public_limiter')] RateLimiterFactoryInterface $rateLimiter,
|
||||||
|
) {
|
||||||
|
$this->rateLimiter = $rateLimiter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws SyntaxError|RuntimeError|LoaderError */
|
||||||
|
#[AsEventListener(priority: 84)]
|
||||||
|
public function onKernelRequest(RequestEvent $event): void
|
||||||
|
{
|
||||||
|
if ($this->pathMatcher->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $event->getRequest();
|
||||||
|
$host = $request->getHost();
|
||||||
|
$path = $request->getPathInfo();
|
||||||
|
|
||||||
|
// Never treat the auth subdomain itself as public
|
||||||
|
if ($this->domainManager->getAuthSubdomain() === $host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->pathMatcher->matches($host, $path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path is public — apply rate limiting
|
||||||
|
$limiter = $this->rateLimiter->create($request->getClientIp());
|
||||||
|
$limit = $limiter->consume(1);
|
||||||
|
|
||||||
|
if ($limit->isAccepted()) {
|
||||||
|
$this->logger->debug("public access granted: {$request->getClientIp()} -> $path");
|
||||||
|
$event->setResponse(new Response(
|
||||||
|
'',
|
||||||
|
Response::HTTP_OK,
|
||||||
|
[
|
||||||
|
'Content-Type' => 'text/plain',
|
||||||
|
'Retry-After' => (string) $limit->getRemainingTokens(),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
$retryAfter = $limit->getRetryAfter()?->getTimestamp() - time();
|
||||||
|
$retryAfter = max(1, $retryAfter);
|
||||||
|
|
||||||
|
$this->logger->debug("public access rate-limited: {$request->getClientIp()} -> $path");
|
||||||
|
$html = $this->twig->render('error.html.twig');
|
||||||
|
$event->setResponse(new Response(
|
||||||
|
$html,
|
||||||
|
Response::HTTP_TOO_MANY_REQUESTS,
|
||||||
|
[
|
||||||
|
'Content-Type' => 'text/html',
|
||||||
|
'Retry-After' => (string) $retryAfter,
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Listener;
|
namespace App\Listener;
|
||||||
|
|
||||||
|
use App\Service\DomainInterface;
|
||||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||||
@@ -15,6 +16,11 @@ use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
|||||||
*/
|
*/
|
||||||
final readonly class SecurityHeadersListener
|
final readonly class SecurityHeadersListener
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private DomainInterface $domainManager,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
#[AsEventListener(priority: 0)]
|
#[AsEventListener(priority: 0)]
|
||||||
public function onKernelResponse(ResponseEvent $event): void
|
public function onKernelResponse(ResponseEvent $event): void
|
||||||
{
|
{
|
||||||
@@ -36,11 +42,24 @@ final readonly class SecurityHeadersListener
|
|||||||
|
|
||||||
/* Content-Security-Policy — the login page uses inline styles
|
/* Content-Security-Policy — the login page uses inline styles
|
||||||
* and scripts (via Twig includes), so we allow 'unsafe-inline'
|
* and scripts (via Twig includes), so we allow 'unsafe-inline'
|
||||||
* for those. No external resources are loaded. */
|
* for those. No external resources are loaded.
|
||||||
$headers->set(
|
*
|
||||||
'Content-Security-Policy',
|
* When subdomain redirection is off (or the request is not on
|
||||||
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';"
|
* 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) */
|
/* HSTS — enforce HTTPS for one year (app is designed for HTTPS behind a proxy) */
|
||||||
$headers->set('Strict-Transport-Security', 'max-age=31536000');
|
$headers->set('Strict-Transport-Security', 'max-age=31536000');
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches request paths against configured public path patterns.
|
||||||
|
*
|
||||||
|
* Patterns are provided as a comma-separated string in the format:
|
||||||
|
* /path/pattern, host.example.com/path/pattern, or a mix.
|
||||||
|
*
|
||||||
|
* Wildcards:
|
||||||
|
* - * matches any characters within a single path segment (not crossing /)
|
||||||
|
* - ** matches any characters including / (crosses path segments)
|
||||||
|
*
|
||||||
|
* Query strings are not part of the pattern — matching is against the
|
||||||
|
* path only.
|
||||||
|
*/
|
||||||
|
final readonly class PublicPathMatcher implements PublicPathMatcherInterface
|
||||||
|
{
|
||||||
|
/** @var list<array{host: ?string, regex: string}> */
|
||||||
|
private array $patterns;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire('%app.public_paths%')] string $publicPaths,
|
||||||
|
) {
|
||||||
|
$this->patterns = $this->parse($publicPaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEmpty(): bool
|
||||||
|
{
|
||||||
|
return $this->patterns === [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function matches(string $host, string $path): bool
|
||||||
|
{
|
||||||
|
if ($this->patterns === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = strtolower($host);
|
||||||
|
|
||||||
|
foreach ($this->patterns as $entry) {
|
||||||
|
if ($entry['host'] !== null && $entry['host'] !== $host) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match($entry['regex'], $path) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the comma-separated PUBLIC_PATHS string into pattern entries.
|
||||||
|
*
|
||||||
|
* @return list<array{host: ?string, regex: string}>
|
||||||
|
*/
|
||||||
|
private function parse(string $publicPaths): array
|
||||||
|
{
|
||||||
|
if (trim($publicPaths) === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$patterns = [];
|
||||||
|
|
||||||
|
foreach (explode(',', $publicPaths) as $raw) {
|
||||||
|
$entry = trim($raw);
|
||||||
|
if ($entry === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for a host prefix (anything before the first /)
|
||||||
|
$host = null;
|
||||||
|
$path = $entry;
|
||||||
|
|
||||||
|
if (preg_match('/^([a-z0-9.-]+)(\/.*)$/i', $entry, $m)) {
|
||||||
|
$host = strtolower($m[1]);
|
||||||
|
$path = $m[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate path starts with /
|
||||||
|
if (!str_starts_with($path, '/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$patterns[] = [
|
||||||
|
'host' => $host,
|
||||||
|
'regex' => $this->compilePattern($path),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $patterns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a wildcard path pattern into a regex string.
|
||||||
|
*
|
||||||
|
* Star becomes a character class matching one or more non-slash chars.
|
||||||
|
* Double-star at end of pattern matches zero or more of any char.
|
||||||
|
* Double-star followed by slash matches zero or more path segments.
|
||||||
|
* Other characters are escaped as literal regex.
|
||||||
|
*/
|
||||||
|
private function compilePattern(string $pattern): string
|
||||||
|
{
|
||||||
|
$regex = '';
|
||||||
|
$length = strlen($pattern);
|
||||||
|
$i = 0;
|
||||||
|
|
||||||
|
while ($i < $length) {
|
||||||
|
// Check for ** (must be at current position)
|
||||||
|
if ($i + 1 < $length && $pattern[$i] === '*' && $pattern[$i + 1] === '*') {
|
||||||
|
$i += 2;
|
||||||
|
if ($i >= $length) {
|
||||||
|
// ** at end of pattern: zero or more chars including /
|
||||||
|
$regex .= '.*';
|
||||||
|
} elseif ($pattern[$i] === '/') {
|
||||||
|
// /**/ in middle: zero or more intermediate segments
|
||||||
|
$regex .= '(?:.*/)?';
|
||||||
|
$i += 1; // skip the / after **
|
||||||
|
} else {
|
||||||
|
// ** not followed by / or end, treat as .*
|
||||||
|
$regex .= '.*';
|
||||||
|
}
|
||||||
|
} elseif ($pattern[$i] === '*') {
|
||||||
|
$regex .= '[^/]+';
|
||||||
|
$i += 1;
|
||||||
|
} else {
|
||||||
|
$regex .= preg_quote($pattern[$i], '#');
|
||||||
|
$i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '#^' . $regex . '$#';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches request paths against configured public path patterns.
|
||||||
|
*
|
||||||
|
* Patterns support simple wildcards:
|
||||||
|
* - `*` matches any characters within a single path segment (not crossing `/`)
|
||||||
|
* - `**` matches any characters including `/` (crosses path segments)
|
||||||
|
*
|
||||||
|
* Patterns may optionally include a host prefix (e.g. `example.com/public/**`).
|
||||||
|
* When no host prefix is given, the pattern matches on any host.
|
||||||
|
*/
|
||||||
|
interface PublicPathMatcherInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Returns true if the given host and path match any configured public pattern.
|
||||||
|
*
|
||||||
|
* @param string $host The request host (e.g. "code.example.com")
|
||||||
|
* @param string $path The request path (e.g. "/public/repo/issues")
|
||||||
|
*/
|
||||||
|
public function matches(string $host, string $path): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if no public paths are configured (feature is disabled).
|
||||||
|
*/
|
||||||
|
public function isEmpty(): bool;
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<style id="preauth-style">
|
<style id="preauth-style">
|
||||||
* { margin: 0; padding: 0.25em; }
|
* { 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%; }
|
font-family: sans-serif; font-size: 1.5em; height: 100%; padding: 0; width: 100%; }
|
||||||
body { display: table-cell; vertical-align: middle; }
|
body { display: table-cell; vertical-align: middle; }
|
||||||
h1 { font-size: 2.5em; font-weight: normal; text-align: center; }
|
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 { align-items: baseline; display: flex; flex-wrap: wrap; justify-content: center; }
|
||||||
form div { width: 45%; min-width: 300px; }
|
form div { width: 45%; min-width: 300px; }
|
||||||
div.right { text-align: right; margin-top: 1em; padding-bottom: 0 }
|
div.right { text-align: right; margin-top: 1em; padding-bottom: 0 }
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional;
|
||||||
|
|
||||||
|
use OTPHP\TOTP;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end functional tests for the public rate-limited access feature.
|
||||||
|
*
|
||||||
|
* The test environment (phpunit.dist.xml) configures:
|
||||||
|
* PUBLIC_PATHS=/public/**
|
||||||
|
* PUBLIC_BURST_COUNT=3, PUBLIC_BURST_TIME=60
|
||||||
|
* PUBLIC_UPPER_COUNT=10000 (effectively unlimited for test purposes)
|
||||||
|
*
|
||||||
|
* @covers \App\Listener\PublicAccessListener
|
||||||
|
* @covers \App\Service\PublicPathMatcher
|
||||||
|
*/
|
||||||
|
final class PublicAccessFlowTest 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), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── public path accessible without auth ───────────────────────────── */
|
||||||
|
|
||||||
|
public function testPublicPathAccessibleWithoutAuthentication(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/public/some-repo');
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
// No Remote-User header for public access
|
||||||
|
self::assertFalse($response->headers->has('Remote-User'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPublicPathWithQuerystringAccessible(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/public/repo?tab=issues&page=2');
|
||||||
|
|
||||||
|
self::assertSame(200, $client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeepPublicPathAccessible(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/public/org/repo/issues/42');
|
||||||
|
|
||||||
|
self::assertSame(200, $client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── non-public path requires auth ─────────────────────────────────── */
|
||||||
|
|
||||||
|
public function testNonPublicPathShowsLoginPage(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/private/settings');
|
||||||
|
|
||||||
|
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||||
|
self::assertSelectorExists('form#preauth-form');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRootPathShowsLoginPage(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/');
|
||||||
|
|
||||||
|
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExactPublicPathWithoutSlashNotMatched(): void
|
||||||
|
{
|
||||||
|
// /public/** does NOT match /public (no trailing content)
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/public');
|
||||||
|
|
||||||
|
self::assertSame(401, $client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── rate limiting ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function testRateLimitEnforcedAfterBurstExceeded(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
// PUBLIC_BURST_COUNT=3 — first 3 requests succeed
|
||||||
|
for ($i = 0; $i < 3; $i++) {
|
||||||
|
$client->request('GET', '/public/repo');
|
||||||
|
self::assertSame(
|
||||||
|
200,
|
||||||
|
$client->getResponse()->getStatusCode(),
|
||||||
|
"Request $i should have been allowed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4th request should be rate limited
|
||||||
|
$client->request('GET', '/public/repo');
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(429, $response->getStatusCode());
|
||||||
|
self::assertTrue($response->headers->has('Retry-After'));
|
||||||
|
$retryAfter = (int) $response->headers->get('Retry-After');
|
||||||
|
self::assertGreaterThan(0, $retryAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── authenticated user bypasses public rate limiter ───────────────── */
|
||||||
|
|
||||||
|
public function testAuthenticatedUserBypassesPublicRateLimit(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
// First, exhaust the public rate limiter
|
||||||
|
for ($i = 0; $i < 4; $i++) {
|
||||||
|
$client->request('GET', '/public/repo');
|
||||||
|
}
|
||||||
|
// Confirm rate limit is in effect
|
||||||
|
$client->request('GET', '/public/repo');
|
||||||
|
self::assertSame(429, $client->getResponse()->getStatusCode());
|
||||||
|
|
||||||
|
// Now log in — the cookie should let us bypass public rate limiting
|
||||||
|
$client->getCookieJar()->clear();
|
||||||
|
|
||||||
|
$crawler = $client->request('GET', '/private');
|
||||||
|
$nonce = $crawler->filter('input[name="nonce"]')->attr('value');
|
||||||
|
|
||||||
|
$client->request('GET', '/private', [], [], [
|
||||||
|
'HTTP_X-Preauth' => $this->encodePayload([
|
||||||
|
'id' => 'alice',
|
||||||
|
'token' => $this->validTotpCode(),
|
||||||
|
'nonce' => $nonce,
|
||||||
|
'json' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
self::assertSame(303, $client->getResponse()->getStatusCode());
|
||||||
|
|
||||||
|
// Now visit a public path while authenticated — should get 200
|
||||||
|
// (AcceptListener runs before PublicAccessListener, so the public
|
||||||
|
// rate limiter is never consulted)
|
||||||
|
$client->request('GET', '/public/repo');
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
// Authenticated users get Remote-User header
|
||||||
|
self::assertSame('alice', $response->headers->get('Remote-User'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 200 response has correct content type ─────────────────────────── */
|
||||||
|
|
||||||
|
public function testPublicAccessResponseIsPlainText(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/public/repo');
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
self::assertStringStartsWith('text/plain', $response->headers->get('Content-Type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 429 response renders error template ───────────────────────────── */
|
||||||
|
|
||||||
|
public function testRateLimitedResponseRendersErrorTemplate(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
|
||||||
|
// Exhaust rate limit
|
||||||
|
for ($i = 0; $i < 4; $i++) {
|
||||||
|
$client->request('GET', '/public/repo');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
self::assertSame(429, $response->getStatusCode());
|
||||||
|
self::assertStringStartsWith('text/html', $response->headers->get('Content-Type'));
|
||||||
|
$content = $response->getContent();
|
||||||
|
// The error template renders either teapot or too-many-requests content
|
||||||
|
// Default test env has TEAPOT=true
|
||||||
|
self::assertNotEmpty($content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── security headers still applied to public responses ────────────── */
|
||||||
|
|
||||||
|
public function testSecurityHeadersOnPublicAccess(): void
|
||||||
|
{
|
||||||
|
$client = static::createClient();
|
||||||
|
$client->request('GET', '/public/repo');
|
||||||
|
|
||||||
|
$response = $client->getResponse();
|
||||||
|
// SecurityHeadersListener runs on all main-request responses
|
||||||
|
self::assertSame('nosniff', $response->headers->get('X-Content-Type-Options'));
|
||||||
|
self::assertSame('DENY', $response->headers->get('X-Frame-Options'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ class TestKernel extends AppKernel
|
|||||||
$container->addCompilerPass(new class () implements CompilerPassInterface {
|
$container->addCompilerPass(new class () implements CompilerPassInterface {
|
||||||
public function process(ContainerBuilder $container): void
|
public function process(ContainerBuilder $container): void
|
||||||
{
|
{
|
||||||
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage'] as $poolId) {
|
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage', 'publicRateLimitCache'] as $poolId) {
|
||||||
if ($container->hasDefinition($poolId)) {
|
if ($container->hasDefinition($poolId)) {
|
||||||
$container->getDefinition($poolId)->clearTag('kernel.reset');
|
$container->getDefinition($poolId)->clearTag('kernel.reset');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Listener;
|
||||||
|
|
||||||
|
use App\Listener\PublicAccessListener;
|
||||||
|
use App\Service\DomainInterface;
|
||||||
|
use App\Service\PublicPathMatcher;
|
||||||
|
use App\Tests\Support\ListenerTestHelper;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Log\NullLogger;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for PublicAccessListener.
|
||||||
|
*
|
||||||
|
* @covers \App\Listener\PublicAccessListener
|
||||||
|
*/
|
||||||
|
final class PublicAccessListenerTest extends TestCase
|
||||||
|
{
|
||||||
|
use ListenerTestHelper;
|
||||||
|
|
||||||
|
private function makeListener(
|
||||||
|
string $publicPaths = '',
|
||||||
|
int $remainingTokens = 10,
|
||||||
|
?string $authSubdomain = null,
|
||||||
|
): PublicAccessListener {
|
||||||
|
$pathMatcher = new PublicPathMatcher($publicPaths);
|
||||||
|
|
||||||
|
$domainManager = $this->createStub(DomainInterface::class);
|
||||||
|
$domainManager->method('getAuthSubdomain')->willReturn($authSubdomain);
|
||||||
|
|
||||||
|
$listener = new PublicAccessListener(
|
||||||
|
$pathMatcher,
|
||||||
|
$domainManager,
|
||||||
|
$this->makeTwig(),
|
||||||
|
$this->makeRateLimiterFactory($remainingTokens),
|
||||||
|
);
|
||||||
|
$listener->setLogger(new NullLogger());
|
||||||
|
return $listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeEvent(Request $request): RequestEvent
|
||||||
|
{
|
||||||
|
return new RequestEvent(
|
||||||
|
$this->createStub(HttpKernelInterface::class),
|
||||||
|
$request,
|
||||||
|
HttpKernelInterface::MAIN_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── feature disabled ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function testNoPublicPathsReturnsWithoutResponse(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: '');
|
||||||
|
|
||||||
|
$request = Request::create('/public', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── non-public path ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function testNonPublicPathReturnsWithoutResponse(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: '/public/**');
|
||||||
|
|
||||||
|
$request = Request::create('/private', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── public path within rate limit ─────────────────────────────────── */
|
||||||
|
|
||||||
|
public function testPublicPathWithinRateLimitReturns200(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 10);
|
||||||
|
|
||||||
|
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
|
self::assertSame('text/plain', $response->headers->get('Content-Type'));
|
||||||
|
// No Remote-User header for public access
|
||||||
|
self::assertFalse($response->headers->has('Remote-User'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── public path rate limited ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function testPublicPathOverRateLimitReturns429(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 0);
|
||||||
|
|
||||||
|
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode());
|
||||||
|
self::assertSame('text/html', $response->headers->get('Content-Type'));
|
||||||
|
self::assertTrue($response->headers->has('Retry-After'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRateLimitedResponseContainsErrorTemplate(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 0);
|
||||||
|
|
||||||
|
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
$content = $event->getResponse()->getContent();
|
||||||
|
// Default teapot template content (env.teapot is true in test helper)
|
||||||
|
self::assertStringContainsString('teapot', $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── auth subdomain is never public ────────────────────────────────── */
|
||||||
|
|
||||||
|
public function testAuthSubdomainRequestIsSkipped(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(
|
||||||
|
publicPaths: '/**',
|
||||||
|
remainingTokens: 10,
|
||||||
|
authSubdomain: 'auth.example.com',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Request to auth subdomain — should NOT be treated as public
|
||||||
|
$request = Request::create('https://auth.example.com/public', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── query string is ignored ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function testQueryStringIsIgnoredForPathMatching(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: '/public', remainingTokens: 10);
|
||||||
|
|
||||||
|
$request = Request::create('/public?foo=bar', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── domain-scoped paths ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function testDomainScopedPathMatchesCorrectHost(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: 'code.example.com/public/**', remainingTokens: 10);
|
||||||
|
|
||||||
|
$request = Request::create('https://code.example.com/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDomainScopedPathDoesNotMatchOtherHost(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: 'code.example.com/public/**', remainingTokens: 10);
|
||||||
|
|
||||||
|
$request = Request::create('https://other.example.com/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── wildcard matching ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function testSingleWildcardMatching(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: '/public/*', remainingTokens: 10);
|
||||||
|
|
||||||
|
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertTrue($event->hasResponse());
|
||||||
|
self::assertSame(Response::HTTP_OK, $event->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSingleWildcardDoesNotMatchDeepPath(): void
|
||||||
|
{
|
||||||
|
$listener = $this->makeListener(publicPaths: '/public/*', remainingTokens: 10);
|
||||||
|
|
||||||
|
$request = Request::create('/public/a/b', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
self::assertFalse($event->hasResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 200 response includes remaining token count ───────────────────── */
|
||||||
|
|
||||||
|
public function testOkResponseIncludesRetryAfterHeader(): void
|
||||||
|
{
|
||||||
|
// The 200 response includes a Retry-After header showing remaining tokens
|
||||||
|
$listener = $this->makeListener(publicPaths: '/public/**', remainingTokens: 42);
|
||||||
|
|
||||||
|
$request = Request::create('/public/repo', 'GET', [], [], [], ['REMOTE_ADDR' => '1.2.3.4']);
|
||||||
|
$event = $this->makeEvent($request);
|
||||||
|
$listener->onKernelRequest($event);
|
||||||
|
|
||||||
|
$response = $event->getResponse();
|
||||||
|
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||||
|
self::assertSame('42', $response->headers->get('Retry-After'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Service;
|
||||||
|
|
||||||
|
use App\Service\PublicPathMatcher;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for PublicPathMatcher — path pattern parsing and matching.
|
||||||
|
*
|
||||||
|
* @covers \App\Service\PublicPathMatcher
|
||||||
|
*/
|
||||||
|
final class PublicPathMatcherTest extends TestCase
|
||||||
|
{
|
||||||
|
/* ── empty / disabled ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function testEmptyStringResultsInNoPatterns(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('');
|
||||||
|
self::assertTrue($matcher->isEmpty());
|
||||||
|
self::assertFalse($matcher->matches('example.com', '/public'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWhitespaceOnlyStringResultsInNoPatterns(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher(' ');
|
||||||
|
self::assertTrue($matcher->isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── exact path matching ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function testExactPathMatch(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/public');
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/public'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExactPathDoesNotMatchSubpath(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/public');
|
||||||
|
self::assertFalse($matcher->matches('example.com', '/public/'));
|
||||||
|
self::assertFalse($matcher->matches('example.com', '/public/repo'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExactPathDoesNotMatchDifferentPath(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/public');
|
||||||
|
self::assertFalse($matcher->matches('example.com', '/private'));
|
||||||
|
self::assertFalse($matcher->matches('example.com', '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── single wildcard * ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function testSingleWildcardMatchesOneSegment(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/public/*');
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/public/repo'));
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/public/xyz'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSingleWildcardDoesNotMatchBasePath(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/public/*');
|
||||||
|
self::assertFalse($matcher->matches('example.com', '/public'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSingleWildcardDoesNotCrossSegments(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/public/*');
|
||||||
|
self::assertFalse($matcher->matches('example.com', '/public/a/b'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSingleWildcardDoesNotMatchEmptySegment(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/public/*');
|
||||||
|
self::assertFalse($matcher->matches('example.com', '/public/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── double wildcard ** ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function testDoubleWildcardMatchesMultipleSegments(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/public/**');
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/public/a'));
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/public/a/b/c'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDoubleWildcardDoesNotMatchBasePath(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/public/**');
|
||||||
|
self::assertFalse($matcher->matches('example.com', '/public'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDoubleWildcardMatchesTrailingSlash(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/public/**');
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/public/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── mid-path wildcards ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function testMidPathSingleWildcard(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/api/*/status');
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/api/v1/status'));
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/api/v2/status'));
|
||||||
|
self::assertFalse($matcher->matches('example.com', '/api/v1/v2/status'));
|
||||||
|
self::assertFalse($matcher->matches('example.com', '/api/status'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMidPathDoubleWildcard(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/api/**/status');
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/api/v1/status'));
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/api/v1/v2/status'));
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/api/status'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── multiple patterns ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function testMultiplePatternsCommaSeparated(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/public/**,/api/status,/health');
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/public/repo'));
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/api/status'));
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/health'));
|
||||||
|
self::assertFalse($matcher->matches('example.com', '/private'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMultiplePatternsWithWhitespace(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/public/**, /api/status, /health');
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/public/repo'));
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/api/status'));
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/health'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmptySegmentsInCommaListAreIgnored(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/public,,/health,');
|
||||||
|
self::assertFalse($matcher->isEmpty());
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/public'));
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/health'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── domain-prefixed patterns ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function testDomainPrefixedPatternMatchesOnThatHost(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('code.example.com/public/**');
|
||||||
|
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDomainPrefixedPatternDoesNotMatchOtherHost(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('code.example.com/public/**');
|
||||||
|
self::assertFalse($matcher->matches('other.example.com', '/public/repo'));
|
||||||
|
self::assertFalse($matcher->matches('example.com', '/public/repo'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPathWithoutDomainPrefixMatchesAnyHost(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/public/**');
|
||||||
|
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
|
||||||
|
self::assertTrue($matcher->matches('other.example.com', '/public/repo'));
|
||||||
|
self::assertTrue($matcher->matches('localhost', '/public/repo'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMixedDomainPrefixedAndPlainPatterns(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/health,code.example.com/public/**');
|
||||||
|
self::assertTrue($matcher->matches('any.host', '/health'));
|
||||||
|
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
|
||||||
|
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/**');
|
||||||
|
self::assertTrue($matcher->matches('code.example.com', '/public/repo'));
|
||||||
|
self::assertTrue($matcher->matches('CODE.EXAMPLE.COM', '/public/repo'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── invalid patterns ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function testPatternWithoutLeadingSlashIsIgnored(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('public');
|
||||||
|
self::assertTrue($matcher->isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInvalidPatternAmongValidOnesIsIgnored(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('invalid,/public');
|
||||||
|
self::assertFalse($matcher->isEmpty());
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/public'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── special regex characters in paths ─────────────────────────────── */
|
||||||
|
|
||||||
|
public function testSpecialRegexCharactersAreEscaped(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/path.with.dots');
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/path.with.dots'));
|
||||||
|
self::assertFalse($matcher->matches('example.com', '/pathXwithXdots'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPlusCharacterIsLiteral(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/a+b');
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/a+b'));
|
||||||
|
self::assertFalse($matcher->matches('example.com', '/aaab'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── root path ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
public function testRootPathMatch(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/');
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/'));
|
||||||
|
self::assertFalse($matcher->matches('example.com', '/anything'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWildcardAtRoot(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/*');
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/anything'));
|
||||||
|
self::assertFalse($matcher->matches('example.com', '/a/b'));
|
||||||
|
self::assertFalse($matcher->matches('example.com', '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDoubleWildcardAtRoot(): void
|
||||||
|
{
|
||||||
|
$matcher = new PublicPathMatcher('/**');
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/'));
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/anything'));
|
||||||
|
self::assertTrue($matcher->matches('example.com', '/a/b/c'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user