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:
1 parent
9ad54f8e2a
commit
5563999525
19 files changed
+1491
-54
No files matched your search
@@ -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,
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user