Skip to content

Commit 3eb28ba

Browse files
committed
Review referenced RFCs for compliance, clarity and currency
- Rename `Regex::UUID` to `UUID_V4` - Add `Regex::UUID` to match all UUIDs and adopt in `AbstractSyncProvider::isValidIdentifier()` - Http: Adopt terminology and behaviour of latest RFCs - Especially: - \[RFC7230] and \[RFC7231] -> \[RFC9112] and \[RFC9110] - \[RFC5987] -> \[RFC8187] - Replace "header line" with "field line" where appropriate - Add and implement `HeadersInterface` methods `hasBadWhitespace()` and `hasObsoleteLineFolding()` - In `Headers::addLine()`, don't throw `InvalidHeaderException` when bad whitespace is received and `$strict = true` - Rearrange `HasHttpRegex` - Fix `AbstractRequest` issue where request target type `origin-form` may be incorrectly detected when the given URI has an empty path
1 parent 75f46b9 commit 3eb28ba

File tree

12 files changed

+169
-76
lines changed

12 files changed

+169
-76
lines changed

src/Toolkit/Contract/Http/HeadersInterface.php

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ interface HeadersInterface extends
2727
public function __construct($items = []);
2828

2929
/**
30-
* Parse and apply a header line or continuation thereof
30+
* Parse and apply a header field line or continuation thereof
3131
*
3232
* To initialise an instance from an HTTP stream or message, call this
33-
* method once per header line after the request or status line, including
33+
* method once per field line after the request or status line, including
3434
* the CRLF sequence at the end of each line. After receiving an empty line
3535
* (`"\r\n"`), {@see hasEmptyLine()} returns `true`, and any headers
3636
* received via {@see addLine()} are applied as trailers.
@@ -43,10 +43,20 @@ public function __construct($items = []);
4343
public function addLine(string $line);
4444

4545
/**
46-
* Check if an empty header line has been received via addLine()
46+
* Check if an empty line has been received via addLine()
4747
*/
4848
public function hasEmptyLine(): bool;
4949

