Files
Netbox-Store/store/src/Security/HttpClient.php
T
MrBlake f36d6be511
CI / php-store (push) Waiting to run
CI / python-components (push) Waiting to run
feat: add NetBox plugin store
2026-08-24 20:51:25 +02:00

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;
}
}