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
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,11 +579,16 @@ Slim. Any framework works, as long as the closure returns the pattern and never
Three exceptions reach the consumer, and every one of them reports a configuration mistake rather than a runtime
condition.

| Exception | Thrown when |
|------------------------|-------------------------------------------------------------------------|
| `MappingNotConfigured` | `build()` runs before a single mapping is registered |
| `HttpStatusOutOfRange` | a rule maps to a status that is not a known HTTP error status |
| `HttpHeaderMalformed` | a rule declares a header whose name or value is not a well-formed field |
| Exception | Thrown when |
|------------------------|-------------------------------------------------------------------------------------------------------|
| `MappingNotConfigured` | `build()` runs before a single mapping is registered |
| `HttpStatusOutOfRange` | a rule maps to a status that is not a known HTTP error status |
| `HttpHeaderMalformed` | a rule declares a header whose name or value is not a well-formed field, or a connection-specific one |

Connection-specific headers are `Connection`, `Keep-Alive`, `Proxy-Connection`, `Transfer-Encoding`, `Upgrade`, and
`TE` with any value other than `trailers`. HTTP/2 and HTTP/3 treat a response carrying one as malformed, and
`tiny-blocks/http` refuses to set one on a response, so `MappedError` refuses it up front instead of letting the error
response fail while it is being built.

