Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,25 @@ Response::ok(['ok' => true], $cacheControl, ContentType::applicationJson())
->withHeader(name: 'X-Trace-Id', value: 'abc-123');
```

Every header that reaches a response, whether through a `Headerable`, `withHeader()` or `withAddedHeader()`, is
checked against RFC 9110. A name that is not a token raises `HeaderNameIsInvalid`, and a value carrying a control
character other than horizontal tab (CR, LF, and NUL included) raises `HeaderValueIsInvalid`. Both extend
`InvalidArgumentException`, as PSR-7 requires, so a header can never smuggle a line break into the response.

The connection-specific headers `Connection`, `Keep-Alive`, `Proxy-Connection`, `Transfer-Encoding` and `Upgrade`, and
`TE` with any value other than `trailers`, raise `HeaderIsConnectionSpecific` on the same paths, with names compared
case-insensitively and the `TE` value trimmed and compared case-insensitively. They describe the connection rather than
the message, HTTP/2 (RFC 9113 section 8.2.2) and HTTP/3 (RFC 9114 section 4.2) treat a message carrying one as
malformed, and the server that writes the response to the wire owns them.

A response starts on protocol version `1.1`, the PSR-7 convention. `withProtocolVersion()` accepts `1.0`, `1.1`, `2`
and `3`, stores `2.0` and `3.0` as `2` and `3`, and raises `ProtocolVersionIsUnsupported` for anything else, since an
emitter writes the version into the status line. To answer in the protocol the request arrived on, mirror its version:

```php
$response = Response::ok(body: ['ok' => true])->withProtocolVersion($request->getProtocolVersion());
```

`withStatus($code, $reasonPhrase)` honors the supplied reason phrase: when a non-empty string is passed,
`getReasonPhrase()` returns it instead of the enum-derived phrase.

Expand Down
40 changes: 40 additions & 0 deletions src/Exceptions/HeaderIsConnectionSpecific.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Http\Exceptions;

use InvalidArgumentException;

/**
* Raised when a response is given a connection-specific header.
*
* <code>Connection</code>, <code>Keep-Alive</code>, <code>Proxy-Connection</code>,
* <code>Transfer-Encoding</code> and <code>Upgrade</code> describe the connection that carries a
* message, not the message, and HTTP/2 (RFC 9113 section 8.2.2) and HTTP/3 (RFC 9114 section 4.2)
* treat a message carrying one as malformed. <code>TE</code> is admitted only with the value
* <code>trailers</code>.
*/
final class HeaderIsConnectionSpecific extends InvalidArgumentException implements HttpException
{
private const string REASON_TEMPLATE = 'Header <%s> is connection-specific and must not be set on a response. '
. 'RFC 9113 section 8.2.2 and RFC 9114 section 4.2 forbid it, and TE admits only the value trailers.';

private function __construct(string $name)
{
$template = HeaderIsConnectionSpecific::REASON_TEMPLATE;

parent::__construct(message: sprintf($template, $name));
}

/**
* Creates a HeaderIsConnectionSpecific signaling that the given header must not be set on a response.
*
* @param string $name The offending header name.
* @return HeaderIsConnectionSpecific The composed exception describing the connection-specific header.
*/
public static function for(string $name): HeaderIsConnectionSpecific
{
return new HeaderIsConnectionSpecific(name: $name);
}
}
39 changes: 39 additions & 0 deletions src/Exceptions/ProtocolVersionIsUnsupported.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Http\Exceptions;

use InvalidArgumentException;

/**
* Raised when a response is given a protocol version outside the HTTP versions it supports.
*
* A response speaks HTTP/1.0, HTTP/1.1, HTTP/2 or HTTP/3, written as <code>1.0</code>,
* <code>1.1</code>, <code>2</code> and <code>3</code>. The forms <code>2.0</code> and
* <code>3.0</code> are accepted and stored as <code>2</code> and <code>3</code>. Anything else is
* rejected, because an emitter writes the version into the status line.
*/
final class ProtocolVersionIsUnsupported extends InvalidArgumentException implements HttpException
{
private const string REASON_TEMPLATE = 'Protocol version <%s> is not supported. Supported versions are 1.0, '
. '1.1, 2 and 3.';

private function __construct(string $version)
{
$template = ProtocolVersionIsUnsupported::REASON_TEMPLATE;

parent::__construct(message: sprintf($template, $version));
}

/**
* Creates a ProtocolVersionIsUnsupported signaling that the given version is not a supported HTTP version.
*
* @param string $version The offending protocol version.
* @return ProtocolVersionIsUnsupported The composed exception describing the unsupported version.
*/
public static function for(string $version): ProtocolVersionIsUnsupported
{
return new ProtocolVersionIsUnsupported(version: $version);
}
}
14 changes: 6 additions & 8 deletions src/Headers.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use Psr\Http\Message\MessageInterface;
use TinyBlocks\Http\Exceptions\HeaderNameIsInvalid;
use TinyBlocks\Http\Exceptions\HeaderValueIsInvalid;
use TinyBlocks\Http\Internal\HeaderName;
use TinyBlocks\Http\Internal\HeaderValue;

/**
* Case-insensitive collection of HTTP headers represented as a name-to-value map.
Expand Down Expand Up @@ -73,17 +75,13 @@ public static function empty(): Headers
*/
public static function fromArray(array $entries): Headers
{
foreach ($entries as $name => $value) {
if (!preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/', $name)) {
throw HeaderNameIsInvalid::for(name: $name);
}
$fields = [];

if (preg_match('/[\x00-\x08\x0A-\x1F\x7F]/', $value) === 1) {
throw HeaderValueIsInvalid::for(value: $value);
}
foreach ($entries as $name => $value) {
$fields[HeaderName::from(value: $name)->toString()] = HeaderValue::from(value: $value)->toString();
}

return new Headers(entries: $entries);
return new Headers(entries: $fields);
}

/**
Expand Down
30 changes: 30 additions & 0 deletions src/Internal/HeaderName.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Http\Internal;

use TinyBlocks\Http\Exceptions\HeaderNameIsInvalid;

final readonly class HeaderName
{
private const string TOKEN_PATTERN = '/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/D';

private function __construct(private string $value)
{
}

public static function from(string $value): HeaderName
{
if (preg_match(HeaderName::TOKEN_PATTERN, $value) !== 1) {
throw HeaderNameIsInvalid::for(name: $value);
}

return new HeaderName(value: $value);
}

public function toString(): string
{
return $this->value;
}
}
30 changes: 30 additions & 0 deletions src/Internal/HeaderValue.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Http\Internal;

use TinyBlocks\Http\Exceptions\HeaderValueIsInvalid;

final readonly class HeaderValue
{
private const string FORBIDDEN_CONTROL_PATTERN = '/[\x00-\x08\x0A-\x1F\x7F]/';

private function __construct(private string $value)
{
}

public static function from(string $value): HeaderValue
{
if (preg_match(HeaderValue::FORBIDDEN_CONTROL_PATTERN, $value) === 1) {
throw HeaderValueIsInvalid::for(value: $value);
}

return new HeaderValue(value: $value);
}

public function toString(): string
{
return $this->value;
}
}
8 changes: 4 additions & 4 deletions src/Internal/Server/Response/InternalResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public static function createWithBody(mixed $body, Code $code, Headerable ...$he
body: StreamFactory::fromBody(body: $body)->write(),
code: $code,
headers: ResponseHeaders::fromWithDefaultContentType(...$headers),
protocolVersion: ProtocolVersion::default(),
protocolVersion: ProtocolVersion::HTTP_1_1,
customReasonPhrase: null
);
}
Expand All @@ -39,7 +39,7 @@ public static function createWithoutBody(Code $code, Headerable ...$headers): Re
body: StreamFactory::fromEmptyBody()->write(),
code: $code,
headers: ResponseHeaders::fromWithoutDefaultContentType(...$headers),
protocolVersion: ProtocolVersion::default(),
protocolVersion: ProtocolVersion::HTTP_1_1,
customReasonPhrase: null
);
}
Expand Down Expand Up @@ -136,12 +136,12 @@ public function withAddedHeader(string $name, mixed $value): MessageInterface

public function getProtocolVersion(): string
{
return $this->protocolVersion->version;
return $this->protocolVersion->value;
}

public function withProtocolVersion(string $version): MessageInterface
{
$protocolVersion = ProtocolVersion::from(version: $version);
$protocolVersion = ProtocolVersion::fromVersion(version: $version);

return new InternalResponse(
body: $this->body,
Expand Down
26 changes: 14 additions & 12 deletions src/Internal/Server/Response/ProtocolVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,23 @@

namespace TinyBlocks\Http\Internal\Server\Response;

final readonly class ProtocolVersion
{
private const string DEFAULT_PROTOCOL_VERSION = '1.1';
use TinyBlocks\Http\Exceptions\ProtocolVersionIsUnsupported;

private function __construct(public string $version)
{
}
enum ProtocolVersion: string
{
case HTTP_1_0 = '1.0';
case HTTP_1_1 = '1.1';
case HTTP_2 = '2';
case HTTP_3 = '3';

public static function from(string $version): ProtocolVersion
public static function fromVersion(string $version): ProtocolVersion
{
return $version === '' ? ProtocolVersion::default() : new ProtocolVersion(version: $version);
}
$canonical = match ($version) {
'2.0' => self::HTTP_2->value,
'3.0' => self::HTTP_3->value,
default => $version
};

public static function default(): ProtocolVersion
{
return new ProtocolVersion(version: ProtocolVersion::DEFAULT_PROTOCOL_VERSION);
return (ProtocolVersion::tryFrom($canonical) ?? throw ProtocolVersionIsUnsupported::for(version: $version));
}
}
57 changes: 57 additions & 0 deletions src/Internal/Server/Response/ResponseField.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace TinyBlocks\Http\Internal\Server\Response;

use TinyBlocks\Http\Exceptions\HeaderIsConnectionSpecific;
use TinyBlocks\Http\Internal\HeaderName;
use TinyBlocks\Http\Internal\HeaderValue;

final readonly class ResponseField
{
private const string TE = 'te';
private const string TRAILERS = 'trailers';
private const array CONNECTION_SPECIFIC = [
'upgrade',
'connection',
'keep-alive',
'proxy-connection',
'transfer-encoding'
];

private function __construct(private string $name, private array $values)
{
}

public static function from(string $name, string|array $value): ResponseField
{
$fieldName = HeaderName::from(value: $name)->toString();
$loweredName = strtolower($fieldName);
$values = array_map(
static fn(string $fieldValue): string => HeaderValue::from(value: $fieldValue)->toString(),
is_array($value) ? $value : [$value]
);

$isOtherThanTrailers = static fn(string $fieldValue): bool => strtolower(trim($fieldValue)) !== self::TRAILERS;

if (
in_array($loweredName, self::CONNECTION_SPECIFIC, true)
|| ($loweredName === self::TE && array_any($values, $isOtherThanTrailers))
) {
throw HeaderIsConnectionSpecific::for(name: $fieldName);
}

return new ResponseField(name: $fieldName, values: $values);
}

public function name(): string
{
return $this->name;
}

public function values(): array
{
return $this->values;
}
}
18 changes: 10 additions & 8 deletions src/Internal/Server/Response/ResponseHeaders.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ private function __construct(private array $headers)
private static function mergeInto(Headerable $header, array $merged): array
{
foreach ($header->toArray() as $name => $value) {
$values = is_array($value) ? $value : [$value];
$merged[$name] = isset($merged[$name]) ? array_merge($merged[$name], $values) : $values;
$field = ResponseField::from(name: $name, value: $value);
$key = $field->name();
$merged[$key] = isset($merged[$key]) ? array_merge($merged[$key], $field->values()) : $field->values();
}

return $merged;
Expand Down Expand Up @@ -84,11 +85,12 @@ public function hasHeader(string $name): bool
public function withAdded(string $name, string|array $value): ResponseHeaders
{
$headers = $this->headers;
$existingKey = $this->findKey(name: $name);
$appended = is_array($value) ? $value : [$value];
$field = ResponseField::from(name: $name, value: $value);
$appended = $field->values();
$existingKey = $this->findKey(name: $field->name());

if (is_null($existingKey)) {
$headers[$name] = $appended;
$headers[$field->name()] = $appended;

return new ResponseHeaders(headers: $headers);
}
Expand Down Expand Up @@ -121,9 +123,9 @@ public function removeByName(string $name): ResponseHeaders
public function withReplaced(string $name, string|array $value): ResponseHeaders
{
$headers = $this->headers;
$existingKey = $this->findKey(name: $name);
$targetKey = ($existingKey ?? $name);
$headers[$targetKey] = is_array($value) ? $value : [$value];
$field = ResponseField::from(name: $name, value: $value);
$targetKey = ($this->findKey(name: $field->name()) ?? $field->name());
$headers[$targetKey] = $field->values();

return new ResponseHeaders(headers: $headers);
}
Expand Down
7 changes: 5 additions & 2 deletions tests/Unit/HeadersTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,9 @@ public static function invalidHeaderNameProvider(): array
'Name with CR' => ["foo\r"],
'Name with CRLF' => ["foo\r\nbar"],
'Name with tab' => ["foo\tbar"],
'Name with null byte' => ["foo\x00bar"]
'Name with null byte' => ["foo\x00bar"],
'Name ending in LF' => ["foo\n"],
'Name ending in CRLF' => ["foo\r\n"]
];
}

Expand All @@ -447,7 +449,8 @@ public static function invalidHeaderValueProvider(): array
'Value with LF' => ["foo\nbar"],
'Value with CR' => ["foo\rbar"],
'CRLF injection' => ["foo\r\nbar: injected"],
'Value with null byte' => ["foo\x00bar"]
'Value with null byte' => ["foo\x00bar"],
'Value ending in LF' => ["foo\n"]
];
}
}
Loading