feat: public rate-limited access for v1.1
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.
This commit is contained in:
lyra committed 2026-08-12 09:26:50 -04:00
1 parent 9ad54f8e2a
commit 5563999525
19 files changed
+1491 -54

No files matched your search

+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Listener;
use App\Service\DomainInterface;
use App\Service\PublicPathMatcherInterface;
use App\Trait\HasLoggerTrait;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
/**
* Allows rate-limited unauthenticated access to configured public paths.
*
* Runs at priority 84 — after AcceptListener (99) and AllowListener (88)
* so authenticated users bypass this listener entirely, but before
* RejectListener (77) and LoginListener (66) so public traffic is not
* subject to the login rate limiter.
*
* When the request path matches a configured public path pattern:
* - If within rate limit → 200 OK (no Remote-User header)
* - If over rate limit → 429 Too Many Requests with Retry-After header
*
* Non-matching paths fall through to the normal auth flow.
*/
final readonly class PublicAccessListener
{
use HasLoggerTrait;
private RateLimiterFactoryInterface $rateLimiter;
public function __construct(
private PublicPathMatcherInterface $pathMatcher,
private DomainInterface $domainManager,
private Environment $twig,
#[Target('public_limiter')] RateLimiterFactoryInterface $rateLimiter,
) {
$this->rateLimiter = $rateLimiter;
}
/** @throws SyntaxError|RuntimeError|LoaderError */
#[AsEventListener(priority: 84)]
public function onKernelRequest(RequestEvent $event): void
{
if ($this->pathMatcher->isEmpty()) {
return;
}
$request = $event->getRequest();
$host = $request->getHost();
$path = $request->getPathInfo();
// Never treat the auth subdomain itself as public
if ($this->domainManager->getAuthSubdomain() === $host) {
return;
}
if (! $this->pathMatcher->matches($host, $path)) {
return;
}
// Path is public — apply rate limiting
$limiter = $this->rateLimiter->create($request->getClientIp());
$limit = $limiter->consume(1);
if ($limit->isAccepted()) {
$this->logger->debug("public access granted: {$request->getClientIp()} -> $path");
$event->setResponse(new Response(
'',
Response::HTTP_OK,
[
'Content-Type' => 'text/plain',
'Retry-After' => (string) $limit->getRemainingTokens(),
],
));
} else {
$retryAfter = $limit->getRetryAfter()?->getTimestamp() - time();
$retryAfter = max(1, $retryAfter);
$this->logger->debug("public access rate-limited: {$request->getClientIp()} -> $path");
$html = $this->twig->render('error.html.twig');
$event->setResponse(new Response(
$html,
Response::HTTP_TOO_MANY_REQUESTS,
[
'Content-Type' => 'text/html',
'Retry-After' => (string) $retryAfter,
],
));
}
}
}
+141
View File
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Matches request paths against configured public path patterns.
*
* Patterns are provided as a comma-separated string in the format:
* /path/pattern, host.example.com/path/pattern, or a mix.
*
* Wildcards:
* - * matches any characters within a single path segment (not crossing /)
* - ** matches any characters including / (crosses path segments)
*
* Query strings are not part of the pattern — matching is against the
* path only.
*/
final readonly class PublicPathMatcher implements PublicPathMatcherInterface
{
/** @var list<array{host: ?string, regex: string}> */
private array $patterns;
public function __construct(
#[Autowire('%app.public_paths%')] string $publicPaths,
) {
$this->patterns = $this->parse($publicPaths);
}
public function isEmpty(): bool
{
return $this->patterns === [];
}
public function matches(string $host, string $path): bool
{
if ($this->patterns === []) {
return false;
}
$host = strtolower($host);
foreach ($this->patterns as $entry) {
if ($entry['host'] !== null && $entry['host'] !== $host) {
continue;
}
if (preg_match($entry['regex'], $path) === 1) {
return true;
}
}
return false;
}
/**
* Parse the comma-separated PUBLIC_PATHS string into pattern entries.
*
* @return list<array{host: ?string, regex: string}>
*/
private function parse(string $publicPaths): array
{
if (trim($publicPaths) === '') {
return [];
}
$patterns = [];
foreach (explode(',', $publicPaths) as $raw) {
$entry = trim($raw);
if ($entry === '') {
continue;
}
// Check for a host prefix (anything before the first /)
$host = null;
$path = $entry;
if (preg_match('/^([a-z0-9.-]+)(\/.+)$/i', $entry, $m)) {
$host = strtolower($m[1]);
$path = $m[2];
}
// Validate path starts with /
if (!str_starts_with($path, '/')) {
continue;
}
$patterns[] = [
'host' => $host,
'regex' => $this->compilePattern($path),
];
}
return $patterns;
}
/**
* Convert a wildcard path pattern into a regex string.
*
* Star becomes a character class matching one or more non-slash chars.
* Double-star at end of pattern matches zero or more of any char.
* Double-star followed by slash matches zero or more path segments.
* Other characters are escaped as literal regex.
*/
private function compilePattern(string $pattern): string
{
$regex = '';
$length = strlen($pattern);
$i = 0;
while ($i < $length) {
// Check for ** (must be at current position)
if ($i + 1 < $length && $pattern[$i] === '*' && $pattern[$i + 1] === '*') {
$i += 2;
if ($i >= $length) {
// ** at end of pattern: zero or more chars including /
$regex .= '.*';
} elseif ($pattern[$i] === '/') {
// /**/ in middle: zero or more intermediate segments
$regex .= '(?:.*/)?';
$i += 1; // skip the / after **
} else {
// ** not followed by / or end, treat as .*
$regex .= '.*';
}
} elseif ($pattern[$i] === '*') {
$regex .= '[^/]+';
$i += 1;
} else {
$regex .= preg_quote($pattern[$i], '#');
$i += 1;
}
}
return '#^' . $regex . '$#';
}
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Service;
/**
* Matches request paths against configured public path patterns.
*
* Patterns support simple wildcards:
* - `*` matches any characters within a single path segment (not crossing `/`)
* - `**` matches any characters including `/` (crosses path segments)
*
* Patterns may optionally include a host prefix (e.g. `example.com/public/**`).
* When no host prefix is given, the pattern matches on any host.
*/
interface PublicPathMatcherInterface
{
/**
* Returns true if the given host and path match any configured public pattern.
*
* @param string $host The request host (e.g. "code.example.com")
* @param string $path The request path (e.g. "/public/repo/issues")
*/
public function matches(string $host, string $path): bool;
/**
* Returns true if no public paths are configured (feature is disabled).
*/
public function isEmpty(): bool;
}