feat: v1.1 — public rate-limited access #5

Merged
andrew merged 3 commits from feat/v1.1-public-access into main 2026-08-13 01:30:13 -04:00
Member

Summary

First feature for v1.1: public rate-limited access. This allows select paths to be made publicly accessible without TOTP authentication, with separate per-IP rate limiting to protect server resources from bot traffic.

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. Authenticated users bypass the public rate limiter entirely.

Tests: 293 passing (605 assertions) · Linting: PHP CS Fixer clean (63/63 files) · Working tree: clean

Built on top of fix/v1.0-must-fix (PR #4). This PR should be merged after #4 is merged into main.


How It Works

A new PublicAccessListener (priority 84) sits in the request pipeline after the auth listeners (AcceptListener at 99, AllowListener at 88) but before the login rate limiter (RejectListener at 77). This means:

  1. Authenticated users never reach PublicAccessListener — they get 200 from AcceptListener/AllowListener first.
  2. Unauthenticated users visiting a configured public path get rate-limited by a separate public_limiter (independent from the login attempt limiter). Within limit → 200 OK (no Remote-User header). Over limit → 429 with Retry-After header.
  3. Unauthenticated users visiting a non-public path fall through to the normal auth flow (login page or redirect).

New Components

PublicPathMatcher (src/Service/PublicPathMatcher.php)

  • Parses the PUBLIC_PATHS env var into pattern entries
  • Supports * (single segment) and ** (cross-segment) wildcards
  • Supports optional host prefix (e.g., code.example.com/public/**)
  • Query strings are ignored — matching is against path only
  • Hosts are case-insensitive; paths are case-sensitive

PublicAccessListener (src/Listener/PublicAccessListener.php)

  • Priority 84 — after auth checks, before login rate limiter
  • Skips when PUBLIC_PATHS is empty (feature disabled)
  • Skips requests to the auth subdomain
  • Consumes 1 token from public_limiter per request
  • 200 OK (text/plain, no Remote-User) when within limit
  • 429 Too Many Requests (text/html, Retry-After) when over limit

Configuration

Variable Default Description
PUBLIC_PATHS '' (disabled) Comma-separated path patterns with wildcards
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

Path pattern syntax:

  • /public — exact match
  • /public/* — matches one segment after /public/
  • /public/** — matches any depth after /public/
  • host.com/public/** — restricts to specific host

When PUBLIC_PATHS is empty (default), the feature is completely disabled and has zero impact on existing behavior.

Tests (52 new)

PublicPathMatcherTest (29 unit tests)

  • Empty/whitespace strings, exact matching, single/double wildcards
  • Mid-path wildcards, multiple patterns, whitespace handling
  • Domain-prefixed patterns, mixed domain/plain patterns
  • Case sensitivity, invalid patterns, special regex characters

PublicAccessListenerTest (12 unit tests)

  • Feature disabled, non-public path, public path within/over rate limit
  • Auth subdomain skipped, query string ignored, domain-scoped paths
  • Wildcard matching, Retry-After header

PublicAccessFlowTest (11 functional tests)

  • Public path accessible without auth, query string ignored, deep paths
  • Non-public path shows login, exact path without slash not matched
  • Rate limit enforced after burst exceeded
  • Authenticated user bypasses public rate limit
  • Security headers on public responses, error template on 429

Documentation

  • README.md — New "Public Rate-Limited Access" section with config table, pattern syntax, and examples. Updated Features list and listener pipeline.
  • CHANGELOG.md — v1.1 section with all new features.
  • ROADMAP.md — Phase 1 marked as completed. Updated listener pipeline, test counts (293/605), and source→test mapping.
  • docs/Caddyfile — Added example of public + protected service config.
  • docs/example.env — All new env vars documented with comments.
  • docs/v1.1-plan.md — Full design plan document for reference.
## Summary First feature for v1.1: **public rate-limited access**. This allows select paths to be made publicly accessible without TOTP authentication, with separate per-IP rate limiting to protect server resources from bot traffic. **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. Authenticated users bypass the public rate limiter entirely. **Tests:** 293 passing (605 assertions) · **Linting:** PHP CS Fixer clean (63/63 files) · **Working tree:** clean Built on top of `fix/v1.0-must-fix` (PR #4). This PR should be merged after #4 is merged into main. --- ## How It Works A new `PublicAccessListener` (priority 84) sits in the request pipeline after the auth listeners (AcceptListener at 99, AllowListener at 88) but before the login rate limiter (RejectListener at 77). This means: 1. **Authenticated users** never reach `PublicAccessListener` — they get 200 from AcceptListener/AllowListener first. 2. **Unauthenticated users** visiting a configured public path get rate-limited by a separate `public_limiter` (independent from the login attempt limiter). Within limit → 200 OK (no `Remote-User` header). Over limit → 429 with `Retry-After` header. 3. **Unauthenticated users** visiting a non-public path fall through to the normal auth flow (login page or redirect). ## New Components ### `PublicPathMatcher` (`src/Service/PublicPathMatcher.php`) - Parses the `PUBLIC_PATHS` env var into pattern entries - Supports `*` (single segment) and `**` (cross-segment) wildcards - Supports optional host prefix (e.g., `code.example.com/public/**`) - Query strings are ignored — matching is against path only - Hosts are case-insensitive; paths are case-sensitive ### `PublicAccessListener` (`src/Listener/PublicAccessListener.php`) - Priority 84 — after auth checks, before login rate limiter - Skips when `PUBLIC_PATHS` is empty (feature disabled) - Skips requests to the auth subdomain - Consumes 1 token from `public_limiter` per request - 200 OK (text/plain, no Remote-User) when within limit - 429 Too Many Requests (text/html, Retry-After) when over limit ## Configuration | Variable | Default | Description | |----------|---------|-------------| | `PUBLIC_PATHS` | `''` (disabled) | Comma-separated path patterns with wildcards | | `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 | **Path pattern syntax:** - `/public` — exact match - `/public/*` — matches one segment after /public/ - `/public/**` — matches any depth after /public/ - `host.com/public/**` — restricts to specific host When `PUBLIC_PATHS` is empty (default), the feature is completely disabled and has zero impact on existing behavior. ## Tests (52 new) ### `PublicPathMatcherTest` (29 unit tests) - Empty/whitespace strings, exact matching, single/double wildcards - Mid-path wildcards, multiple patterns, whitespace handling - Domain-prefixed patterns, mixed domain/plain patterns - Case sensitivity, invalid patterns, special regex characters ### `PublicAccessListenerTest` (12 unit tests) - Feature disabled, non-public path, public path within/over rate limit - Auth subdomain skipped, query string ignored, domain-scoped paths - Wildcard matching, Retry-After header ### `PublicAccessFlowTest` (11 functional tests) - Public path accessible without auth, query string ignored, deep paths - Non-public path shows login, exact path without slash not matched - Rate limit enforced after burst exceeded - Authenticated user bypasses public rate limit - Security headers on public responses, error template on 429 ## Documentation - **README.md** — New "Public Rate-Limited Access" section with config table, pattern syntax, and examples. Updated Features list and listener pipeline. - **CHANGELOG.md** — v1.1 section with all new features. - **ROADMAP.md** — Phase 1 marked as completed. Updated listener pipeline, test counts (293/605), and source→test mapping. - **docs/Caddyfile** — Added example of public + protected service config. - **docs/example.env** — All new env vars documented with comments. - **docs/v1.1-plan.md** — Full design plan document for reference.
andrew changed target branch from fix/v1.0-must-fix to main 2026-08-12 22:32:58 -04:00
andrew added 2 commits 2026-08-12 22:32:58 -04:00
feat: public rate-limited access for v1.1
Sync GitHub / sync (push) Successful in 8s
5563999525
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.
Merge branch 'fix/v1.0-must-fix' into feat/v1.1-public-access
Push Develop / docker (push) Successful in 6m1s
Sync GitHub / sync (push) Successful in 7s
Tests / test (push) Successful in 1m3s
29e471c536
andrew approved these changes 2026-08-13 01:18:41 -04:00
Dismissed
andrew added 1 commit 2026-08-13 01:19:09 -04:00
Merge branch 'main' into feat/v1.1-public-access
Tests / test (pull_request) Successful in 54s
Sync GitHub / sync (push) Successful in 13s
17c2d525ff
andrew approved these changes 2026-08-13 01:29:57 -04:00
andrew merged commit 66b960ccea into main 2026-08-13 01:30:13 -04:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: public/preauth#5