271 lines
11 KiB
PHP
271 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace NetBoxStore\Security;
|
|
|
|
use CurlHandle;
|
|
use NetBoxStore\Config;
|
|
use NetBoxStore\Sync\SyncBudget;
|
|
use RuntimeException;
|
|
|
|
final class HttpClient
|
|
{
|
|
private ?SyncBudget $budget = null;
|
|
|
|
public function __construct(
|
|
private readonly Config $config,
|
|
private readonly SsrfGuard $guard,
|
|
) {
|
|
}
|
|
|
|
public function beginBudget(SyncBudget $budget): void
|
|
{
|
|
if ($this->budget !== null) {
|
|
throw new RuntimeException('An outbound sync budget is already active.');
|
|
}
|
|
$this->budget = $budget;
|
|
}
|
|
|
|
public function endBudget(SyncBudget $budget): void
|
|
{
|
|
if ($this->budget === $budget) {
|
|
$this->budget = null;
|
|
}
|
|
}
|
|
|
|
public function consumeRepositories(int $count): void
|
|
{
|
|
$this->budget?->consumeRepositories($count);
|
|
}
|
|
|
|
public function consumeReleases(int $count): void
|
|
{
|
|
$this->budget?->consumeReleases($count);
|
|
}
|
|
|
|
/** Defense-in-depth for adapters which do not account per page. */
|
|
public function ensureRepositoriesCounted(int $total): void
|
|
{
|
|
if ($this->budget !== null && $total > $this->budget->repositoriesCounted()) {
|
|
$this->budget->consumeRepositories($total - $this->budget->repositoriesCounted());
|
|
}
|
|
}
|
|
|
|
/** Defense-in-depth for adapters which do not account per page. */
|
|
public function ensureReleasesCounted(int $total): void
|
|
{
|
|
if ($this->budget !== null && $total > $this->budget->releasesCounted()) {
|
|
$this->budget->consumeReleases($total - $this->budget->releasesCounted());
|
|
}
|
|
}
|
|
|
|
/** @param list<string> $headers @return array<string,mixed>|null */
|
|
public function getJson(string $url, array $headers = [], ?string $sensitiveOrigin = null, bool $allowNotFound = false): ?array
|
|
{
|
|
$response = $this->request($url, $headers, $sensitiveOrigin, $allowNotFound);
|
|
if ($response === null) {
|
|
return null;
|
|
}
|
|
$decoded = json_decode($response['body'], true, 512, JSON_THROW_ON_ERROR);
|
|
if (!is_array($decoded)) {
|
|
throw new RuntimeException('Remote source returned invalid JSON.');
|
|
}
|
|
return $decoded;
|
|
}
|
|
|
|
/** @param list<string> $headers */
|
|
public function getText(string $url, array $headers = [], ?string $sensitiveOrigin = null, bool $allowNotFound = false): ?string
|
|
{
|
|
$response = $this->request($url, $headers, $sensitiveOrigin, $allowNotFound);
|
|
return $response['body'] ?? null;
|
|
}
|
|
|
|
/** @return array{sha256:string,artifactSize:int} */
|
|
public function downloadAndHash(string $url, string $apiOrigin, ?string $authorization, string $expectedSha256 = ''): array
|
|
{
|
|
$current = $url;
|
|
for ($redirects = 0; $redirects <= 5; $redirects++) {
|
|
$headers = ['Accept: application/octet-stream'];
|
|
if ($authorization !== null && $this->origin($current) === $apiOrigin) {
|
|
$headers[] = 'Authorization: ' . $authorization;
|
|
}
|
|
$response = $this->performWithRetries($current, $headers, true);
|
|
if (in_array($response['status'], [301, 302, 303, 307, 308], true)) {
|
|
$location = $response['headers']['location'] ?? '';
|
|
if ($location === '') {
|
|
throw new RuntimeException('Artifact redirect has no Location header.');
|
|
}
|
|
$current = $this->resolveRedirect($current, $location);
|
|
continue;
|
|
}
|
|
if ($response['status'] < 200 || $response['status'] >= 300) {
|
|
throw new RuntimeException('Artifact host returned HTTP ' . $response['status'] . '.');
|
|
}
|
|
if ($response['tooLarge']) {
|
|
throw new RuntimeException('Artifact exceeds configured size limit.');
|
|
}
|
|
if ($expectedSha256 !== '' && preg_match('/^[a-f0-9]{64}$/i', $expectedSha256) && !hash_equals(strtolower($expectedSha256), $response['sha256'])) {
|
|
throw new RuntimeException('Downloaded artifact differs from advertised SHA-256.');
|
|
}
|
|
return ['sha256' => $response['sha256'], 'artifactSize' => $response['size']];
|
|
}
|
|
throw new RuntimeException('Artifact has too many redirects.');
|
|
}
|
|
|
|
/** @param list<string> $headers @return array{status:int,headers:array<string,string>,body:string}|null */
|
|
private function request(string $url, array $headers, ?string $sensitiveOrigin, bool $allowNotFound): ?array
|
|
{
|
|
$current = $url;
|
|
for ($redirects = 0; $redirects <= 5; $redirects++) {
|
|
$filtered = $this->filterSensitiveHeaders($headers, $current, $sensitiveOrigin);
|
|
$response = $this->performWithRetries($current, $filtered, false);
|
|
if ($allowNotFound && $response['status'] === 404) {
|
|
return null;
|
|
}
|
|
if (in_array($response['status'], [301, 302, 303, 307, 308], true)) {
|
|
$location = $response['headers']['location'] ?? '';
|
|
if ($location === '') {
|
|
throw new RuntimeException('Redirect has no Location header.');
|
|
}
|
|
$current = $this->resolveRedirect($current, $location);
|
|
continue;
|
|
}
|
|
if ($response['status'] < 200 || $response['status'] >= 300) {
|
|
throw new RuntimeException('Remote source returned HTTP ' . $response['status'] . '.');
|
|
}
|
|
if ($response['tooLarge']) {
|
|
throw new RuntimeException('Metadata response exceeds configured size limit.');
|
|
}
|
|
return ['status' => $response['status'], 'headers' => $response['headers'], 'body' => $response['body']];
|
|
}
|
|
throw new RuntimeException('Remote source has too many redirects.');
|
|
}
|
|
|
|
/** @param list<string> $headers @return array{status:int,headers:array<string,string>,body:string,sha256:string,size:int,tooLarge:bool} */
|
|
private function performWithRetries(string $url, array $headers, bool $artifact): array
|
|
{
|
|
$last = null;
|
|
for ($attempt = 1; $attempt <= 3; $attempt++) {
|
|
$last = $this->perform($url, $headers, $artifact);
|
|
if ($last['status'] !== 429 && $last['status'] < 500) {
|
|
return $last;
|
|
}
|
|
if ($attempt < 3) {
|
|
usleep($attempt * 350_000);
|
|
}
|
|
}
|
|
return $last;
|
|
}
|
|
|
|
/** @param list<string> $headers @return array{status:int,headers:array<string,string>,body:string,sha256:string,size:int,tooLarge:bool} */
|
|
private function perform(string $url, array $headers, bool $artifact): array
|
|
{
|
|
$budget = $this->budget;
|
|
$budget?->consumeRequest();
|
|
$target = $this->guard->resolve($url, $artifact ? 'Artifact URL' : 'Source URL');
|
|
$curl = curl_init($url);
|
|
if (!$curl instanceof CurlHandle) {
|
|
throw new RuntimeException('Could not initialize cURL.');
|
|
}
|
|
$responseHeaders = [];
|
|
$body = '';
|
|
$size = 0;
|
|
$tooLarge = false;
|
|
$budgetExceeded = false;
|
|
$hash = hash_init('sha256');
|
|
$limit = $artifact ? $this->config->network['maxArtifactBytes'] : $this->config->network['maxMetadataBytes'];
|
|
$timeout = $budget === null
|
|
? $this->config->network['timeout']
|
|
: min($this->config->network['timeout'], $budget->remainingSeconds());
|
|
curl_setopt_array($curl, [
|
|
CURLOPT_FOLLOWLOCATION => false,
|
|
CURLOPT_CONNECTTIMEOUT => min(10, $timeout),
|
|
CURLOPT_TIMEOUT => $timeout,
|
|
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
|
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
CURLOPT_SSL_VERIFYHOST => 2,
|
|
CURLOPT_HTTPHEADER => array_merge(['User-Agent: ' . $this->config->network['userAgent']], $headers),
|
|
CURLOPT_RESOLVE => [$target['resolve']],
|
|
CURLOPT_HEADERFUNCTION => static function (CurlHandle $handle, string $line) use (&$responseHeaders): int {
|
|
$trimmed = trim($line);
|
|
if (str_starts_with($trimmed, 'HTTP/')) {
|
|
$responseHeaders = [];
|
|
} elseif (str_contains($trimmed, ':')) {
|
|
[$name, $value] = explode(':', $trimmed, 2);
|
|
$responseHeaders[strtolower(trim($name))] = trim($value);
|
|
}
|
|
return strlen($line);
|
|
},
|
|
CURLOPT_WRITEFUNCTION => static function (CurlHandle $handle, string $chunk) use (&$body, &$size, &$tooLarge, &$budgetExceeded, $hash, $limit, $artifact, $budget): int {
|
|
$chunkSize = strlen($chunk);
|
|
$size += $chunkSize;
|
|
if ($budget !== null && !$budget->tryConsumeBytes($chunkSize)) {
|
|
$budgetExceeded = true;
|
|
return 0;
|
|
}
|
|
if ($size > $limit) {
|
|
$tooLarge = true;
|
|
return 0;
|
|
}
|
|
if ($artifact) {
|
|
hash_update($hash, $chunk);
|
|
} else {
|
|
$body .= $chunk;
|
|
}
|
|
return strlen($chunk);
|
|
},
|
|
]);
|
|
$ok = curl_exec($curl);
|
|
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
|
$error = curl_error($curl);
|
|
curl_close($curl);
|
|
if ($budgetExceeded) {
|
|
$budget?->assertWithinLimits();
|
|
}
|
|
$budget?->checkpoint();
|
|
if ($ok === false && !$tooLarge) {
|
|
throw new RuntimeException('Outbound request failed: ' . $error);
|
|
}
|
|
return [
|
|
'status' => $status,
|
|
'headers' => $responseHeaders,
|
|
'body' => $body,
|
|
'sha256' => hash_final($hash),
|
|
'size' => $size,
|
|
'tooLarge' => $tooLarge,
|
|
];
|
|
}
|
|
|
|
/** @param list<string> $headers @return list<string> */
|
|
private function filterSensitiveHeaders(array $headers, string $url, ?string $sensitiveOrigin): array
|
|
{
|
|
if ($sensitiveOrigin !== null && $this->origin($url) === $sensitiveOrigin) {
|
|
return $headers;
|
|
}
|
|
return array_values(array_filter($headers, static fn (string $header): bool => !str_starts_with(strtolower($header), 'authorization:')));
|
|
}
|
|
|
|
public function origin(string $url): string
|
|
{
|
|
$parts = parse_url($url);
|
|
$port = (int) ($parts['port'] ?? 443);
|
|
return strtolower((string) ($parts['scheme'] ?? '')) . '://' . strtolower((string) ($parts['host'] ?? '')) . ($port === 443 ? '' : ':' . $port);
|
|
}
|
|
|
|
private function resolveRedirect(string $base, string $location): string
|
|
{
|
|
if (preg_match('#^https://#i', $location)) {
|
|
return $location;
|
|
}
|
|
$parts = parse_url($base);
|
|
$origin = $this->origin($base);
|
|
if (str_starts_with($location, '/')) {
|
|
return $origin . $location;
|
|
}
|
|
$directory = rtrim(dirname((string) ($parts['path'] ?? '/')), '/\\');
|
|
return $origin . ($directory === '' ? '' : $directory) . '/' . $location;
|
|
}
|
|
}
|