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

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:

  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 pathAcceptListener 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 pathPublicAccessListener 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.