Files
lyra 5563999525
Sync GitHub / sync (push) Successful in 8s
feat: public rate-limited access for v1.1
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

341 lines
13 KiB
Markdown

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