diff --git a/.env.example b/.env.example index e590d2aca..52ae6035c 100644 --- a/.env.example +++ b/.env.example @@ -76,6 +76,19 @@ APP_DEBUG=false APP_TIMEZONE=UTC APP_URL= +# Trusted proxies / Cloudflare +# Comma-separated list of additional proxies to trust alongside Cloudflare. +TRUSTED_PROXIES= +TRUST_CLOUDFLARE=true +CLOUDFLARE_IPS_V4_URL=https://www.cloudflare.com/ips-v4 +CLOUDFLARE_IPS_V6_URL=https://www.cloudflare.com/ips-v6 +CLOUDFLARE_IPS_TIMEOUT=10 +CLOUDFLARE_IPS_CONNECT_TIMEOUT=5 +CLOUDFLARE_IPS_RETRY_TIMES=2 +CLOUDFLARE_IPS_RETRY_SLEEP_MS=250 +CLOUDFLARE_IPS_STORAGE_PATH=storage/app/cloudflare/trusted-proxies.json +CLOUDFLARE_TRUST_REMOTE_ADDR_FALLBACK=true + APP_LOCALE=en APP_FALLBACK_LOCALE=en APP_FAKER_LOCALE=en_US diff --git a/app/Console/Commands/CloudflareReload.php b/app/Console/Commands/CloudflareReload.php new file mode 100644 index 000000000..f569aa7b5 --- /dev/null +++ b/app/Console/Commands/CloudflareReload.php @@ -0,0 +1,77 @@ +option('dry-run'); + + $this->info($dryRun + ? 'Fetching and validating Cloudflare trusted proxy IP ranges (dry run)...' + : 'Refreshing Cloudflare trusted proxy IP ranges...'); + + try { + $manifest = $this->cloudflareIpRangeService->refresh(! $dryRun); + } catch (Throwable $exception) { + report($exception); + $this->error('Failed to refresh Cloudflare IP ranges: '.$exception->getMessage()); + + return Command::FAILURE; + } + + $manualProxies = $this->cloudflareIpRangeService->manualTrustedProxies(); + $totalTrustedProxies = array_values(array_unique([ + ...$manualProxies, + ...($manifest['proxies'] ?? []), + ])); + + $this->line('Cloudflare IPv4 ranges: '.count($manifest['ipv4'] ?? [])); + $this->line('Cloudflare IPv6 ranges: '.count($manifest['ipv6'] ?? [])); + $this->line('Combined Cloudflare proxy count: '.count($manifest['proxies'] ?? [])); + $this->line('Manual proxy count: '.count($manualProxies)); + $this->line('Effective trusted proxy count: '.count($totalTrustedProxies)); + + if ($dryRun) { + $this->info('Dry run complete; manifest file was not updated.'); + + return Command::SUCCESS; + } + + $this->info('Stored Cloudflare manifest: '.$this->cloudflareIpRangeService->manifestPath()); + $this->info('Last updated at: '.($manifest['updated_at'] ?? 'unknown')); + + return Command::SUCCESS; + } +} diff --git a/app/Http/Middleware/TrustProxies.php b/app/Http/Middleware/TrustProxies.php index 214a838fa..ed8dae9f0 100644 --- a/app/Http/Middleware/TrustProxies.php +++ b/app/Http/Middleware/TrustProxies.php @@ -4,21 +4,12 @@ declare(strict_types=1); namespace App\Http\Middleware; +use App\Services\Cloudflare\CloudflareIpRangeService; use Illuminate\Http\Middleware\TrustProxies as Middleware; use Symfony\Component\HttpFoundation\Request; class TrustProxies extends Middleware { - /** - * Trust the upstream reverse proxy chain. - * - * This keeps Cloudflare / CDN deployments working without the external - * package while remaining compatible with Laravel 12 and 13. - * - * @var string|array|null - */ - protected $proxies = '*'; - /** * The headers used to detect proxy forwarding information. */ @@ -28,4 +19,17 @@ class TrustProxies extends Middleware | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_PREFIX | Request::HEADER_X_FORWARDED_AWS_ELB; + + /** + * Resolve trusted proxies from the stored Cloudflare manifest and any + * manually configured proxy ranges. + * + * @return array|string|null + */ + protected function proxies() + { + $proxies = app(CloudflareIpRangeService::class)->trustedProxies(); + + return $proxies !== [] ? $proxies : null; + } } diff --git a/app/Services/Cloudflare/CloudflareIpRangeService.php b/app/Services/Cloudflare/CloudflareIpRangeService.php new file mode 100644 index 000000000..4e032d904 --- /dev/null +++ b/app/Services/Cloudflare/CloudflareIpRangeService.php @@ -0,0 +1,358 @@ + + */ + private const ALLOWED_PROXY_TOKENS = [ + 'REMOTE_ADDR', + 'PRIVATE_SUBNETS', + 'private_ranges', + ]; + + public function __construct( + private readonly Filesystem $files, + ) {} + + /** + * Fetch the latest Cloudflare IP ranges and optionally persist them. + * + * @return array + */ + public function refresh(bool $persist = true): array + { + $ipv4 = $this->fetchRanges((string) config('trustedproxy.cloudflare.ipv4_url')); + $ipv6 = $this->fetchRanges((string) config('trustedproxy.cloudflare.ipv6_url')); + $proxies = $this->deduplicate(array_merge($ipv4, $ipv6)); + + if ($proxies === []) { + throw new RuntimeException('Cloudflare did not return any trusted proxy CIDRs.'); + } + + $manifest = [ + 'version' => self::MANIFEST_VERSION, + 'updated_at' => now()->toIso8601String(), + 'sources' => [ + 'ipv4_url' => (string) config('trustedproxy.cloudflare.ipv4_url'), + 'ipv6_url' => (string) config('trustedproxy.cloudflare.ipv6_url'), + ], + 'ipv4' => $ipv4, + 'ipv6' => $ipv6, + 'proxies' => $proxies, + ]; + + if ($persist) { + $this->writeManifest($manifest); + } + + return $manifest; + } + + /** + * Return the currently effective trusted proxies. + * + * @return array + */ + public function trustedProxies(): array + { + $proxies = $this->deduplicate([ + ...$this->manualTrustedProxies(), + ...$this->cloudflareTrustedProxies(), + ]); + + if ($proxies !== []) { + return $proxies; + } + + if ((bool) config('trustedproxy.cloudflare.fallback_to_remote_addr', true)) { + return ['REMOTE_ADDR']; + } + + return []; + } + + /** + * Return manually configured trusted proxies. + * + * @return array + */ + public function manualTrustedProxies(): array + { + return $this->normalizeConfiguredProxies(config('trustedproxy.proxies')); + } + + /** + * Return persisted Cloudflare ranges when Cloudflare trust is enabled. + * + * @return array + */ + public function cloudflareTrustedProxies(): array + { + if (! (bool) config('trustedproxy.cloudflare.enabled', true)) { + return []; + } + + return $this->storedTrustedProxies(); + } + + public function manifestPath(): string + { + return $this->resolveManifestPath( + (string) config('trustedproxy.cloudflare.storage_path', storage_path('app/cloudflare/trusted-proxies.json')) + ); + } + + private function resolveManifestPath(string $path): string + { + $path = trim($path); + + if ($path === '') { + return storage_path('app/cloudflare/trusted-proxies.json'); + } + + if ($this->isAbsolutePath($path)) { + return $path; + } + + return base_path($path); + } + + private function isAbsolutePath(string $path): bool + { + return str_starts_with($path, '/') + || str_starts_with($path, '\\') + || preg_match('~^[A-Za-z]:[\\/]~', $path) === 1; + } + + /** + * @return array + */ + private function storedTrustedProxies(): array + { + $manifest = $this->readManifest(); + + if ($manifest === null) { + return []; + } + + $ranges = $manifest['proxies'] ?? array_merge($manifest['ipv4'] ?? [], $manifest['ipv6'] ?? []); + + if (! is_array($ranges)) { + Log::warning('Cloudflare trusted proxy manifest is missing proxy arrays.', [ + 'path' => $this->manifestPath(), + ]); + + return []; + } + + try { + return $this->normalizeRanges($ranges); + } catch (RuntimeException $exception) { + Log::warning('Ignoring invalid Cloudflare trusted proxy manifest.', [ + 'path' => $this->manifestPath(), + 'message' => $exception->getMessage(), + ]); + + return []; + } + } + + /** + * @return array|null + */ + private function readManifest(): ?array + { + $path = $this->manifestPath(); + + if (! $this->files->exists($path)) { + return null; + } + + try { + $decoded = json_decode($this->files->get($path), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + Log::warning('Unable to decode Cloudflare trusted proxy manifest.', [ + 'path' => $path, + 'message' => $exception->getMessage(), + ]); + + return null; + } + + return is_array($decoded) ? $decoded : null; + } + + /** + * @return array + */ + private function fetchRanges(string $url): array + { + if ($url === '') { + throw new RuntimeException('Cloudflare IP list URL is not configured.'); + } + + $response = Http::accept('text/plain') + ->connectTimeout((int) config('trustedproxy.cloudflare.connect_timeout', 5)) + ->retry( + (int) config('trustedproxy.cloudflare.retry_times', 2), + (int) config('trustedproxy.cloudflare.retry_sleep_ms', 250), + ) + ->timeout((int) config('trustedproxy.cloudflare.timeout', 10)) + ->get($url); + + if (! $response->successful()) { + throw new RuntimeException("Cloudflare IP refresh failed for [{$url}] with HTTP status {$response->status()}."); + } + + $ranges = $this->parseRangesFromBody($response->body()); + + if ($ranges === []) { + throw new RuntimeException("Cloudflare IP refresh returned no valid CIDRs for [{$url}]."); + } + + return $ranges; + } + + /** + * @return array + */ + private function parseRangesFromBody(string $body): array + { + $lines = preg_split('/\r\n|\r|\n/', $body) ?: []; + + return $this->normalizeRanges($lines); + } + + /** + * @param array|string|null $proxies + * @return array + */ + private function normalizeConfiguredProxies(array|string|null $proxies): array + { + if ($proxies === null) { + return []; + } + + if (is_string($proxies)) { + $proxies = preg_split('/[\s,]+/', $proxies) ?: []; + } + + try { + return $this->normalizeRanges($proxies, true); + } catch (RuntimeException $exception) { + Log::warning('Ignoring invalid manually configured trusted proxies.', [ + 'message' => $exception->getMessage(), + ]); + + return []; + } + } + + /** + * @param array $ranges + * @return array + */ + private function normalizeRanges(array $ranges, bool $allowProxyTokens = false): array + { + $normalized = []; + + foreach ($ranges as $range) { + if (! is_string($range)) { + continue; + } + + $range = trim($range); + + if ($range === '' || str_starts_with($range, '#')) { + continue; + } + + if ($allowProxyTokens && in_array($range, self::ALLOWED_PROXY_TOKENS, true)) { + $normalized[] = $range === 'private_ranges' ? 'PRIVATE_SUBNETS' : $range; + + continue; + } + + if (! $this->isValidCidr($range)) { + throw new RuntimeException("Invalid trusted proxy entry [{$range}]."); + } + + $normalized[] = $range; + } + + return $this->deduplicate($normalized); + } + + private function isValidCidr(string $cidr): bool + { + if (! str_contains($cidr, '/')) { + return false; + } + + [$address, $prefix] = explode('/', $cidr, 2); + + if (! ctype_digit($prefix)) { + return false; + } + + $isIpv4 = filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false; + $isIpv6 = ! $isIpv4 && filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + + if (! $isIpv4 && ! $isIpv6) { + return false; + } + + $maxPrefix = $isIpv4 ? 32 : 128; + $prefixLength = (int) $prefix; + + return $prefixLength >= 0 && $prefixLength <= $maxPrefix; + } + + /** + * @param array $ranges + * @return array + */ + private function deduplicate(array $ranges): array + { + return array_values(array_unique($ranges)); + } + + /** + * @param array $manifest + */ + private function writeManifest(array $manifest): void + { + $path = $this->manifestPath(); + $directory = dirname($path); + $temporaryPath = $path.'.tmp'; + + $this->files->ensureDirectoryExists($directory); + $this->files->put( + $temporaryPath, + json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) + ); + + if ($this->files->exists($path)) { + $this->files->delete($path); + } + + if (! @rename($temporaryPath, $path)) { + $this->files->delete($temporaryPath); + + throw new RuntimeException("Unable to persist the Cloudflare trusted proxy manifest to [{$path}]."); + } + } +} diff --git a/config/trustedproxy.php b/config/trustedproxy.php new file mode 100644 index 000000000..d88237725 --- /dev/null +++ b/config/trustedproxy.php @@ -0,0 +1,31 @@ + env('TRUSTED_PROXIES'), + + /* + |-------------------------------------------------------------------------- + | Cloudflare Trusted Proxy Settings + |-------------------------------------------------------------------------- + */ + 'cloudflare' => [ + 'enabled' => env('TRUST_CLOUDFLARE', true), + 'ipv4_url' => env('CLOUDFLARE_IPS_V4_URL', 'https://www.cloudflare.com/ips-v4'), + 'ipv6_url' => env('CLOUDFLARE_IPS_V6_URL', 'https://www.cloudflare.com/ips-v6'), + 'timeout' => env('CLOUDFLARE_IPS_TIMEOUT', 10), + 'connect_timeout' => env('CLOUDFLARE_IPS_CONNECT_TIMEOUT', 5), + 'retry_times' => env('CLOUDFLARE_IPS_RETRY_TIMES', 2), + 'retry_sleep_ms' => env('CLOUDFLARE_IPS_RETRY_SLEEP_MS', 250), + 'storage_path' => env('CLOUDFLARE_IPS_STORAGE_PATH', storage_path('app/cloudflare/trusted-proxies.json')), + 'fallback_to_remote_addr' => env('CLOUDFLARE_TRUST_REMOTE_ADDR_FALLBACK', true), + ], +]; diff --git a/routes/console.php b/routes/console.php index 1357e5510..168ee8fe6 100644 --- a/routes/console.php +++ b/routes/console.php @@ -32,6 +32,7 @@ Schedule::command('nntmux:disable-expired-promotions')->daily(); // Automatically mark completed registration periods as done shortly after they end Schedule::command('nntmux:disable-expired-registration-periods')->everyFiveMinutes()->withoutOverlapping(); Schedule::command('nntmux:remove-bad')->hourly()->withoutOverlapping(); +Schedule::command('cloudflare:reload')->daily()->withoutOverlapping(); Schedule::command('cache:prune-stale-tags')->hourly(); Schedule::command('nntmux:collect-stats')->hourly(); Schedule::command('nntmux:populate-steam-apps')->monthly(); diff --git a/tests/Feature/Console/CloudflareReloadCommandTest.php b/tests/Feature/Console/CloudflareReloadCommandTest.php new file mode 100644 index 000000000..9bc6d3e31 --- /dev/null +++ b/tests/Feature/Console/CloudflareReloadCommandTest.php @@ -0,0 +1,168 @@ + + */ + private array $originalEnvironment = []; + + public function createApplication() + { + $this->databasePath = sys_get_temp_dir().'/nntmux-cloudflare-command-test.sqlite'; + + $this->originalEnvironment = [ + 'APP_ENV' => getenv('APP_ENV'), + 'DB_CONNECTION' => getenv('DB_CONNECTION'), + 'DB_DATABASE' => getenv('DB_DATABASE'), + ]; + + if (file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + $pdo = new PDO('sqlite:'.$this->databasePath); + $pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)'); + $pdo->exec("INSERT INTO settings (name, value) VALUES + ('categorizeforeign', '0'), + ('catwebdl', '0')"); + + $this->setEnvironmentValue('APP_ENV', 'testing'); + $this->setEnvironmentValue('DB_CONNECTION', 'sqlite'); + $this->setEnvironmentValue('DB_DATABASE', $this->databasePath); + + $app = require __DIR__.'/../../../bootstrap/app.php'; + + $app->make(Kernel::class)->bootstrap(); + + return $app; + } + + protected function setUp(): void + { + parent::setUp(); + + $this->manifestPath = 'storage/framework/testing/cloudflare-'.Str::uuid()->toString().'.json'; + $this->resolvedManifestPath = base_path($this->manifestPath); + + config([ + 'trustedproxy.proxies' => null, + 'database.default' => 'sqlite', + 'database.connections.sqlite.database' => $this->databasePath, + 'trustedproxy.cloudflare.storage_path' => $this->manifestPath, + 'trustedproxy.cloudflare.ipv4_url' => 'https://www.cloudflare.com/ips-v4', + 'trustedproxy.cloudflare.ipv6_url' => 'https://www.cloudflare.com/ips-v6', + 'trustedproxy.cloudflare.fallback_to_remote_addr' => false, + ]); + } + + protected function tearDown(): void + { + if (is_file($this->resolvedManifestPath)) { + unlink($this->resolvedManifestPath); + } + + if ($this->databasePath !== '' && file_exists($this->databasePath)) { + unlink($this->databasePath); + } + + $directory = dirname($this->resolvedManifestPath); + + if (is_dir($directory)) { + @rmdir($directory); + } + + parent::tearDown(); + + foreach ($this->originalEnvironment as $key => $value) { + $this->setEnvironmentValue($key, $value === false ? null : $value); + } + } + + public function test_it_refreshes_and_persists_cloudflare_ip_ranges(): void + { + Http::fake([ + 'https://www.cloudflare.com/ips-v4' => Http::response("173.245.48.0/20\n104.16.0.0/13\n", 200), + 'https://www.cloudflare.com/ips-v6' => Http::response("2400:cb00::/32\n2a06:98c0::/29\n", 200), + ]); + + $this->artisan('cloudflare:reload') + ->expectsOutputToContain('Cloudflare IPv4 ranges: 2') + ->expectsOutputToContain('Cloudflare IPv6 ranges: 2') + ->expectsOutputToContain('Combined Cloudflare proxy count: 4') + ->expectsOutputToContain('Stored Cloudflare manifest: '.base_path($this->manifestPath)) + ->assertExitCode(0); + + $this->assertFileExists($this->resolvedManifestPath); + + $manifest = json_decode((string) file_get_contents($this->resolvedManifestPath), true, 512, JSON_THROW_ON_ERROR); + + $this->assertSame(['173.245.48.0/20', '104.16.0.0/13'], $manifest['ipv4']); + $this->assertSame(['2400:cb00::/32', '2a06:98c0::/29'], $manifest['ipv6']); + $this->assertCount(4, $manifest['proxies']); + Http::assertSentCount(2); + } + + public function test_it_preserves_the_existing_manifest_when_refresh_fails(): void + { + $existingManifest = [ + 'version' => 1, + 'updated_at' => '2026-03-30T00:00:00+00:00', + 'ipv4' => ['173.245.48.0/20'], + 'ipv6' => [], + 'proxies' => ['173.245.48.0/20'], + ]; + + if (! is_dir(dirname($this->resolvedManifestPath))) { + mkdir(dirname($this->resolvedManifestPath), 0777, true); + } + + file_put_contents( + $this->resolvedManifestPath, + json_encode($existingManifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) + ); + + Http::fake([ + 'https://www.cloudflare.com/ips-v4' => Http::response('upstream failure', 500), + 'https://www.cloudflare.com/ips-v6' => Http::response("2400:cb00::/32\n", 200), + ]); + + $this->artisan('cloudflare:reload') + ->expectsOutputToContain('Failed to refresh Cloudflare IP ranges:') + ->assertExitCode(1); + + $manifest = json_decode((string) file_get_contents($this->resolvedManifestPath), true, 512, JSON_THROW_ON_ERROR); + + $this->assertSame($existingManifest, $manifest); + } + + private function setEnvironmentValue(string $key, ?string $value): void + { + if ($value === null) { + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); + + return; + } + + putenv($key.'='.$value); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } +} diff --git a/tests/Unit/Http/Middleware/TrustProxiesTest.php b/tests/Unit/Http/Middleware/TrustProxiesTest.php new file mode 100644 index 000000000..46f3a2b98 --- /dev/null +++ b/tests/Unit/Http/Middleware/TrustProxiesTest.php @@ -0,0 +1,105 @@ +manifestPath = 'storage/framework/testing/trusted-proxies-test.json'; + $this->resolvedManifestPath = base_path($this->manifestPath); + + config([ + 'trustedproxy.proxies' => null, + 'trustedproxy.cloudflare.storage_path' => $this->manifestPath, + 'trustedproxy.cloudflare.enabled' => true, + 'trustedproxy.cloudflare.fallback_to_remote_addr' => true, + ]); + } + + protected function tearDown(): void + { + TrustProxies::flushState(); + SymfonyRequest::setTrustedProxies([], SymfonyRequest::HEADER_X_FORWARDED_FOR); + + if (is_file($this->resolvedManifestPath)) { + unlink($this->resolvedManifestPath); + } + + $directory = dirname($this->resolvedManifestPath); + + if (is_dir($directory)) { + @rmdir($directory); + } + + parent::tearDown(); + } + + public function test_it_trusts_manual_and_cloudflare_proxies(): void + { + config([ + 'trustedproxy.proxies' => '10.0.0.1/32, REMOTE_ADDR', + 'trustedproxy.cloudflare.fallback_to_remote_addr' => false, + ]); + + $this->writeManifest([ + 'version' => 1, + 'updated_at' => now()->toIso8601String(), + 'ipv4' => ['173.245.48.0/20'], + 'ipv6' => ['2400:cb00::/32'], + 'proxies' => ['173.245.48.0/20', '2400:cb00::/32'], + ]); + + $middleware = new TrustProxies; + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '203.0.113.9']); + + $middleware->handle($request, static fn (Request $request) => response('ok')); + + $this->assertSame([ + '10.0.0.1/32', + '203.0.113.9', + '173.245.48.0/20', + '2400:cb00::/32', + ], SymfonyRequest::getTrustedProxies()); + } + + public function test_it_falls_back_to_the_calling_ip_when_no_manifest_exists(): void + { + $middleware = new TrustProxies; + $request = Request::create('/', 'GET', [], [], [], ['REMOTE_ADDR' => '198.51.100.7']); + + $middleware->handle($request, static fn (Request $request) => response('ok')); + + $this->assertSame(['198.51.100.7'], SymfonyRequest::getTrustedProxies()); + } + + /** + * @param array $manifest + */ + private function writeManifest(array $manifest): void + { + $directory = dirname($this->resolvedManifestPath); + + if (! is_dir($directory)) { + mkdir($directory, 0777, true); + } + + file_put_contents( + $this->resolvedManifestPath, + json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) + ); + } +}