diff --git a/.env.test b/.env.test index 1055b7a..5d7f163 100644 --- a/.env.test +++ b/.env.test @@ -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' diff --git a/CHANGELOG.md b/CHANGELOG.md index f3c8af5..8dddee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 72240f4..f5263f8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index 8c0dd1c..93125a6 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -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 diff --git a/config/packages/rate_limiter.yaml b/config/packages/rate_limiter.yaml index 80e70fe..bceb2ef 100644 --- a/config/packages/rate_limiter.yaml +++ b/config/packages/rate_limiter.yaml @@ -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] diff --git a/config/packages/test/cache.yaml b/config/packages/test/cache.yaml index 8fd43b9..d25a7bd 100644 --- a/config/packages/test/cache.yaml +++ b/config/packages/test/cache.yaml @@ -10,3 +10,5 @@ framework: adapters: cache.adapter.array sessionStorage: adapters: cache.adapter.array + publicRateLimitCache: + adapters: cache.adapter.array diff --git a/config/services.yaml b/config/services.yaml index 9a4a51c..bb828a9 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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)%' diff --git a/docs/Caddyfile b/docs/Caddyfile index 6d1b7c4..92ce41e 100644 --- a/docs/Caddyfile +++ b/docs/Caddyfile @@ -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 diff --git a/docs/example.env b/docs/example.env index 3e12dfd..96da772 100644 --- a/docs/example.env +++ b/docs/example.env @@ -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' diff --git a/docs/v1.1-plan.md b/docs/v1.1-plan.md new file mode 100644 index 0000000..2842c32 --- /dev/null +++ b/docs/v1.1-plan.md @@ -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. diff --git a/phpunit.dist.xml b/phpunit.dist.xml index 1597ca7..061c0a7 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -22,6 +22,12 @@ + + + + + + diff --git a/readme.md b/readme.md index 21c1ff8..1127023 100644 --- a/readme.md +++ b/readme.md @@ -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. diff --git a/src/Listener/PublicAccessListener.php b/src/Listener/PublicAccessListener.php new file mode 100644 index 0000000..adbb209 --- /dev/null +++ b/src/Listener/PublicAccessListener.php @@ -0,0 +1,100 @@ +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, + ], + )); + } + } +} diff --git a/src/Service/PublicPathMatcher.php b/src/Service/PublicPathMatcher.php new file mode 100644 index 0000000..df5a608 --- /dev/null +++ b/src/Service/PublicPathMatcher.php @@ -0,0 +1,141 @@ + */ + 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 + */ + 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 . '$#'; + } +} diff --git a/src/Service/PublicPathMatcherInterface.php b/src/Service/PublicPathMatcherInterface.php new file mode 100644 index 0000000..0f889ef --- /dev/null +++ b/src/Service/PublicPathMatcherInterface.php @@ -0,0 +1,31 @@ +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')); + } +} diff --git a/tests/TestKernel.php b/tests/TestKernel.php index 369568d..61f6d53 100644 --- a/tests/TestKernel.php +++ b/tests/TestKernel.php @@ -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'); } diff --git a/tests/Unit/Listener/PublicAccessListenerTest.php b/tests/Unit/Listener/PublicAccessListenerTest.php new file mode 100644 index 0000000..7261060 --- /dev/null +++ b/tests/Unit/Listener/PublicAccessListenerTest.php @@ -0,0 +1,227 @@ +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')); + } +} diff --git a/tests/Unit/Service/PublicPathMatcherTest.php b/tests/Unit/Service/PublicPathMatcherTest.php new file mode 100644 index 0000000..010a789 --- /dev/null +++ b/tests/Unit/Service/PublicPathMatcherTest.php @@ -0,0 +1,241 @@ +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')); + } +}