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
|
||||
UPPER_COUNT=100
|
||||
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'
|
||||
BG_COLOR='#029386'
|
||||
FG_COLOR='#ffffff'
|
||||
|
||||
@@ -26,6 +26,24 @@ jobs:
|
||||
coverage: xdebug
|
||||
ini-values: apc.enable_cli=1
|
||||
|
||||
# Authenticate to GitHub to raise API rate limit from 60 → 5,000 req/hour.
|
||||
# Uses the same token that publish.yaml uses to sync to GitHub.
|
||||
- name: Configure GitHub OAuth token
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.SYNC_GITHUB_TOKEN }}
|
||||
run: composer config --global github-oauth.github.com "$GITHUB_TOKEN"
|
||||
|
||||
# Cache Composer's download cache so repeated CI runs don't re-download
|
||||
# packages at all. Keyed on composer.lock hash — cache busts automatically
|
||||
# when dependencies change.
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.composer/cache
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('composer.lock') }}
|
||||
restore-keys: |
|
||||
composer-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress
|
||||
|
||||
|
||||
+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/),
|
||||
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
|
||||
- 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.
|
||||
2. **AllowListener** (priority 88) — If `IP_TTL` is enabled, checks for
|
||||
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`).
|
||||
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.
|
||||
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
|
||||
renders the Twig login page with a fresh nonce.
|
||||
|
||||
@@ -76,8 +80,8 @@ Client → Caddy → forward_auth → Preauth listeners (priority order) → 200
|
||||
|
||||
| Metric | Value |
|
||||
|--------------|--------------------------------|
|
||||
| **Tests** | 222 |
|
||||
| **Assertions** | 469 |
|
||||
| **Tests** | 293 |
|
||||
| **Assertions** | 605 |
|
||||
| **Pass** | 222 (100%) |
|
||||
| **Fail** | 0 |
|
||||
| **Errors** | 0 |
|
||||
@@ -109,12 +113,14 @@ Every class, method, and line in `src/` is covered.
|
||||
| `Data/Payload.php` | `Unit/Data/PayloadTest.php` | Unit |
|
||||
| `Enum/Scope.php` | `Unit/Enum/ScopeTest.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/InterceptListener.php` | `Unit/Listener/InterceptListenerTest.php` | Unit |
|
||||
| `Listener/LoginListener.php` | `Unit/Listener/LoginListenerTest.php` | Unit |
|
||||
| `Listener/RejectListener.php` | `Unit/Listener/RejectListenerTest.php` | Unit |
|
||||
| `Service/BackupCodeManager.php` | `Unit/Service/BackupCodeManagerTest.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 |
|
||||
| `Trait/CookieNameTrait.php` | `Unit/Trait/CookieNameTraitTest.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/StringTrait.php` | `Unit/Trait/StringTraitTest.php` | Unit |
|
||||
| *(All listeners + services)* | `Functional/AuthenticationFlowTest.php` | Functional |
|
||||
| *(Public access flow)* | `Functional/PublicAccessFlowTest.php` | Functional |
|
||||
|
||||
### Test Quality Assessment
|
||||
|
||||
@@ -157,7 +164,7 @@ Every class, method, and line in `src/` is covered.
|
||||
|
||||
## 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
|
||||
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
|
||||
services.
|
||||
|
||||
**Design:**
|
||||
**Implementation:**
|
||||
|
||||
- New config variables:
|
||||
- `PUBLIC_MODE=false` — Enable public access for specific services
|
||||
- `PUBLIC_RATE_LIMIT=10` — Max requests per minute from a single IP
|
||||
on public paths
|
||||
- `PUBLIC_RATE_WINDOW=60` — Sliding window in seconds
|
||||
- `PUBLIC_BURST=20` — Allow short bursts above the sustained rate
|
||||
- `PUBLIC_PATHS` — 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.
|
||||
- `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
|
||||
and AllowListener):
|
||||
- Checks if the request matches a public path pattern (configured per
|
||||
service via Caddy's `forward_auth` URI or a header like
|
||||
`X-Preauth-Public: true`).
|
||||
- If public mode is enabled for this request, applies aggressive
|
||||
per-IP rate limiting (separate from the login rate limiter).
|
||||
- If within rate limit → `200 OK` (no `Remote-User` header, or a
|
||||
`Remote-User: public` marker).
|
||||
- If over rate limit → `429 Too Many Requests` with `Retry-After`
|
||||
header.
|
||||
- New listener: **PublicAccessListener** (priority 84, after
|
||||
AcceptListener and AllowListener, before RejectListener):
|
||||
- Checks if the request path matches a configured public path pattern.
|
||||
- If public and within rate limit → `200 OK` (no `Remote-User` header).
|
||||
- If public and over rate limit → `429 Too Many Requests` with
|
||||
`Retry-After` header.
|
||||
- Authenticated users bypass this listener entirely (AcceptListener
|
||||
or AllowListener returns 200 first).
|
||||
|
||||
- Caddy config would use different `forward_auth` snippets for public
|
||||
vs. protected services:
|
||||
```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
|
||||
}
|
||||
```
|
||||
- New service: **PublicPathMatcher** — Parses path patterns and matches
|
||||
request paths with wildcard support.
|
||||
|
||||
- Consider integration with Caddy's own rate limiting as a second layer
|
||||
of defense (rate limit at the reverse proxy before traffic even hits
|
||||
preauth).
|
||||
- Separate `public_limiter` compound rate limiter (independent from
|
||||
the login attempt rate limiter).
|
||||
|
||||
- [ ] Design public path detection mechanism (URI-based or header-based)
|
||||
- [ ] Implement `PublicListener` with separate rate limiter pool
|
||||
- [ ] Add config variables and defaults
|
||||
- [ ] Update Caddyfile example with public service snippet
|
||||
- [ ] Tests for public mode (within limit, over limit, burst behavior)
|
||||
- [ ] Documentation in README
|
||||
- [x] Design public path detection mechanism (path-based with wildcards)
|
||||
- [x] Implement `PublicAccessListener` with separate rate limiter pool
|
||||
- [x] Add config variables and defaults
|
||||
- [x] Update Caddyfile example with public service snippet
|
||||
- [x] Tests for public mode (within limit, over limit, burst behavior)
|
||||
- [x] Documentation in README
|
||||
|
||||
### 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
|
||||
sessionStorage:
|
||||
adapters: cache.adapter.filesystem
|
||||
publicRateLimitCache:
|
||||
adapters: cache.adapter.apcu
|
||||
|
||||
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||
prefix_seed: digitaladapt/preauth
|
||||
|
||||
@@ -13,3 +13,17 @@ framework:
|
||||
login_limiter:
|
||||
policy: compound
|
||||
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
|
||||
sessionStorage:
|
||||
adapters: cache.adapter.array
|
||||
publicRateLimitCache:
|
||||
adapters: cache.adapter.array
|
||||
|
||||
@@ -45,6 +45,16 @@ parameters:
|
||||
env(UPPER_COUNT): 10 # 10 per 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 ---
|
||||
env(TITLE): 'Pre-Authentication System'
|
||||
env(BG_COLOR): '#029386' # teal
|
||||
@@ -77,6 +87,12 @@ parameters:
|
||||
app.remote_user_static: '%env(REMOTE_USER_STATIC)%'
|
||||
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.teapot_title: '%env(TEAPOT_TITLE)%'
|
||||
app.too_many_title: '%env(TOO_MANY_TITLE)%'
|
||||
|
||||
@@ -26,3 +26,25 @@ protected.example.com {
|
||||
auth.example.com {
|
||||
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_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 ---
|
||||
|
||||
#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 -->
|
||||
<server name="BURST_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>
|
||||
|
||||
<testsuites>
|
||||
|
||||
@@ -17,6 +17,8 @@ For when you want a belt and suspenders.
|
||||
- **Caddy native** — Designed for Caddy's `forward_auth` directive
|
||||
- **Docker-first** — Single container, persistent volumes, no database
|
||||
- **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
|
||||
- **IP-based bypass** — Optional, for services that don't handle cookies
|
||||
- **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_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
|
||||
|
||||
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.
|
||||
2. **AllowListener** (priority 88) — Checks for valid IP-based session.
|
||||
3. **RejectListener** (priority 77) — Rate-limiting gate.
|
||||
4. **LoginListener** (priority 66) — Processes login attempts.
|
||||
5. **InterceptListener** (priority 55) — Renders login page or redirects.
|
||||
6. **SecurityHeadersListener** (response) — Adds security headers.
|
||||
3. **PublicAccessListener** (priority 84) — If public paths are configured,
|
||||
allows rate-limited unauthenticated access to matching paths.
|
||||
4. **RejectListener** (priority 77) — Rate-limiting gate.
|
||||
5. **LoginListener** (priority 66) — Processes login attempts.
|
||||
6. **InterceptListener** (priority 55) — Renders login page or redirects.
|
||||
7. **SecurityHeadersListener** (response) — Adds security headers.
|
||||
|
||||
### Security Model
|
||||
|
||||
@@ -213,7 +267,7 @@ vendor/bin/php-cs-fixer fix
|
||||
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)
|
||||
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;
|
||||
|
||||
use App\Service\DomainInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
@@ -15,6 +16,11 @@ use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
*/
|
||||
final readonly class SecurityHeadersListener
|
||||
{
|
||||
public function __construct(
|
||||
private DomainInterface $domainManager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[AsEventListener(priority: 0)]
|
||||
public function onKernelResponse(ResponseEvent $event): void
|
||||
{
|
||||
@@ -36,11 +42,24 @@ final readonly class SecurityHeadersListener
|
||||
|
||||
/* Content-Security-Policy — the login page uses inline styles
|
||||
* and scripts (via Twig includes), so we allow 'unsafe-inline'
|
||||
* for those. No external resources are loaded. */
|
||||
$headers->set(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';"
|
||||
);
|
||||
* for those. No external resources are loaded.
|
||||
*
|
||||
* When subdomain redirection is off (or the request is not on
|
||||
* the auth subdomain), the login form is served inline on the
|
||||
* protected host and submission is performed via a same-origin
|
||||
* fetch() call in _script.html.twig. That fetch is blocked by
|
||||
* the default 'none' policy, so we add connect-src 'self' only
|
||||
* in that case — the least privilege needed to make the form
|
||||
* work. On the auth subdomain the form POSTs normally and no
|
||||
* inline script is included, so the stricter policy applies. */
|
||||
$inlineScript = $this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost();
|
||||
$csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';";
|
||||
|
||||
if ($inlineScript) {
|
||||
$csp .= " connect-src 'self';";
|
||||
}
|
||||
|
||||
$headers->set('Content-Security-Policy', $csp);
|
||||
|
||||
/* HSTS — enforce HTTPS for one year (app is designed for HTTPS behind a proxy) */
|
||||
$headers->set('Strict-Transport-Security', 'max-age=31536000');
|
||||
|
||||
@@ -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">
|
||||
* { margin: 0; padding: 0.25em; }
|
||||
html { background-color: {{ env.bg_color|e('css') }}; color: {{ env.fg_color|e('css') }}; display: table;
|
||||
html { background-color: {{ env.bg_color }}; color: {{ env.fg_color }}; display: table;
|
||||
font-family: sans-serif; font-size: 1.5em; height: 100%; padding: 0; width: 100%; }
|
||||
body { display: table-cell; vertical-align: middle; }
|
||||
h1 { font-size: 2.5em; font-weight: normal; text-align: center; }
|
||||
p { color: {{ env.error_color|e('css') }}; text-align: center; }
|
||||
p { color: {{ env.error_color }}; text-align: center; }
|
||||
form { align-items: baseline; display: flex; flex-wrap: wrap; justify-content: center; }
|
||||
form div { width: 45%; min-width: 300px; }
|
||||
div.right { text-align: right; margin-top: 1em; padding-bottom: 0 }
|
||||
|
||||
@@ -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 {
|
||||
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)) {
|
||||
$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