50+
/**
51+
* Check if a line with bad whitespace has been received via addLine()
52+
*/
53+
public function hasBadWhitespace(): bool;
54+
55+
/**
56+
* Check if obsolete line folding has been received via addLine()
57+
*/
58+
public function hasObsoleteLineFolding(): bool;
59+
5060
/**
5161
* Apply a value to a header, preserving any existing values
5262
*
@@ -108,8 +118,8 @@ public function trailers();
108118
public function withoutTrailers();
109119

110120
/**
111-
* Get header names and values in their original order as a list of header
112-
* fields, preserving the original case of each header
121+
* Get header names and values in their original order as a list of field
122+
* lines, preserving the original case of each header
113123
*
114124
* If `$emptyFormat` is given, it is used for headers with an empty value.
115125
*

src/Toolkit/Curler/Curler.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,12 +1156,12 @@ static function ($handle, $infile, int $length) use ($body): string {
11561156
}
11571157
}
11581158
} elseif (self::REQUEST_METHOD_HAS_BODY[$method] ?? false) {
1159-
// [RFC7230], Section 3.3.2: "A user agent SHOULD send a
1160-
// Content-Length in a request message when no Transfer-Encoding is
1161-
// sent and the request method defines a meaning for an enclosed
1162-
// payload body. For example, a Content-Length header field is
1163-
// normally sent in a POST request even when the value is 0
1164-
// (indicating an empty payload body)."
1159+
// [RFC9110], Section 8.6 ("Content-Length"): "A user agent SHOULD
1160+
// send Content-Length in a request when the method defines a
1161+
// meaning for enclosed content and it is not sending
1162+
// Transfer-Encoding. For example, a user agent normally sends
1163+
// Content-Length in a POST request even when the value is 0
1164+
// (indicating empty content)"
11651165
$request = $request->withHeader(self::HEADER_CONTENT_LENGTH, '0');
11661166
}
11671167

src/Toolkit/Http/HasHttpRegex.php

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@
77
*/
88
interface HasHttpRegex
99
{
10-
public const HTTP_TOKEN = '(?:(?i)[-0-9a-z!#$%&\'*+.^_`|~]++)';
10+
public const AUTHORITY_FORM_REGEX = '/^(([-a-z0-9!$&\'()*+,.;=_~]|%[0-9a-f]{2})++|\[[0-9a-f:]++\]):[0-9]++$/iD';
11+
public const HOST_REGEX = '/^(([-a-z0-9!$&\'()*+,.;=_~]|%[0-9a-f]{2})++|\[[0-9a-f:]++\])$/iD';
12+
public const HTTP_FIELD_NAME_REGEX = self::HTTP_TOKEN_REGEX;
13+
public const HTTP_FIELD_VALUE_REGEX = '/^([\x21-\x7e\x80-\xff]++(?:\h++[\x21-\x7e\x80-\xff]++)*+)?$/D';
1114
public const HTTP_TOKEN_REGEX = '/^[-0-9a-z!#$%&\'*+.^_`|~]++$/iD';
15+
public const HTTP_TOKEN = '(?:(?i)[-0-9a-z!#$%&\'*+.^_`|~]++)';
16+
public const SCHEME_REGEX = '/^[a-z][-a-z0-9+.]*$/iD';
1217

13-
public const HTTP_HEADER_FIELD_REGEX = <<<'REGEX'
18+
public const HTTP_FIELD_LINE_REGEX = <<<'REGEX'
1419
/ ^
1520
(?(DEFINE)
1621
(?<token> [-0-9a-z!#$%&'*+.^_`|~]++ )
@@ -25,9 +30,6 @@ interface HasHttpRegex
2530
$ /ixD
2631
REGEX;
2732

28-
public const HTTP_HEADER_FIELD_NAME_REGEX = self::HTTP_TOKEN_REGEX;
29-
public const HTTP_HEADER_FIELD_VALUE_REGEX = '/^([\x21-\x7e\x80-\xff]++(?:\h++[\x21-\x7e\x80-\xff]++)*+)?$/D';
30-
3133
public const URI_REGEX = <<<'REGEX'
3234
` ^
3335
(?(DEFINE)
@@ -64,8 +66,4 @@ interface HasHttpRegex
6466
(?: \# (?<fragment> (?: (?&pchar) | [?/] )* ) )?+
6567
$ `ixD
6668
REGEX;
67-
68-
public const SCHEME_REGEX = '/^[a-z][-a-z0-9+.]*$/iD';
69-
public const HOST_REGEX = '/^(([-a-z0-9!$&\'()*+,.;=_~]|%[0-9a-f]{2})++|\[[0-9a-f:]++\])$/iD';
70-
public const AUTHORITY_FORM_REGEX = '/^(([-a-z0-9!$&\'()*+,.;=_~]|%[0-9a-f]{2})++|\[[0-9a-f:]++\]):[0-9]++$/iD';
7169
}

src/Toolkit/Http/Headers.php

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ class Headers implements HeadersInterface, IteratorAggregate, HasHttpRegex
5858

5959
private bool $IsParser;
6060
private bool $HasEmptyLine = false;
61+
private bool $HasBadWhitespace = false;
62+
private bool $HasObsoleteLineFolding = false;
6163

6264
/**
6365
* Trailing whitespace carried over from the previous line
@@ -101,7 +103,7 @@ public static function from($headersOrPayload): self
101103
"\r\n",
102104
explode("\r\n\r\n", $headersOrPayload, 2)[0] . "\r\n",
103105
));
104-
// Parse header lines
106+
// Parse field lines
105107
$instance = new static();
106108
foreach ($lines as $line) {
107109
$instance = $instance->addLine("$line\r\n");
@@ -114,7 +116,7 @@ public static function from($headersOrPayload): self
114116
/**
115117
* @inheritDoc
116118
*
117-
* @param bool $strict If `true`, strict \[RFC7230] compliance is enforced.
119+
* @param bool $strict If `true`, strict \[RFC9112] compliance is enforced.
118120
*/
119121
public function addLine(string $line, bool $strict = false)
120122
{
@@ -138,43 +140,50 @@ private function doAddLine(string $line, bool $strict)
138140
{
139141
if ($strict && substr($line, -2) !== "\r\n") {
140142
throw new InvalidHeaderException(
141-
'HTTP header line must end with CRLF',
143+
'HTTP field line must end with CRLF',
142144
);
143145
}
144146

145147
if ($line === "\r\n" || (!$strict && trim($line) === '')) {
146148
if ($strict && $this->HasEmptyLine) {
147149
throw new InvalidHeaderException(
148-
'HTTP message cannot have empty header line after body',
150+
'HTTP message cannot have empty field line after body',
149151
);
150152
}
151153
return $this->with('Carry', null)->with('HasEmptyLine', true);
152154
}
153155

154156
if ($strict) {
155157
$line = substr($line, 0, -2);
156-
if (
157-
!Regex::match(self::HTTP_HEADER_FIELD_REGEX, $line, $matches, \PREG_UNMATCHED_AS_NULL)
158-
|| $matches['bad_whitespace'] !== null
159-
) {
158+
if (!Regex::match(self::HTTP_FIELD_LINE_REGEX, $line, $matches, \PREG_UNMATCHED_AS_NULL)) {
160159
throw new InvalidHeaderException(
161-
sprintf('Invalid HTTP header field: %s', $line),
160+
sprintf('Invalid HTTP field line: %s', $line),
162161
);
163162
}
164163

165-
// As per [RFC7230] Section 3.2.4, "replace each received obs-fold
166-
// with one or more SP octets prior to interpreting the field value"
164+
// As per [RFC9112] Section 5.2 ("Obsolete Line Folding"), "replace
165+
// each received obs-fold with one or more SP octets"
167166
$instance = $this->with('Carry', (string) $matches['carry']);
168167
if ($matches['extended'] !== null) {
169168
if ($this->Carry === null) {
170169
throw new InvalidHeaderException(
171-
sprintf('Invalid HTTP header line folding: %s', $line),
170+
sprintf('Invalid HTTP field line folding: %s', $line),
172171
);
173172
}
174173
$line = $this->Carry . ' ' . $matches['extended'];
175174
return $instance->extendPreviousLine($line);
176175
}
177176

177+
// [RFC9112] Section 5.1 ("Field Line Parsing"):
178+
// - "No whitespace is allowed between the field name and colon"
179+
// - "A server MUST reject ... any received request message that
180+
// contains whitespace between a header field name and colon"
181+
// - "A proxy MUST remove any such whitespace from a response
182+
// message before forwarding the message downstream"
183+
if ($matches['bad_whitespace'] !== null) {
184+
$instance = $instance->with('HasBadWhitespace', true);
185+
}
186+
178187
/** @var string */
179188
$name = $matches['name'];
180189
/** @var string */
@@ -190,7 +199,7 @@ private function doAddLine(string $line, bool $strict)
190199
if (strpos(" \t", $line[0]) !== false) {
191200
if ($this->Carry === null) {
192201
throw new InvalidHeaderException(
193-
sprintf('Invalid HTTP header line folding: %s', $line),
202+
sprintf('Invalid HTTP field line folding: %s', $line),
194203
);
195204
}
196205
$line = $this->Carry . ' ' . trim($line);
@@ -200,16 +209,15 @@ private function doAddLine(string $line, bool $strict)
200209
$split = explode(':', $line, 2);
201210
if (count($split) !== 2) {
202211
throw new InvalidHeaderException(
203-
sprintf('Invalid HTTP header field: %s', $line),
212+
sprintf('Invalid HTTP field line: %s', $line),
204213
);
205214
}
206215

207-
// [RFC7230] Section 3.2.4:
208-
// - "No whitespace is allowed between the header field-name and colon."
209-
// - "A proxy MUST remove any such whitespace from a response message
210-
// before forwarding the message downstream."
211216
$name = rtrim($split[0]);
212217
$value = trim($split[1]);
218+
if ($name !== $split[0]) {
219+
$instance = $instance->with('HasBadWhitespace', true);
220+
}
213221
return $instance->addValue($name, $value)->maybeIndexLastHeader();
214222
}
215223

@@ -223,7 +231,9 @@ private function extendPreviousLine(string $line)
223231
$k = array_key_last($headers);
224232
[, $value] = $this->Headers[$k];
225233
$headers[$k][1] = ltrim($value . $line);
226-
return $this->maybeReplaceHeaders($headers, $this->Index);
234+
return $this
235+
->with('HasObsoleteLineFolding', true)
236+
->maybeReplaceHeaders($headers, $this->Index);
227237
}
228238

229239
/**
@@ -249,6 +259,22 @@ public function hasEmptyLine(): bool
249259
return $this->HasEmptyLine;
250260
}
251261

262+
/**
263+
* @inheritDoc
264+
*/
265+
public function hasBadWhitespace(): bool
266+
{
267+
return $this->HasBadWhitespace;
268+
}
269+
270+
/**
271+
* @inheritDoc
272+
*/
273+
public function hasObsoleteLineFolding(): bool
274+
{
275+
return $this->HasObsoleteLineFolding;
276+
}
277+
252278
/**
253279
* @inheritDoc
254280
*/
@@ -698,7 +724,7 @@ public function getHeaderValues(string $name): array
698724
$line = $this->getHeaderLine($name);
699725
return $line === ''
700726
? []
701-
// [RFC7230] Section 7: "a recipient MUST parse and ignore a
727+
// [RFC9110] Section 5.6.1.2: "A recipient MUST parse and ignore a
702728
// reasonable number of empty list elements"
703729
: Str::splitDelimited(',', $line);
704730
}
@@ -831,8 +857,8 @@ private function getIndexHeaders(array $index): array
831857
*/
832858
private function filterIndex(array $index): array
833859
{
834-
// [RFC7230] Section 5.4: "a user agent SHOULD generate Host as the
835-
// first header field following the request-line"
860+
// [RFC9110] Section 7.2: "A user agent that sends Host SHOULD send it
861+
// as the first field in the header section of a request"
836862
return isset($index['host'])
837863
? ['host' => $index['host']] + $index
838864
: $index;
@@ -919,7 +945,7 @@ private function filterItems(iterable $items): iterable
919945

920946
private function filterName(string $name): string
921947
{
922-
if (!Regex::match(self::HTTP_HEADER_FIELD_NAME_REGEX, $name)) {
948+
if (!Regex::match(self::HTTP_FIELD_NAME_REGEX, $name)) {
923949
throw new InvalidArgumentException(
924950
sprintf('Invalid header name: %s', $name),
925951
);
@@ -930,7 +956,7 @@ private function filterName(string $name): string
930956
private function filterValue(string $value): string
931957
{
932958
$value = Regex::replace('/\r\n\h++/', ' ', trim($value, " \t"));
933-
if (!Regex::match(self::HTTP_HEADER_FIELD_VALUE_REGEX, $value)) {
959+
if (!Regex::match(self::HTTP_FIELD_VALUE_REGEX, $value)) {
934960
throw new InvalidArgumentException(
935961
sprintf('Invalid header value: %s', $value),
936962
);

src/Toolkit/Http/HttpUtil.php

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,10 @@ public static function isRequestMethod(string $method): bool
230230
* Check if a string contains only a host and port number, separated by a
231231
* colon
232232
*
233-
* \[RFC7230] Section 5.3.3 (authority-form): "When making a CONNECT request
234-
* to establish a tunnel through one or more proxies, a client MUST send
235-
* only the target URI's authority component (excluding any userinfo and its
236-
* "@" delimiter) as the request-target."
233+
* \[RFC9112] Section 3.2.3 ("authority-form"): "When making a CONNECT
234+
* request to establish a tunnel through one or more proxies, a client MUST
235+
* send only the host and port of the tunnel destination as the
236+
* request-target."
237237
*/
238238
public static function isAuthorityForm(string $target): bool
239239
{
@@ -268,7 +268,7 @@ public static function mediaTypeIs(string $type, string $mimeType): bool
268268
}
269269

270270
/**
271-
* Get an HTTP date as per [RFC7231] Section 7.1.1.1
271+
* Get an HTTP date as per [RFC9110] Section 5.6.7 ("Date/Time Formats")
272272
*/
273273
public static function getDate(?DateTimeInterface $date = null): string
274274
{
@@ -279,7 +279,7 @@ public static function getDate(?DateTimeInterface $date = null): string
279279

280280
/**
281281
* Get a product identifier suitable for User-Agent and Server headers as
282-
* per [RFC7231] Section 5.5.3
282+
* per [RFC9110] Section 10.1.5 ("User-Agent")
283283
*/
284284
public static function getProduct(): string
285285
{
@@ -305,7 +305,7 @@ public static function getMultipartMediaType(MultipartStreamInterface $stream):
305305

306306
/**
307307
* Escape and double-quote a string unless it is a valid HTTP token, as per
308-
* [RFC7230] Section 3.2.6
308+
* [RFC9110] Section 5.6.4 ("Quoted Strings")
309309
*/
310310
public static function maybeQuoteString(string $string): string
311311
{
@@ -315,15 +315,17 @@ public static function maybeQuoteString(string $string): string
315315
}
316316

317317
/**
318-
* Escape and double-quote a string as per [RFC7230] Section 3.2.6
318+
* Escape and double-quote a string as per [RFC9110] Section 5.6.4 ("Quoted
319+
* Strings")
319320
*/
320321
public static function quoteString(string $string): string
321322
{
322323
return '"' . str_replace(['\\', '"'], ['\\\\', '\"'], $string) . '"';
323324
}
324325

325326
/**
326-
* Unescape and remove quotes from a string as per [RFC7230] Section 3.2.6
327+
* Unescape and remove quotes from a string as per [RFC9110] Section 5.6.4
328+
* ("Quoted Strings")
327329
*/
328330
public static function unquoteString(string $string): string
329331
{

src/Toolkit/Http/Message/AbstractRequest.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ public function getRequestTarget(): string
6666
return $this->RequestTarget;
6767
}
6868

69-
// As per [RFC7230] Section 5.3.1 ("origin-form")
69+
// As per [RFC9112] Section 3.2.1 ("origin-form")
7070
$query = $this->Uri->getComponents()['query'] ?? null;
7171
return Str::coalesce($this->Uri->getPath(), '/')
7272
. ($query === null ? '' : '?' . $query);
@@ -130,7 +130,7 @@ private function filterRequestTarget(string $requestTarget): ?string
130130
return null;
131131
}
132132

133-
// As per [RFC7230] Section 5.3 ("Request Target")
133+
// As per [RFC9112] Section 3.2 ("Request Target")
134134
/** @disregard P1006 */
135135
if (
136136
// "asterisk-form"
@@ -141,7 +141,10 @@ private function filterRequestTarget(string $requestTarget): ?string
141141
// "absolute-form"
142142
isset($parts['scheme'])
143143
// "origin-form"
144-
|| !array_diff_key($parts, ['path' => null, 'query' => null])
144+
|| (
145+
isset($parts['path'])
146+
&& !array_diff_key($parts, ['path' => null, 'query' => null])
147+
)
145148
))
146149
) {
147150
return $requestTarget;

src/Toolkit/Http/Message/MultipartStream.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,11 @@ public function __construct(array $parts = [], ?string $boundary = null)
6969
if ($filename !== null && $filename !== $asciiFilename) {
7070
$disposition[] = sprintf(
7171
"filename*=UTF-8''%s",
72-
// Percent-encode as per [RFC5987] Section 3.2 ("Parameter
73-
// Value Character Set and Language Information")
72+
// Percent-encode as per [RFC8187] Section 3.2 ("Parameter
73+
// Value Character Encoding and Language Information")
7474
Regex::replaceCallback(
75+
// HTTP token characters except "*", "'", "%" and those
76+
// left alone by `rawurlencode()`
7577
'/[^!#$&+^`|]++/',
7678
fn($matches) => rawurlencode($matches[0]),
7779
$filename,

0 commit comments

Comments
 (0)