Harden release gate diagnostics fetches
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user