Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
91.76% |
78 / 85 |
|
50.00% |
5 / 10 |
CRAP | |
0.00% |
0 / 1 |
| HttpFetcher | |
91.76% |
78 / 85 |
|
50.00% |
5 / 10 |
31.54 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| fetch | |
100.00% |
34 / 34 |
|
100.00% |
1 / 1 |
7 | |||
| finaliseResponse | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
4 | |||
| ssrfGuard | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| mimeSniffer | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| transport | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| isRedirect | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| hostOf | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
4.25 | |||
| initialHeaders | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| resolveUrl | |
81.25% |
13 / 16 |
|
0.00% |
0 / 1 |
9.53 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\ResourceLoader; |
| 6 | |
| 7 | use Phpdftk\ResourceLoader\Exception\FetchFailedException; |
| 8 | use Phpdftk\ResourceLoader\Transport\CurlTransport; |
| 9 | use Phpdftk\ResourceLoader\Transport\RawResponse; |
| 10 | use Phpdftk\ResourceLoader\Transport\TransportInterface; |
| 11 | |
| 12 | /** |
| 13 | * Orchestrates one resource fetch end-to-end: |
| 14 | * |
| 15 | * 1. Pre-flight SSRF check on the requested URL |
| 16 | * 2. Hand off to the {@see TransportInterface} for the actual |
| 17 | * send |
| 18 | * 3. If the response is a 3xx redirect, re-check SSRF against the |
| 19 | * Location header, strip Authorization if the host changes |
| 20 | * (RFC 9110 §15.4), and loop |
| 21 | * 4. After {@see FetchOptions::$maxRedirects} hops, fail |
| 22 | * 5. On a 2xx final response, sniff the body's MIME type and |
| 23 | * return the assembled {@see FetchResult} |
| 24 | * |
| 25 | * The orchestration is independent of the actual transport so the |
| 26 | * orchestration logic can be unit-tested against an in-test fake |
| 27 | * transport. The production fetcher defaults to {@see CurlTransport}. |
| 28 | */ |
| 29 | final class HttpFetcher |
| 30 | { |
| 31 | public function __construct( |
| 32 | private readonly SsrfGuard $ssrfGuard = new SsrfGuard(), |
| 33 | private readonly MimeSniffer $mimeSniffer = new MimeSniffer(), |
| 34 | private readonly TransportInterface $transport = new CurlTransport(), |
| 35 | ) {} |
| 36 | |
| 37 | public function fetch(string $originalUrl, FetchOptions $options): FetchResult |
| 38 | { |
| 39 | $currentUrl = $originalUrl; |
| 40 | $previousHost = self::hostOf($currentUrl); |
| 41 | $headers = self::initialHeaders($options); |
| 42 | $hops = 0; |
| 43 | |
| 44 | while (true) { |
| 45 | $this->ssrfGuard->assertSafe($currentUrl); |
| 46 | |
| 47 | $response = $this->transport->send( |
| 48 | $currentUrl, |
| 49 | $headers, |
| 50 | $options->timeoutSeconds, |
| 51 | $options->maxContentLengthBytes, |
| 52 | ); |
| 53 | |
| 54 | if (!self::isRedirect($response->statusCode)) { |
| 55 | return $this->finaliseResponse($response, $originalUrl, $currentUrl, $options); |
| 56 | } |
| 57 | |
| 58 | $hops++; |
| 59 | if ($hops > $options->maxRedirects) { |
| 60 | throw new FetchFailedException(sprintf( |
| 61 | 'Exceeded redirect limit of %d while fetching %s', |
| 62 | $options->maxRedirects, |
| 63 | $originalUrl, |
| 64 | )); |
| 65 | } |
| 66 | |
| 67 | $location = $response->headers['location'] ?? null; |
| 68 | if ($location === null || $location === '') { |
| 69 | throw new FetchFailedException(sprintf( |
| 70 | 'Redirect response %d from %s has no Location header', |
| 71 | $response->statusCode, |
| 72 | $currentUrl, |
| 73 | )); |
| 74 | } |
| 75 | |
| 76 | $nextUrl = self::resolveUrl($currentUrl, $location); |
| 77 | $nextHost = self::hostOf($nextUrl); |
| 78 | if ($nextHost !== $previousHost) { |
| 79 | // Cross-host redirect — strip Authorization per |
| 80 | // RFC 9110 §15.4 so we don't leak credentials to a |
| 81 | // host the caller didn't intend. |
| 82 | unset($headers['Authorization']); |
| 83 | } |
| 84 | $currentUrl = $nextUrl; |
| 85 | $previousHost = $nextHost; |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | private function finaliseResponse( |
| 90 | RawResponse $response, |
| 91 | string $originalUrl, |
| 92 | string $finalUrl, |
| 93 | FetchOptions $options, |
| 94 | ): FetchResult { |
| 95 | if ($response->statusCode < 200 || $response->statusCode >= 300) { |
| 96 | throw new FetchFailedException(sprintf( |
| 97 | 'HTTP %d from %s', |
| 98 | $response->statusCode, |
| 99 | $finalUrl, |
| 100 | )); |
| 101 | } |
| 102 | |
| 103 | // Defence in depth — transport already enforces the cap but |
| 104 | // we re-check here so a malicious transport can't subvert |
| 105 | // it. |
| 106 | if (strlen($response->body) > $options->maxContentLengthBytes) { |
| 107 | throw new FetchFailedException(sprintf( |
| 108 | 'Response body exceeds the %d-byte limit: %s', |
| 109 | $options->maxContentLengthBytes, |
| 110 | $finalUrl, |
| 111 | )); |
| 112 | } |
| 113 | |
| 114 | // Sniff the body for MIME detection. Server Content-Type is |
| 115 | // a hint at best. |
| 116 | $mimeType = $this->mimeSniffer->sniff($response->body); |
| 117 | |
| 118 | return new FetchResult( |
| 119 | bytes: $response->body, |
| 120 | mimeType: $mimeType, |
| 121 | originalUrl: $originalUrl, |
| 122 | finalUrl: $finalUrl, |
| 123 | cacheHit: false, |
| 124 | statusCode: $response->statusCode, |
| 125 | ); |
| 126 | } |
| 127 | |
| 128 | public function ssrfGuard(): SsrfGuard |
| 129 | { |
| 130 | return $this->ssrfGuard; |
| 131 | } |
| 132 | |
| 133 | public function mimeSniffer(): MimeSniffer |
| 134 | { |
| 135 | return $this->mimeSniffer; |
| 136 | } |
| 137 | |
| 138 | public function transport(): TransportInterface |
| 139 | { |
| 140 | return $this->transport; |
| 141 | } |
| 142 | |
| 143 | private static function isRedirect(int $status): bool |
| 144 | { |
| 145 | return in_array($status, [301, 302, 303, 307, 308], true); |
| 146 | } |
| 147 | |
| 148 | private static function hostOf(string $url): string |
| 149 | { |
| 150 | $parsed = parse_url($url); |
| 151 | if (!is_array($parsed) || !isset($parsed['host']) || !is_string($parsed['host'])) { |
| 152 | return ''; |
| 153 | } |
| 154 | return strtolower($parsed['host']); |
| 155 | } |
| 156 | |
| 157 | /** |
| 158 | * @return array<string, string> |
| 159 | */ |
| 160 | private static function initialHeaders(FetchOptions $options): array |
| 161 | { |
| 162 | $headers = $options->headers; |
| 163 | $headers['User-Agent'] = $options->userAgent; |
| 164 | // Accept anything — the MIME sniffer figures out what we |
| 165 | // actually got. Caller can override by passing |
| 166 | // `Accept: image/*` etc in `$options->headers`. |
| 167 | if (!isset($headers['Accept'])) { |
| 168 | $headers['Accept'] = '*/*'; |
| 169 | } |
| 170 | return $headers; |
| 171 | } |
| 172 | |
| 173 | /** |
| 174 | * Resolve a (possibly relative) Location URL against the |
| 175 | * current URL. Doesn't handle every RFC 3986 §5 edge case (a |
| 176 | * full reference resolver lives in 4F.1.x); covers absolute |
| 177 | * URLs, root-relative paths, and relative paths. |
| 178 | */ |
| 179 | private static function resolveUrl(string $base, string $reference): string |
| 180 | { |
| 181 | if (str_contains($reference, '://')) { |
| 182 | return $reference; |
| 183 | } |
| 184 | $parsed = parse_url($base); |
| 185 | if (!is_array($parsed) || !isset($parsed['scheme']) || !isset($parsed['host'])) { |
| 186 | return $reference; |
| 187 | } |
| 188 | $origin = $parsed['scheme'] . '://' . $parsed['host']; |
| 189 | if (isset($parsed['port'])) { |
| 190 | $origin .= ':' . $parsed['port']; |
| 191 | } |
| 192 | if (str_starts_with($reference, '//')) { |
| 193 | return $parsed['scheme'] . ':' . $reference; |
| 194 | } |
| 195 | if (str_starts_with($reference, '/')) { |
| 196 | return $origin . $reference; |
| 197 | } |
| 198 | // Relative path — strip the last segment of the base path |
| 199 | // and join. |
| 200 | $basePath = $parsed['path'] ?? '/'; |
| 201 | $lastSlash = strrpos($basePath, '/'); |
| 202 | $basePathDir = $lastSlash === false ? '/' : substr($basePath, 0, $lastSlash + 1); |
| 203 | return $origin . $basePathDir . $reference; |
| 204 | } |
| 205 | } |