4 Commits
Author SHA1 Message Date
andrew 66b960ccea Merge pull request 'feat: v1.1 — public rate-limited access' (#5) from feat/v1.1-public-access into main
Push Develop / docker (push) Successful in 4m47s
Sync GitHub / sync (push) Successful in 7s
Tests / test (push) Successful in 55s
Push Docker / docker (push) Successful in 4m46s
Reviewed-on: #5
Reviewed-by: Andrew <andrew@digitaladapt.com>
2026-08-13 01:30:13 -04:00
andrew 17c2d525ff Merge branch 'main' into feat/v1.1-public-access
Sync GitHub / sync (push) Successful in 13s
Tests / test (pull_request) Successful in 54s
2026-08-13 01:19:08 -04:00
lyra 29e471c536 Merge branch 'fix/v1.0-must-fix' into feat/v1.1-public-access
Push Develop / docker (push) Successful in 6m1s
Sync GitHub / sync (push) Successful in 7s
Tests / test (push) Successful in 1m3s
2026-08-12 11:21:42 -04:00
lyra 5563999525 feat: public rate-limited access for v1.1
Sync GitHub / sync (push) Successful in 8s
Add PublicAccessListener (priority 84) that allows rate-limited
unauthenticated access to configured public paths. Authenticated users
bypass this listener entirely via AcceptListener/AllowListener.

New components:
- PublicPathMatcher service with wildcard path matching (* and **)
  and optional host-prefix scoping
- PublicAccessListener applying per-IP rate limiting to public paths
- Separate public_limiter compound rate limiter (burst + sustained)
- publicRateLimitCache pool (APCu in prod, array in tests)

New env vars:
- PUBLIC_PATHS (comma-separated path patterns, empty = disabled)
- PUBLIC_BURST_COUNT/PUBLIC_BURST_TIME (default 100/60s)
- PUBLIC_UPPER_COUNT/PUBLIC_UPPER_TIME (default 500/3600s)

Tests: 52 new tests (29 unit for PublicPathMatcher, 12 unit for
PublicAccessListener, 11 functional for PublicAccessFlowTest).
Total: 293 tests, 605 assertions, all passing.
PHP CS Fixer: 0 of 63 files need fixing.

Documentation: README, CHANGELOG, ROADMAP, Caddyfile, example.env
all updated with public access configuration and examples.
2026-08-12 09:26:50 -04:00
19 changed files with 1491 additions and 54 deletions
+5
View File
@@ -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'
+21 -1
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
+14
View File
@@ -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]
+2
View File
@@ -10,3 +10,5 @@ framework:
adapters: cache.adapter.array
sessionStorage:
adapters: cache.adapter.array
publicRateLimitCache:
adapters: cache.adapter.array
+16
View File
@@ -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)%'
+22
View File
@@ -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
+12
View File
@@ -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'
+340
View File
@@ -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.
+6
View File
@@ -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>
+59 -5
View File
@@ -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.
+100
View File
@@ -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,
],
));
}
}
}
+141
View File
@@ -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;
}
+211
View File
@@ -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'));
}
}
+1 -1
View File
@@ -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,241 @@
<?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 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'));
}
}