# 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.