Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.76% covered (success)
91.76%
78 / 85
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
HttpFetcher
91.76% covered (success)
91.76%
78 / 85
50.00% covered (danger)
50.00%
5 / 10
31.54
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetch
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
7
 finaliseResponse
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 ssrfGuard
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 mimeSniffer
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 transport
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isRedirect
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hostOf
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 initialHeaders
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 resolveUrl
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
9.53
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\ResourceLoader;
6
7use Phpdftk\ResourceLoader\Exception\FetchFailedException;
8use Phpdftk\ResourceLoader\Transport\CurlTransport;
9use Phpdftk\ResourceLoader\Transport\RawResponse;
10use 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 */
29final 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}