Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.45% covered (warning)
85.45%
47 / 55
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ResponseFrameReader
85.45% covered (warning)
85.45%
47 / 55
57.14% covered (warning)
57.14%
4 / 7
26.92
0.00% covered (danger)
0.00%
0 / 1
 __construct
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
4.12
 readFrom
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 detectMessageEnd
80.95% covered (warning)
80.95%
17 / 21
0.00% covered (danger)
0.00%
0 / 1
6.25
 findEncapsulatedHeader
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 encapsulatedBodyOffset
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 findChunkedTerminator
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 longestUnterminatedLine
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * SPDX-License-Identifier: EUPL-1.2
5 *
6 * This file is part of icap-flow.
7 *
8 * Licensed under the EUPL, Version 1.2 only (the "Licence");
9 * you may not use this work except in compliance with the Licence.
10 * You may obtain a copy of the Licence at:
11 *
12 *     https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
13 *
14 * Unless required by applicable law or agreed to in writing, software
15 * distributed under the Licence is distributed on an "AS IS" basis,
16 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 */
18
19declare(strict_types=1);
20
21namespace Ndrstmr\Icap\Transport;
22
23use Closure;
24use Ndrstmr\Icap\Exception\IcapMalformedResponseException;
25
26/**
27 * Frames a single ICAP response off a stream of byte chunks.
28 *
29 * RFC 3507 §5.5 lets servers keep the TCP connection open for the
30 * next request, which means transports cannot rely on socket-EOF to
31 * delimit the response. The framing rules come from the response
32 * itself:
33 *
34 *   1. The ICAP head ends at the first `\r\n\r\n`.
35 *   2. The Encapsulated header in that head says either `null-body`
36 *      (no encapsulated body — message ends at the blank line) or
37 *      it gives an offset to the encapsulated HTTP body, which is
38 *      always HTTP/1.1 chunk-encoded.
39 *   3. The chunk-encoded body ends with `0\r\n\r\n` (optionally with
40 *      a chunk-extension like `0; ieof\r\n\r\n`).
41 *
42 * The reader stops as soon as it has a complete message, leaving any
43 * trailing bytes in the underlying socket for the next request.
44 */
45final class ResponseFrameReader
46{
47    public function __construct(
48        private readonly int $maxResponseSize,
49        private readonly int $maxHeaderLineLength,
50    ) {
51        if ($maxResponseSize < 1 || $maxHeaderLineLength < 1) {
52            throw new \InvalidArgumentException('Reader limits must be >= 1.');
53        }
54    }
55
56    /**
57     * Read a complete ICAP response from the supplied producer.
58     *
59     * The producer is a callable that returns the next chunk of bytes
60     * (or null on EOF). It is invoked only when the reader needs more
61     * bytes to complete the framing.
62     *
63     * @param Closure(): ?string $produce
64     */
65    public function readFrom(Closure $produce): string
66    {
67        $buffer = '';
68        $messageEnd = null;
69
70        while ($messageEnd === null) {
71            $chunk = $produce();
72            if ($chunk === null) {
73                throw new IcapMalformedResponseException(
74                    'Connection closed before the response was complete (got ' . strlen($buffer) . ' bytes).',
75                );
76            }
77            $buffer .= $chunk;
78            if (strlen($buffer) > $this->maxResponseSize) {
79                throw new IcapMalformedResponseException(
80                    sprintf('ICAP response exceeded max size (%d bytes).', $this->maxResponseSize),
81                );
82            }
83            $messageEnd = $this->detectMessageEnd($buffer);
84        }
85
86        // Trim any bytes that came in past the end of this message —
87        // they belong to the next request on the same socket and must
88        // be left in the producer for the caller to handle. With the
89        // current single-shot transports this never happens (we close
90        // after each request), but the contract is honoured for the
91        // upcoming pooling work.
92        return substr($buffer, 0, $messageEnd);
93    }
94
95    /**
96     * Returns the byte offset just past the end of a complete ICAP
97     * message in $buffer, or null if the message isn't complete yet.
98     */
99    private function detectMessageEnd(string $buffer): ?int
100    {
101        $headEnd = strpos($buffer, "\r\n\r\n");
102        if ($headEnd === false) {
103            // Don't even have the ICAP head yet — but enforce the
104            // single-line cap so a malicious server can't push us off
105            // a cliff before we know it.
106            $longestLine = $this->longestUnterminatedLine($buffer);
107            if ($longestLine > $this->maxHeaderLineLength) {
108                throw new IcapMalformedResponseException(
109                    sprintf('ICAP header line exceeded %d bytes before CRLF.', $this->maxHeaderLineLength),
110                );
111            }
112            return null;
113        }
114
115        $headBlock = substr($buffer, 0, $headEnd);
116        $bodyStart = $headEnd + 4;
117
118        $encapsulated = $this->findEncapsulatedHeader($headBlock);
119        if ($encapsulated === null) {
120            // No Encapsulated header — by RFC 3507 §4.4.1 every ICAP
121            // message MUST carry one, but there's no body, so treat
122            // the message as ending right after the head separator.
123            return $bodyStart;
124        }
125
126        $bodyOffset = $this->encapsulatedBodyOffset($encapsulated);
127        if ($bodyOffset === null) {
128            // null-body or header-only — message ends at the blank line.
129            return $bodyStart;
130        }
131
132        // Encapsulated says the chunked body starts at $bodyOffset
133        // (relative to $bodyStart). Look for the chunked terminator
134        // beginning at that absolute offset.
135        $absoluteBodyStart = $bodyStart + $bodyOffset;
136        if (strlen($buffer) <= $absoluteBodyStart) {
137            return null;
138        }
139
140        $terminator = $this->findChunkedTerminator($buffer, $absoluteBodyStart);
141        return $terminator;
142    }
143
144    private function findEncapsulatedHeader(string $headBlock): ?string
145    {
146        // RFC 7230 §3.2.4: obs-fold — a continuation line starts with
147        // at least one SP or HTAB and belongs to the previous header.
148        // Unfold before splitting so multi-line Encapsulated values
149        // (seen with c-icap) are parsed correctly.
150        $unfolded = (string) preg_replace('/\r?\n[ \t]+/', ' ', $headBlock);
151
152        $lines = preg_split('/\r?\n/', $unfolded) ?: [];
153        foreach ($lines as $line) {
154            if (preg_match('/^Encapsulated\s*:\s*(.+)$/i', $line, $m) === 1) {
155                return trim($m[1]);
156            }
157        }
158        return null;
159    }
160
161    /**
162     * Returns the body-section offset (req-body / res-body) declared
163     * in an Encapsulated header value, or null when the header
164     * advertises null-body / has no body section.
165     */
166    private function encapsulatedBodyOffset(string $value): ?int
167    {
168        foreach (array_map('trim', explode(',', $value)) as $entry) {
169            if (preg_match('/^(req-body|res-body)\s*=\s*(\d+)$/i', $entry, $m) === 1) {
170                return (int) $m[2];
171            }
172        }
173        return null;
174    }
175
176    /**
177     * Scan $buffer starting at $offset for the chunked-transfer
178     * terminator `0\r\n\r\n` (or `0; ext\r\n\r\n` per RFC 7230 §4.1).
179     * Returns the absolute byte offset just past the terminator, or
180     * null when not yet present.
181     */
182    private function findChunkedTerminator(string $buffer, int $offset): ?int
183    {
184        // Match either `\n0\r\n\r\n` or `\n0; ext\r\n\r\n` — the
185        // mandatory leading newline is what separates the size line
186        // of the last chunk from the previous chunk's data.
187        if (preg_match('/\r?\n0(?:;[^\r\n]*)?\r\n\r\n/', $buffer, $m, PREG_OFFSET_CAPTURE, $offset) === 1) {
188            return (int) $m[0][1] + strlen((string) $m[0][0]);
189        }
190        // The very first chunk could be the zero-chunk if there's no
191        // payload — match it at the start of the body.
192        if (preg_match('/^0(?:;[^\r\n]*)?\r\n\r\n/', substr($buffer, $offset), $m) === 1) {
193            return $offset + strlen($m[0]);
194        }
195        return null;
196    }
197
198    /**
199     * Length of the longest sequence in $buffer not separated by a
200     * line break. Used to spot the "no CRLF in 16 MB" attack before
201     * the full message has been received.
202     */
203    private function longestUnterminatedLine(string $buffer): int
204    {
205        $lastBreak = max(strrpos($buffer, "\n"), strrpos($buffer, "\r"));
206        return strlen($buffer) - ($lastBreak === false ? 0 : $lastBreak + 1);
207    }
208}