A rule closed with `mapsTo` builds its `MappedError` while the table is being declared, so a wrong status or a malformed
header fails at boot rather than on the first request that would have returned it. A rule closed with
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"psr/http-server-handler": "^1.0",
"psr/http-server-middleware": "^1.0",
"psr/log": "^3.0",
"tiny-blocks/http": "^7.2",
"tiny-blocks/http": "^8.0",
"tiny-blocks/http-correlation-id": "^2.1"
},
"require-dev": {
Expand Down
3 changes: 3 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ parameters:
# Tag map the fake exception replays; PHPDoc is prohibited on constructors and inside tests/.
- identifier: missingType.iterableValue
path: tests/Unit/ContextualException.php
# Header maps the data providers feed to the tests; PHPDoc is prohibited inside tests/.
- identifier: missingType.iterableValue
path: tests/Unit/MappedErrorTest.php
# The replayed map cannot be narrowed without the constructor tag; root cause is the same as above.
- identifier: return.type
path: tests/Unit/ContextualException.php
Expand Down
7 changes: 6 additions & 1 deletion src/Exceptions/HttpHeaderMalformed.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
use InvalidArgumentException;

/**
* Thrown when a header configured on a mapped error is not a well-formed HTTP field.
* Thrown when a header configured on a mapped error is not a well-formed HTTP response field.
*
* <p>A connection-specific header (<code>Connection</code>, <code>Keep-Alive</code>,
* <code>Proxy-Connection</code>, <code>Transfer-Encoding</code>, <code>Upgrade</code>, or <code>TE</code> with any
* value other than <code>trailers</code>) is not one: HTTP/2 and HTTP/3 treat a response carrying it as
* malformed.</p>
*/
final class HttpHeaderMalformed extends InvalidArgumentException
{
Expand Down
20 changes: 17 additions & 3 deletions src/Internal/Response/ResponseHeaders.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,17 @@

final readonly class ResponseHeaders
{
private const string NAME = '/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/';
private const string VALUE = '/^[\x20\x09\x21-\x7E\x80-\xFF]*$/';
private const string TE = 'te';
private const string NAME = '/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/D';
private const string VALUE = '/^[\x20\x09\x21-\x7E\x80-\xFF]*$/D';
private const string TRAILERS = 'trailers';
private const array CONNECTION_SPECIFIC = [
'upgrade',
'connection',
'keep-alive',
'proxy-connection',
'transfer-encoding'
];

public static function malformedIn(array $headers): ?string
{
Expand All @@ -24,10 +33,15 @@ public static function malformedIn(array $headers): ?string

private static function isWellFormed(string $name, array $values): bool
{
$loweredName = strtolower($name);

return preg_match(self::NAME, $name) === 1
&& !in_array($loweredName, self::CONNECTION_SPECIFIC, true)
&& array_all(
$values,
static fn(mixed $value): bool => is_scalar($value) && preg_match(self::VALUE, (string)$value) === 1
static fn(mixed $value): bool => is_scalar($value)
&& preg_match(self::VALUE, (string)$value) === 1
&& ($loweredName !== self::TE || strtolower(trim((string)$value)) === self::TRAILERS)
);
}
}
72 changes: 72 additions & 0 deletions tests/Unit/MappedErrorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Test\TinyBlocks\Http\ErrorHandler\Unit;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use TinyBlocks\Http\ErrorHandler\Exceptions\HttpHeaderMalformed;
use TinyBlocks\Http\ErrorHandler\Exceptions\HttpStatusOutOfRange;
Expand Down Expand Up @@ -73,6 +74,18 @@ public function testConstructionWhenStatusIsAtUpperBoundaryThenNoExceptionIsThro
self::assertSame(511, $error->status);
}

#[DataProvider('trailersOnlyTeHeadersProvider')]
public function testConstructionWhenTeHeaderCarriesOnlyTrailersThenHeaderIsPreserved(array $headers): void
{
/** @Given a TE header whose every value is trailers, the only TE value a response may carry */

/** @When constructing a MappedError with that header */
$error = new MappedError(code: 'ERR', status: 422, message: 'An error occurred.', headers: $headers);

/** @Then the header should be preserved as provided */
self::assertSame($headers, $error->headers);
}

public function testConstructionWhenHeaderNameIsMalformedThenHttpHeaderMalformedIsThrown(): void
{
/** @Given a header whose name carries a character no HTTP field name admits */
Expand Down Expand Up @@ -107,6 +120,42 @@ public function testConstructionWhenHeaderValueIsMalformedThenHttpHeaderMalforme
new MappedError(code: 'ERR', status: 429, message: 'An error occurred.', headers: $headers);
}

public function testConstructionWhenHeaderNameEndsInLineFeedThenHttpHeaderMalformedIsThrown(): void
{
/** @Given a header whose name ends in a line feed, which a dollar anchor alone would let through */
$headers = ["Retry-After\n" => '60'];

/** @Then a malformed header exception should be thrown */
$this->expectException(HttpHeaderMalformed::class);

/** @When constructing a MappedError with that header */
new MappedError(code: 'ERR', status: 429, message: 'An error occurred.', headers: $headers);
}

public function testConstructionWhenHeaderValueEndsInLineFeedThenHttpHeaderMalformedIsThrown(): void
{
/** @Given a well-formed name carrying a value that ends in a line feed */
$headers = ['Retry-After' => "60\n"];

/** @Then a malformed header exception should be thrown */
$this->expectException(HttpHeaderMalformed::class);

/** @When constructing a MappedError with that header */
new MappedError(code: 'ERR', status: 429, message: 'An error occurred.', headers: $headers);
}

#[DataProvider('connectionSpecificHeadersProvider')]
public function testConstructionWhenHeaderIsConnectionSpecificThenHttpHeaderMalformedIsThrown(array $headers): void
{
/** @Given a connection-specific header, which HTTP/2 and HTTP/3 treat as a malformed response */

/** @Then a malformed header exception should be thrown */
$this->expectException(HttpHeaderMalformed::class);

/** @When constructing a MappedError with that header */
new MappedError(code: 'ERR', status: 503, message: 'An error occurred.', headers: $headers);
}

public function testConstructionWhenStatusExceedsUpperBoundaryThenHttpStatusOutOfRangeIsThrown(): void
{
/** @Then an exception indicating the status is out of range should be thrown */
Expand Down Expand Up @@ -150,4 +199,27 @@ public function testConstructionWhenNumericHeaderNameCarriesMalformedValueThenHt
/** @When constructing a MappedError with that header */
new MappedError(code: 'ERR', status: 422, message: 'An error occurred.', headers: $headers);
}

public static function trailersOnlyTeHeadersProvider(): array
{
return [
'TE with trailers' => ['headers' => ['TE' => 'trailers']],
'TE with padded capital trailers' => ['headers' => ['TE' => ' Trailers ']],
'TE with trailers in every value' => ['headers' => ['te' => ['trailers', 'TRAILERS']]]
];
}

public static function connectionSpecificHeadersProvider(): array
{
return [
'Connection' => ['headers' => ['Connection' => 'close']],
'Keep-Alive' => ['headers' => ['Keep-Alive' => 'timeout=5']],
'Proxy-Connection in upper case' => ['headers' => ['PROXY-CONNECTION' => 'keep-alive']],
'Transfer-Encoding' => ['headers' => ['Transfer-Encoding' => 'chunked']],
'Upgrade in lower case' => ['headers' => ['upgrade' => 'h2c']],
'TE with a coding' => ['headers' => ['TE' => 'gzip']],
'TE with a numeric value' => ['headers' => ['TE' => 1]],
'TE with trailers and a coding' => ['headers' => ['te' => ['trailers', 'gzip']]]
];
}
}