From dfa04412667633045eb96a2efdc0fbc668e3be88 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 1 Jun 2026 23:16:51 +0200 Subject: [PATCH] Harden release gate diagnostics fetches --- .../nginx/app/classes/release_manager.php | 98 ++++++++++++++++--- .../ReleaseManager/ReleaseManagerTest.php | 31 ++++++ 2 files changed, 118 insertions(+), 11 deletions(-) diff --git a/services/nginx/app/classes/release_manager.php b/services/nginx/app/classes/release_manager.php index 6e59bb57..64947855 100644 --- a/services/nginx/app/classes/release_manager.php +++ b/services/nginx/app/classes/release_manager.php @@ -35,6 +35,9 @@ class release_manager private const DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS = ['stable', 'production', 'prod']; private const DEFAULT_COOLIFY_APPLICATION_PORT = '80'; private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api'; + private const RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES = ['truckwash.io']; + private const RELEASE_GATE_MAX_PATHS = 10; + private const RELEASE_GATE_MAX_ASSETS = 50; private const RELEASE_API_RUNTIME_ENV_KEYS = [ 'USE_ENV', 'DEBUG', @@ -1031,9 +1034,10 @@ class release_manager 'api_ping_paths' => $this->releaseGateStringArray( $input['api_ping_paths'] ?? $input['api_paths'] - ?? ['/master/api/ping'] + ?? ['/master/api/ping'], + self::RELEASE_GATE_MAX_PATHS ), - 'shell_paths' => $this->releaseGateStringArray($input['shell_paths'] ?? ['/', '/guest/book/wash']), + 'shell_paths' => $this->releaseGateStringArray($input['shell_paths'] ?? ['/', '/guest/book/wash'], self::RELEASE_GATE_MAX_PATHS), ]; } @@ -1133,7 +1137,7 @@ class release_manager return $steps; } - private function releaseGateStringArray(mixed $value): array + private function releaseGateStringArray(mixed $value, int $limit = 50): array { if (is_string($value)) { $value = preg_split('/\s*,\s*/', trim($value)) ?: []; @@ -1149,7 +1153,7 @@ class release_manager $values[] = $item; } } - return $values; + return array_slice($values, 0, max(0, $limit)); } private function normalizeReleaseGateUrl(string $value): string @@ -1389,14 +1393,14 @@ class release_manager } } - $assetUrls = $this->releaseGateUniqueStrings(array_merge( + $assetUrls = array_slice($this->releaseGateUniqueStrings(array_merge( ['release-manifest.json', 'release-entry.json'], [(string)($manifestData['entry'] ?? '')], is_array($manifestData['css'] ?? null) ? $manifestData['css'] : [], is_array($manifestData['index_asset_urls'] ?? null) ? $manifestData['index_asset_urls'] : [], is_array($manifestData['pwa_asset_urls'] ?? null) ? $manifestData['pwa_asset_urls'] : [], is_array($manifestData['asset_urls'] ?? null) ? $manifestData['asset_urls'] : [] - )); + )), 0, self::RELEASE_GATE_MAX_ASSETS); $verifiedAssets = 0; foreach ($assetUrls as $assetUrl) { if ($assetUrl === '/index.html') { @@ -1515,15 +1519,18 @@ class release_manager private function releaseGateFetch(string $url): array { + $this->assertReleaseGateFetchUrlAllowed($url); + $curl = curl_init($url); if ($curl === false) { throw new RuntimeException('Could not initialize release gate request.'); } curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); - curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 5); - curl_setopt($curl, CURLOPT_TIMEOUT, 15); + curl_setopt($curl, CURLOPT_FOLLOWLOCATION, false); + curl_setopt($curl, CURLOPT_MAXREDIRS, 0); + curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 3); + curl_setopt($curl, CURLOPT_TIMEOUT, 8); curl_setopt($curl, CURLOPT_NOSIGNAL, true); curl_setopt($curl, CURLOPT_HTTPHEADER, [ 'Accept: application/json, text/html, */*', @@ -1552,13 +1559,82 @@ class release_manager private function releaseGateJoinUrl(string $baseUrl, string $path): string { - if (preg_match('#^https?://#i', $path) === 1) { - return $path; + $path = trim($path); + $parts = parse_url($path); + if (is_array($parts) && (!empty($parts['scheme']) || !empty($parts['host']))) { + throw new RuntimeException('Release gate paths must be relative to the configured Truckwash release host.'); + } + if (str_starts_with($path, '//')) { + throw new RuntimeException('Release gate paths must not be protocol-relative URLs.'); } return rtrim($baseUrl, '/') . '/' . ltrim($path, '/'); } + + private function assertReleaseGateFetchUrlAllowed(string $url): void + { + $parts = parse_url($url); + $scheme = strtolower((string)($parts['scheme'] ?? '')); + $host = strtolower(rtrim((string)($parts['host'] ?? ''), '.')); + if (!in_array($scheme, ['http', 'https'], true) || $host === '') { + throw new RuntimeException('Release gate checks may only fetch HTTP(S) URLs from Truckwash release hosts.'); + } + if (!$this->releaseGateFetchHostAllowed($host)) { + throw new RuntimeException('Release gate checks may only fetch configured Truckwash release hosts.'); + } + + $addresses = $this->releaseGateResolveHost($host); + if ($addresses === []) { + throw new RuntimeException('Release gate host could not be resolved.'); + } + foreach ($addresses as $address) { + if (!$this->releaseGatePublicIpAllowed($address)) { + throw new RuntimeException('Release gate host resolved to a private, loopback, or reserved address.'); + } + } + } + + private function releaseGateFetchHostAllowed(string $host): bool + { + $host = strtolower(rtrim($host, '.')); + foreach (self::RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES as $allowedSuffix) { + $allowedSuffix = strtolower($allowedSuffix); + if ($host === $allowedSuffix || str_ends_with($host, '.' . $allowedSuffix)) { + return true; + } + } + + return false; + } + + private function releaseGateResolveHost(string $host): array + { + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + return [$host]; + } + + $addresses = gethostbynamel($host) ?: []; + if (function_exists('dns_get_record')) { + foreach (dns_get_record($host, DNS_AAAA) ?: [] as $record) { + if (is_array($record) && !empty($record['ipv6'])) { + $addresses[] = (string)$record['ipv6']; + } + } + } + + return array_values(array_unique(array_filter($addresses, fn(string $address): bool => filter_var($address, FILTER_VALIDATE_IP) !== false))); + } + + private function releaseGatePublicIpAllowed(string $address): bool + { + return filter_var( + $address, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + ) !== false; + } + private function releaseGateCommitMatches(string $actual, string $expected): bool { $expected = strtolower(trim($expected)); diff --git a/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php b/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php index f4caff61..92b695c1 100644 --- a/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php +++ b/services/nginx/app/tests/Unit/ReleaseManager/ReleaseManagerTest.php @@ -1446,3 +1446,34 @@ it('normalizes release assignment subject suggestions without leaking private fi 'title' => 'Invalid', ]))->toBeNull(); }); + +it('restricts release gate fetches to Truckwash release hosts and relative paths', function (): void { + $manager = new release_manager(); + + $joinUrl = new ReflectionMethod(release_manager::class, 'releaseGateJoinUrl'); + $joinUrl->setAccessible(true); + $hostAllowed = new ReflectionMethod(release_manager::class, 'releaseGateFetchHostAllowed'); + $hostAllowed->setAccessible(true); + $publicIpAllowed = new ReflectionMethod(release_manager::class, 'releaseGatePublicIpAllowed'); + $publicIpAllowed->setAccessible(true); + $stringArray = new ReflectionMethod(release_manager::class, 'releaseGateStringArray'); + $stringArray->setAccessible(true); + + expect($joinUrl->invoke($manager, 'https://api-v2.truckwash.io', '/master/api/ping')) + ->toBe('https://api-v2.truckwash.io/master/api/ping') + ->and($hostAllowed->invoke($manager, 'api-v2.truckwash.io'))->toBeTrue() + ->and($hostAllowed->invoke($manager, 'assets.canary.truckwash.io'))->toBeTrue() + ->and($hostAllowed->invoke($manager, 'truckwash.io.evil.test'))->toBeFalse() + ->and($hostAllowed->invoke($manager, '127.0.0.1'))->toBeFalse() + ->and($publicIpAllowed->invoke($manager, '8.8.8.8'))->toBeTrue() + ->and($publicIpAllowed->invoke($manager, '127.0.0.1'))->toBeFalse() + ->and($publicIpAllowed->invoke($manager, '10.0.0.5'))->toBeFalse() + ->and($publicIpAllowed->invoke($manager, '169.254.169.254'))->toBeFalse() + ->and($publicIpAllowed->invoke($manager, '::1'))->toBeFalse() + ->and($stringArray->invoke($manager, ['/a', '/b', '/c'], 2))->toBe(['/a', '/b']); + + expect(fn() => $joinUrl->invoke($manager, 'https://api-v2.truckwash.io', 'http://127.0.0.1/ping')) + ->toThrow(RuntimeException::class, 'relative'); + expect(fn() => $joinUrl->invoke($manager, 'https://api-v2.truckwash.io', '//127.0.0.1/ping')) + ->toThrow(RuntimeException::class, 'relative'); +});