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.
341 lines
13 KiB
Markdown
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.
|