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.
13 KiB
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:
- The public rate limiter is consulted (separate from the login limiter).
- If within limits →
200 OK(noRemote-Userheader). Caddy proxies to the backend. - If over limits →
429 Too Many Requestswith aRetry-Afterheader.
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
-
PublicPathMatcher— Pure path matching logic, no dependencies. Parse thePUBLIC_PATHSstring into pattern entries (each with optional host + path pattern). Convert*/**wildcards to regex. Match a given (host, path) against all patterns. -
Config — Add env vars to
services.yaml, add rate limiter torate_limiter.yaml, add cache pool tocache.yaml+ test cache. -
ConfigBag— AddpublicPaths()returning the raw string (thePublicPathMatcherdoes the parsing). Or add thePublicPathMatcheras a service that receives the raw string via autowiring. -
PublicAccessListener— InjectPublicPathMatcherInterface,RateLimiterFactoryInterface(targetpublic_limiter), andConfigBag. OnRequestEvent:- 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).
-
Tests — Unit tests for
PublicPathMatcherandPublicAccessListener, functional tests for the full flow. -
Documentation — Update all docs.
-
Lint + Test — Run php-cs-fixer + phpunit, fix any issues.
-
Commit + Push + PR.
Key Design Decisions
Why priority 84?
-
Must be after
AcceptListener(99) andAllowListener(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 inPublicAccessListenerbeforeLoginListenerruns, 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-Preauthheader on a public path,PublicAccessListenerwould return 200 beforeLoginListenercan 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:
PublicAccessListenershould 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
-
Empty
PUBLIC_PATHS→ Feature disabled, zero impact on existing behavior. All tests pass unchanged. -
Authenticated user visits a public path →
AcceptListenerorAllowListenerreturns 200 beforePublicAccessListenerruns. The public rate limiter is never consulted. -
Public path rate limit exceeded → 429 with
Retry-Afterheader. The response uses the error template (same as login rate limit) but always with 429 status (never teapot — teapot is for login failures). -
Non-public path on a host that has some public paths → Falls through to the normal auth flow. Login page or redirect.
-
PUBLIC_PATHSwith whitespace → Trimmed during parsing.PUBLIC_PATHS='/public, /api'is equivalent to/public,/api. -
Invalid patterns (not starting with
/) → Silently ignored during parsing. Logged at debug level. -
Login attempt on a public path →
PublicAccessListenerreturns 200 beforeLoginListenerruns. This is correct behavior — if the path is public, no login is needed. -
Subdomain redirect mode + public paths → If using an auth subdomain, requests to the auth subdomain itself should never be treated as public. The
PublicAccessListenershould skip requests wherehost === 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
ListenerTestHelperfor 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.