Harden release gate diagnostics fetches

This commit is contained in:
Jeppe B
2026-06-01 23:16:51 +02:00
parent 7bb67b0470
commit dfa0441266
2 changed files with 118 additions and 11 deletions
+87 -11
View File
@@ -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));