request($url, 'head', $maxBytes); } /** * Download up to $maxBytes of the resource through the pinned, * validated path. Returns the raw body string, or false. * * @return string|false */ public static function get(string $url, ?int $maxBytes = null, ?int $expectedLength = null, array $headers = []) { $maxBytes = $maxBytes ?? self::defaultMaxBytes(); $result = (new self)->request($url, 'get', $maxBytes, $expectedLength, $headers); if (! is_array($result)) { return false; } return $result['body'] ?? false; } /** * Shared request loop: validate -> resolve public IPs -> pin -> issue * request with redirects disabled -> re-validate each hop. * * @return array|false For 'head': ['length'=>int,'mime'=>string]. * For 'get': ['body'=>string,'length'=>int,'mime'=>string]. */ protected function request(string $url, string $method, int $maxBytes, ?int $expectedLength = null, array $extraHeaders = []) { $currentUrl = $url; // Host of the original request. Caller-supplied headers (e.g. an // Authorization bearer token) are only sent to this host and are // stripped on any cross-origin redirect hop, mirroring Guzzle's // RedirectMiddleware credential-stripping behaviour. $originHost = parse_url($url, PHP_URL_HOST); $originHost = is_string($originHost) ? strtolower($originHost) : null; for ($redirects = 0; $redirects <= self::MAX_REDIRECTS; $redirects++) { $currentUrl = Helpers::validateUrl($currentUrl); if (! $currentUrl) { return false; } $host = parse_url($currentUrl, PHP_URL_HOST); $scheme = parse_url($currentUrl, PHP_URL_SCHEME); $port = parse_url($currentUrl, PHP_URL_PORT) ?: 443; if (! $host || strtolower((string) $scheme) !== 'https') { return false; } // Only forward caller headers when the current hop is the same host // as the original request; drop them across origins. $headers = ['User-Agent' => self::userAgent()]; if (! empty($extraHeaders) && strtolower((string) $host) === $originHost) { $headers = array_merge($extraHeaders, $headers); } // Resolve the host and reject if ANY resolved address is // non-global. Fail-closed: empty means unresolved or private. $ips = Helpers::resolvePublicIps($host); if (empty($ips)) { return false; } try { $res = Http::withOptions([ 'allow_redirects' => false, 'sink' => null, 'curl' => [ CURLOPT_RESOLVE => [ $this->buildResolveEntry($host, (int) $port, $ips), ], CURLOPT_FRESH_CONNECT => true, CURLOPT_FORBID_REUSE => true, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS, ], 'on_headers' => function (ResponseInterface $response) use ($maxBytes) { $length = $response->getHeaderLine('Content-Length'); if ($length !== '' && ctype_digit($length) && (int) $length > $maxBytes) { throw new \RuntimeException('Remote media exceeds maximum size'); } }, ]) ->withHeaders($headers) ->timeout(self::TIMEOUT) ->connectTimeout(self::CONNECT_TIMEOUT) ->{$method}($currentUrl); } catch (RequestException $e) { return false; } catch (ConnectionException $e) { return false; } catch (\Throwable $e) { return false; } // Manual redirect handling: re-validate + re-resolve the next hop. if (in_array($res->status(), [301, 302, 303, 307, 308], true)) { if ($redirects >= self::MAX_REDIRECTS) { return false; } $location = $res->header('Location'); if (! $location) { return false; } $nextUrl = $this->resolveRedirect($currentUrl, $location); if (! $nextUrl) { return false; } $currentUrl = $nextUrl; continue; } if (! $res->successful()) { return false; } $mime = $this->normalizeMime($res->header('Content-Type')); $declaredLength = $res->header('Content-Length'); $declaredLength = ($declaredLength !== null && ctype_digit((string) $declaredLength)) ? (int) $declaredLength : null; if ($method === 'head') { if ($declaredLength === null || $mime === null) { return false; } if ($declaredLength < 10 || $declaredLength > $maxBytes) { return false; } return ['length' => $declaredLength, 'mime' => $mime]; } // GET: enforce the cap against the actual body we received. $body = $res->body(); $len = strlen($body); if ($len === 0 || $len > $maxBytes) { return false; } if ($expectedLength !== null && $len < $expectedLength) { // Received less than the HEAD promised; treat as truncated. // Still return what we have, capped, but never more than asked. $body = substr($body, 0, $expectedLength); $len = strlen($body); } return [ 'body' => $body, 'length' => $len, 'mime' => $mime ?? ($declaredLength !== null ? $mime : null), ]; } return false; } protected function buildResolveEntry(string $host, int $port, array $ips): string { $addresses = array_map(function ($ip) { return str_contains($ip, ':') ? '['.$ip.']' : $ip; }, $ips); return $host.':'.$port.':'.implode(',', $addresses); } protected function resolveRedirect(string $baseUrl, string $location): ?string { $location = trim($location); if ($location === '' || preg_match('/[\x00-\x20\x7f]/', $location)) { return null; } try { $resolved = (string) BaseUri::from($baseUrl)->resolve($location); return Helpers::validateUrl($resolved) ? $resolved : null; } catch (\Throwable $e) { return null; } } protected function normalizeMime(?string $contentType): ?string { if (! $contentType) { return null; } $mime = strtolower(trim(explode(';', $contentType)[0])); return $mime === '' ? null : $mime; } protected static function defaultMaxBytes(): int { // Cap on the larger of avatar/photo config limits (kB -> bytes), // with a sane fallback. $photo = (int) config_cache('pixelfed.max_photo_size'); $avatar = (int) config('pixelfed.max_avatar_size'); $maxKb = max($photo, $avatar, 1000); return $maxKb * 1000; } protected static function userAgent(): string { return 'PixelFedBot/1.0.0 (Pixelfed/'.config('pixelfed.version').'; +'.config('app.url').')'; } }