From 97dac1c022dcfff7629bf0886a6c6e683450ed55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 22:38:33 +0200 Subject: [PATCH 1/9] Improve Mercure JWT signing key validation and add tests --- docs/configuration.md | 7 +++-- docs/mercure.md | 11 +++---- src/Broker/Mercure/MercureJwtFactory.php | 9 ++++-- .../Mercure/MercureBrokerAdapterTest.php | 4 +-- tests/Broker/Mercure/MercureConfigTest.php | 10 +++---- .../Broker/Mercure/MercureJwtFactoryTest.php | 29 +++++++++++++++++-- tests/Broker/Mercure/MercurePublisherTest.php | 4 +-- .../MercureSubscriptionFactoryTest.php | 4 +-- tests/Config/ServicesTest.php | 4 +-- tests/HTTP/MercureAuthorizationTest.php | 4 +-- tests/HTTP/SseControllerTest.php | 4 +-- tests/HTTP/SubscriptionEndpointTest.php | 4 +-- 12 files changed, 62 insertions(+), 32 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 066c77c..b2a38fd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -298,9 +298,10 @@ For local plain HTTP only, also set: sse.mercure.cookie.secure = false ``` -Keep Hub signing keys out of source control. The package validates Mercure -configuration when the Mercure adapter factory builds the broker. The full key -reference and deployment guidance are in [Mercure Hub](mercure.md). +Keep Hub signing keys out of source control. HMAC JWT signing keys must be at +least 32 bytes. The package validates Mercure configuration when the Mercure +adapter factory builds the broker. The full key reference and deployment +guidance are in [Mercure Hub](mercure.md). ## Redis connection diff --git a/docs/mercure.md b/docs/mercure.md index e7a59ac..4f2962a 100644 --- a/docs/mercure.md +++ b/docs/mercure.md @@ -323,11 +323,12 @@ pod. Do not expose the entire Caddy admin API publicly. | `maxPayloadBytes` | `1048576` | Maximum serialized event size. | | `cookie` | secure Mercure defaults | Subscriber cookie attributes. | -The built-in JWT issuer supports HMAC algorithms. `publisherJwt` can contain a -token issued by an external system, but dynamic subscriber authorization still -requires the configured HMAC subscriber key. Applications using an external -OAuth/JWKS issuer should replace the authorization controller rather than -exposing signing keys to the browser. +The built-in JWT issuer supports HMAC algorithms and requires HMAC signing keys +to be at least 32 bytes. `publisherJwt` can contain a token issued by an +external system, but dynamic subscriber authorization still requires the +configured HMAC subscriber key. Applications using an external OAuth/JWKS issuer +should replace the authorization controller rather than exposing signing keys to +the browser. The adapter currently maps exact logical channels. Redis glob patterns are not accepted by the Mercure transport; pattern selectors remain a Redis adapter diff --git a/src/Broker/Mercure/MercureJwtFactory.php b/src/Broker/Mercure/MercureJwtFactory.php index 146a3ef..d4ce87e 100644 --- a/src/Broker/Mercure/MercureJwtFactory.php +++ b/src/Broker/Mercure/MercureJwtFactory.php @@ -9,7 +9,8 @@ final class MercureJwtFactory { - private const HASH_ALGORITHMS = [ + private const MINIMUM_KEY_BYTES = 32; + private const HASH_ALGORITHMS = [ 'HS256' => 'sha256', 'HS384' => 'sha384', 'HS512' => 'sha512', @@ -25,8 +26,10 @@ public function create( int $ttl = 300, ?int $issuedAt = null, ): string { - if ($key === '') { - throw new MercureConfigurationException('The Mercure JWT signing key must not be empty.'); + if (strlen($key) < self::MINIMUM_KEY_BYTES) { + throw new MercureConfigurationException( + sprintf('The Mercure JWT signing key must be at least %d bytes.', self::MINIMUM_KEY_BYTES), + ); } $hash = self::HASH_ALGORITHMS[$algorithm] ?? null; diff --git a/tests/Broker/Mercure/MercureBrokerAdapterTest.php b/tests/Broker/Mercure/MercureBrokerAdapterTest.php index acf88d9..9783b1e 100644 --- a/tests/Broker/Mercure/MercureBrokerAdapterTest.php +++ b/tests/Broker/Mercure/MercureBrokerAdapterTest.php @@ -95,8 +95,8 @@ private function config(): Sse $config->mercure = [ 'hubUrl' => 'http://mercure/.well-known/mercure', 'publicHubUrl' => 'https://example.test/.well-known/mercure', - 'publisherKey' => 'publisher-test-secret', - 'subscriberKey' => 'subscriber-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', ]; return $config; diff --git a/tests/Broker/Mercure/MercureConfigTest.php b/tests/Broker/Mercure/MercureConfigTest.php index cc1e530..63b8d24 100644 --- a/tests/Broker/Mercure/MercureConfigTest.php +++ b/tests/Broker/Mercure/MercureConfigTest.php @@ -94,7 +94,7 @@ public static function provideRejectsInvalidMercureConfig(): iterable yield 'missing publisher credentials' => [ static function (Sse $config): void { $config->mercure = [ - 'subscriberKey' => 'subscriber-test-secret', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', ]; }, ]; @@ -102,7 +102,7 @@ static function (Sse $config): void { yield 'missing subscriber key' => [ static function (Sse $config): void { $config->mercure = [ - 'publisherKey' => 'publisher-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', ]; }, ]; @@ -112,7 +112,7 @@ static function (Sse $config): void { $config->mercure = [ 'private' => true, 'authorizeSubscribers' => false, - 'publisherKey' => 'publisher-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', ]; }, ]; @@ -120,8 +120,8 @@ static function (Sse $config): void { yield 'publisher selectors must be a list' => [ static function (Sse $config): void { $config->mercure = [ - 'publisherKey' => 'publisher-test-secret', - 'subscriberKey' => 'subscriber-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', 'publisherTopicSelectors' => '*', ]; }, diff --git a/tests/Broker/Mercure/MercureJwtFactoryTest.php b/tests/Broker/Mercure/MercureJwtFactoryTest.php index f98dc08..3bdc310 100644 --- a/tests/Broker/Mercure/MercureJwtFactoryTest.php +++ b/tests/Broker/Mercure/MercureJwtFactoryTest.php @@ -4,6 +4,7 @@ namespace Tests\Broker\Mercure; +use Maniaba\CodeIgniterSse\Broker\Mercure\Exception\MercureConfigurationException; use Maniaba\CodeIgniterSse\Broker\Mercure\MercureJwtFactory; use PHPUnit\Framework\TestCase; @@ -12,11 +13,13 @@ */ final class MercureJwtFactoryTest extends TestCase { + private const SIGNING_KEY = 'test-secret-that-is-long-enough!'; + public function testCreatesSignedMercureClaimsWithExpiration(): void { $token = (new MercureJwtFactory())->create( ['subscribe' => ['urn:sse:users.42']], - 'test-secret-that-is-long-enough', + self::SIGNING_KEY, 'HS256', 600, 1_700_000_000, @@ -27,7 +30,7 @@ public function testCreatesSignedMercureClaimsWithExpiration(): void $expectedSignature = hash_hmac( 'sha256', $encodedHeader . '.' . $encodedClaims, - 'test-secret-that-is-long-enough', + self::SIGNING_KEY, true, ); @@ -44,6 +47,28 @@ public function testCreatesSignedMercureClaimsWithExpiration(): void ); } + public function testRejectsSigningKeysShorterThan32Bytes(): void + { + $this->expectException(MercureConfigurationException::class); + $this->expectExceptionMessage('The Mercure JWT signing key must be at least 32 bytes.'); + + (new MercureJwtFactory())->create( + ['subscribe' => ['urn:sse:users.42']], + str_repeat('a', 31), + ); + } + + public function testAllowsSigningKeyWith32Bytes(): void + { + $token = (new MercureJwtFactory())->create( + ['subscribe' => ['urn:sse:users.42']], + str_repeat('a', 32), + issuedAt: 1_700_000_000, + ); + + $this->assertCount(3, explode('.', $token)); + } + /** * @return array */ diff --git a/tests/Broker/Mercure/MercurePublisherTest.php b/tests/Broker/Mercure/MercurePublisherTest.php index cc39971..cba57fa 100644 --- a/tests/Broker/Mercure/MercurePublisherTest.php +++ b/tests/Broker/Mercure/MercurePublisherTest.php @@ -163,8 +163,8 @@ private function config(): Sse 'hubUrl' => 'http://mercure/.well-known/mercure', 'publicHubUrl' => 'https://example.test/.well-known/mercure', 'topicPrefix' => 'urn:example:sse:', - 'publisherKey' => 'publisher-test-secret', - 'subscriberKey' => 'subscriber-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', 'cookie' => [ 'secure' => true, ], diff --git a/tests/Broker/Mercure/MercureSubscriptionFactoryTest.php b/tests/Broker/Mercure/MercureSubscriptionFactoryTest.php index 956454b..5d033e0 100644 --- a/tests/Broker/Mercure/MercureSubscriptionFactoryTest.php +++ b/tests/Broker/Mercure/MercureSubscriptionFactoryTest.php @@ -20,8 +20,8 @@ public function testSubscriberTokenIsRestrictedToRequestedTopics(): void $config->mercure = [ 'publicHubUrl' => 'https://example.test/.well-known/mercure', 'topicPrefix' => 'urn:example:sse:', - 'publisherKey' => 'publisher-test-secret', - 'subscriberKey' => 'subscriber-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', 'subscriberTokenTtl' => 600, ]; diff --git a/tests/Config/ServicesTest.php b/tests/Config/ServicesTest.php index 730f266..fe9cad8 100644 --- a/tests/Config/ServicesTest.php +++ b/tests/Config/ServicesTest.php @@ -129,8 +129,8 @@ public function testMercurePublisherUsesTheConfiguredBrokerAdapter(): void $config = new Sse(); $config->broker = 'mercure'; $config->mercure = [ - 'publisherKey' => 'publisher-test-secret', - 'subscriberKey' => 'subscriber-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', ]; $publisher = Services::ssePublisher($config, false); diff --git a/tests/HTTP/MercureAuthorizationTest.php b/tests/HTTP/MercureAuthorizationTest.php index 5897b58..b545b44 100644 --- a/tests/HTTP/MercureAuthorizationTest.php +++ b/tests/HTTP/MercureAuthorizationTest.php @@ -361,8 +361,8 @@ private function mercureConfig(): Sse 'hubUrl' => 'http://mercure/.well-known/mercure', 'publicHubUrl' => 'https://example.test/.well-known/mercure', 'topicPrefix' => 'urn:example:sse:', - 'publisherKey' => 'publisher-test-secret', - 'subscriberKey' => 'subscriber-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', 'cookie' => [ 'name' => 'mercureAuthorization', 'secure' => true, diff --git a/tests/HTTP/SseControllerTest.php b/tests/HTTP/SseControllerTest.php index 5dcd0ad..c44acae 100644 --- a/tests/HTTP/SseControllerTest.php +++ b/tests/HTTP/SseControllerTest.php @@ -132,8 +132,8 @@ public function testMercureRouteAuthorizesChannelsWithoutOpeningAPhpStream(): vo 'hubUrl' => 'http://mercure/.well-known/mercure', 'publicHubUrl' => 'https://example.test/.well-known/mercure', 'topicPrefix' => 'urn:example:sse:', - 'publisherKey' => 'publisher-test-secret', - 'subscriberKey' => 'subscriber-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', 'cookie' => [ 'name' => 'mercureAuthorization', 'secure' => true, diff --git a/tests/HTTP/SubscriptionEndpointTest.php b/tests/HTTP/SubscriptionEndpointTest.php index f6e4560..4926980 100644 --- a/tests/HTTP/SubscriptionEndpointTest.php +++ b/tests/HTTP/SubscriptionEndpointTest.php @@ -305,8 +305,8 @@ private function mercureConfig(array $mercure = []): Sse 'hubUrl' => 'http://mercure/.well-known/mercure', 'publicHubUrl' => 'https://example.test/.well-known/mercure', 'topicPrefix' => 'urn:example:sse:', - 'publisherKey' => 'publisher-test-secret', - 'subscriberKey' => 'subscriber-test-secret', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', 'cookie' => [ 'name' => 'mercureAuthorization', 'secure' => true, From b3605fb738b94cfe44688b81a0abacf007e8913f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 22:40:56 +0200 Subject: [PATCH 2/9] Refactor JWT creation to include issuer, audience, subject, and unique ID claims, and add new tests --- docs/mercure.md | 11 ++--- src/Broker/Mercure/MercureJwtFactory.php | 38 ++++++++++++++++++ .../Broker/Mercure/MercureJwtFactoryTest.php | 40 +++++++++++++++++++ .../MercureSubscriptionFactoryTest.php | 4 ++ tests/HTTP/MercureAuthorizationTest.php | 4 ++ 5 files changed, 92 insertions(+), 5 deletions(-) diff --git a/docs/mercure.md b/docs/mercure.md index 4f2962a..b51c374 100644 --- a/docs/mercure.md +++ b/docs/mercure.md @@ -324,11 +324,12 @@ pod. Do not expose the entire Caddy admin API publicly. | `cookie` | secure Mercure defaults | Subscriber cookie attributes. | The built-in JWT issuer supports HMAC algorithms and requires HMAC signing keys -to be at least 32 bytes. `publisherJwt` can contain a token issued by an -external system, but dynamic subscriber authorization still requires the -configured HMAC subscriber key. Applications using an external OAuth/JWKS issuer -should replace the authorization controller rather than exposing signing keys to -the browser. +to be at least 32 bytes. Generated JWTs include `iss`, `aud`, `sub`, `jti`, +`iat`, and `exp` claims plus the Mercure scope. `publisherJwt` can contain a +token issued by an external system, but dynamic subscriber authorization still +requires the configured HMAC subscriber key. Applications using an external +OAuth/JWKS issuer should replace the authorization controller rather than +exposing signing keys to the browser. The adapter currently maps exact logical channels. Redis glob patterns are not accepted by the Mercure transport; pattern selectors remain a Redis adapter diff --git a/src/Broker/Mercure/MercureJwtFactory.php b/src/Broker/Mercure/MercureJwtFactory.php index d4ce87e..dab4db1 100644 --- a/src/Broker/Mercure/MercureJwtFactory.php +++ b/src/Broker/Mercure/MercureJwtFactory.php @@ -6,6 +6,7 @@ use JsonException; use Maniaba\CodeIgniterSse\Broker\Mercure\Exception\MercureConfigurationException; +use Random\RandomException; final class MercureJwtFactory { @@ -15,6 +16,10 @@ final class MercureJwtFactory 'HS384' => 'sha384', 'HS512' => 'sha512', ]; + private const ISSUER = 'maniaba/codeigniter4-sse'; + private const AUDIENCE = 'mercure'; + private const PUBLISHER_SUBJECT = 'mercure-publisher'; + private const SUBSCRIBER_SUBJECT = 'mercure-subscriber'; /** * @param array{publish?: list, subscribe?: list} $mercure @@ -47,6 +52,10 @@ public function create( $issuedAt ??= time(); $header = $this->encode(['alg' => $algorithm, 'typ' => 'JWT']); $claims = $this->encode([ + 'iss' => self::ISSUER, + 'aud' => self::AUDIENCE, + 'sub' => self::subject($mercure), + 'jti' => self::jwtId(), 'iat' => $issuedAt, 'exp' => $issuedAt + $ttl, 'mercure' => $mercure, @@ -75,6 +84,35 @@ private function encode(array $value): string } } + /** + * @param array{publish?: list, subscribe?: list} $mercure + */ + private static function subject(array $mercure): string + { + if (isset($mercure['publish']) && ! isset($mercure['subscribe'])) { + return self::PUBLISHER_SUBJECT; + } + + if (isset($mercure['subscribe']) && ! isset($mercure['publish'])) { + return self::SUBSCRIBER_SUBJECT; + } + + return 'mercure-token'; + } + + private static function jwtId(): string + { + try { + return bin2hex(random_bytes(16)); + } catch (RandomException $exception) { + throw new MercureConfigurationException( + 'The Mercure JWT ID could not be generated.', + 0, + $exception, + ); + } + } + private static function base64UrlEncode(string $value): string { return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); diff --git a/tests/Broker/Mercure/MercureJwtFactoryTest.php b/tests/Broker/Mercure/MercureJwtFactoryTest.php index 3bdc310..00a6bd6 100644 --- a/tests/Broker/Mercure/MercureJwtFactoryTest.php +++ b/tests/Broker/Mercure/MercureJwtFactoryTest.php @@ -35,6 +35,10 @@ public function testCreatesSignedMercureClaimsWithExpiration(): void ); $this->assertSame(['alg' => 'HS256', 'typ' => 'JWT'], $header); + $this->assertSame('maniaba/codeigniter4-sse', $claims['iss']); + $this->assertSame('mercure', $claims['aud']); + $this->assertSame('mercure-subscriber', $claims['sub']); + $this->assertMatchesRegularExpression('/\A[0-9a-f]{32}\z/', $claims['jti']); $this->assertSame(1_700_000_000, $claims['iat']); $this->assertSame(1_700_000_600, $claims['exp']); $this->assertSame( @@ -47,6 +51,42 @@ public function testCreatesSignedMercureClaimsWithExpiration(): void ); } + public function testCreatesPublisherSubjectForPublisherTokens(): void + { + $token = (new MercureJwtFactory())->create( + ['publish' => ['*']], + self::SIGNING_KEY, + issuedAt: 1_700_000_000, + ); + [, $encodedClaims] = explode('.', $token); + $claims = $this->decode($encodedClaims); + + $this->assertSame('mercure-publisher', $claims['sub']); + } + + public function testCreatesUniqueJwtIds(): void + { + $factory = new MercureJwtFactory(); + $first = $factory->create( + ['subscribe' => ['urn:sse:users.42']], + self::SIGNING_KEY, + issuedAt: 1_700_000_000, + ); + $second = $factory->create( + ['subscribe' => ['urn:sse:users.42']], + self::SIGNING_KEY, + issuedAt: 1_700_000_000, + ); + + [, $firstClaims] = explode('.', $first); + [, $secondClaims] = explode('.', $second); + + $this->assertNotSame( + $this->decode($firstClaims)['jti'], + $this->decode($secondClaims)['jti'], + ); + } + public function testRejectsSigningKeysShorterThan32Bytes(): void { $this->expectException(MercureConfigurationException::class); diff --git a/tests/Broker/Mercure/MercureSubscriptionFactoryTest.php b/tests/Broker/Mercure/MercureSubscriptionFactoryTest.php index 5d033e0..f873ebe 100644 --- a/tests/Broker/Mercure/MercureSubscriptionFactoryTest.php +++ b/tests/Broker/Mercure/MercureSubscriptionFactoryTest.php @@ -42,6 +42,10 @@ public function testSubscriberTokenIsRestrictedToRequestedTopics(): void $this->assertCount(3, $parts); $claims = $this->decode($parts[1]); + $this->assertSame('maniaba/codeigniter4-sse', $claims['iss']); + $this->assertSame('mercure', $claims['aud']); + $this->assertSame('mercure-subscriber', $claims['sub']); + $this->assertMatchesRegularExpression('/\A[0-9a-f]{32}\z/', $claims['jti']); $this->assertSame( ['subscribe' => $subscription->topics], $claims['mercure'], diff --git a/tests/HTTP/MercureAuthorizationTest.php b/tests/HTTP/MercureAuthorizationTest.php index b545b44..fa84b93 100644 --- a/tests/HTTP/MercureAuthorizationTest.php +++ b/tests/HTTP/MercureAuthorizationTest.php @@ -298,6 +298,10 @@ private function assertMercureAuthorizationResponse( $this->assertCount(3, $tokenParts); $claims = $this->decodeJwtPayload($tokenParts[1]); + $this->assertSame('maniaba/codeigniter4-sse', $claims['iss']); + $this->assertSame('mercure', $claims['aud']); + $this->assertSame('mercure-subscriber', $claims['sub']); + $this->assertMatchesRegularExpression('/\A[0-9a-f]{32}\z/', $claims['jti']); $this->assertSame( ['subscribe' => $topics], $claims['mercure'], From 060762d937aa0fb510c0cf76d13319f4f45b2608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 22:44:31 +0200 Subject: [PATCH 3/9] Ensure `topicPrefix` is a literal absolute IRI prefix without wildcards or URI-template characters --- docs/configuration.md | 5 +++++ docs/mercure.md | 2 +- src/Broker/Mercure/MercureConfig.php | 25 +++++++++++++--------- tests/Broker/Mercure/MercureConfigTest.php | 12 +++++++++++ 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index b2a38fd..935a4fe 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -285,6 +285,11 @@ public array $mercure = [ ]; ``` +Use a literal absolute IRI prefix such as `urn:herceg:sse:`. Prefixes +containing wildcard or URI-template characters, such as +`https://example.com/{topic}`, are rejected because they can broaden Mercure +topic selectors. + Nested `.env` overrides are supported: ```dotenv diff --git a/docs/mercure.md b/docs/mercure.md index b51c374..d6a98a2 100644 --- a/docs/mercure.md +++ b/docs/mercure.md @@ -306,7 +306,7 @@ pod. Do not expose the entire Caddy admin API publicly. |---|---|---| | `hubUrl` | `http://127.0.0.1:3000/.well-known/mercure` | Server-side publish URL. | | `publicHubUrl` | same local URL | Browser-facing subscription URL. | -| `topicPrefix` | `urn:codeigniter4-sse:` | Absolute IRI prefix added to logical channels. | +| `topicPrefix` | `urn:codeigniter4-sse:` | Literal absolute IRI prefix added to logical channels. Wildcard and URI-template characters are rejected. | | `private` | `true` | Mark published updates as private. | | `authorizeSubscribers` | `true` | Issue a topic-restricted subscriber JWT cookie. | | `publisherJwt` | `null` | Optional pre-generated publisher JWT. | diff --git a/src/Broker/Mercure/MercureConfig.php b/src/Broker/Mercure/MercureConfig.php index c8e33f7..0ebbf52 100644 --- a/src/Broker/Mercure/MercureConfig.php +++ b/src/Broker/Mercure/MercureConfig.php @@ -41,16 +41,7 @@ public function __construct( ) { $this->assertUrl($this->hubUrl, 'server-side Hub'); $this->assertUrl($this->publicHubUrl, 'public Hub'); - - if ( - $this->topicPrefix === '' - || strpbrk($this->topicPrefix, "\r\n\0") !== false - || preg_match('/^[A-Za-z][A-Za-z0-9+.-]*:/D', $this->topicPrefix) !== 1 - ) { - throw new MercureConfigurationException( - 'Mercure topicPrefix must be a non-empty absolute IRI prefix.', - ); - } + $this->assertTopicPrefix($this->topicPrefix); if ($this->publisherJwt === null && $this->publisherKey === null) { throw new MercureConfigurationException( @@ -141,6 +132,20 @@ public function __construct( } } + private function assertTopicPrefix(string $prefix): void + { + if ( + $prefix === '' + || strpbrk($prefix, "\r\n\0{}*?[]") !== false + || preg_match('/^[A-Za-z][A-Za-z0-9+.-]*:/D', $prefix) !== 1 + ) { + throw new MercureConfigurationException( + 'Mercure topicPrefix must be a literal absolute IRI prefix ' + . 'without wildcard or URI-template characters.', + ); + } + } + public function publisherToken(MercureJwtFactory $tokens, ?int $issuedAt = null): string { if ($this->publisherJwt !== null) { diff --git a/tests/Broker/Mercure/MercureConfigTest.php b/tests/Broker/Mercure/MercureConfigTest.php index 63b8d24..dda95e1 100644 --- a/tests/Broker/Mercure/MercureConfigTest.php +++ b/tests/Broker/Mercure/MercureConfigTest.php @@ -126,5 +126,17 @@ static function (Sse $config): void { ]; }, ]; + + foreach (['{', '}', '*', '?', '[', ']'] as $character) { + yield sprintf('topic prefix rejects "%s"', $character) => [ + static function (Sse $config) use ($character): void { + $config->mercure = [ + 'topicPrefix' => 'https://example.test/sse/' . $character . 'topic', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + }, + ]; + } } } From c129abb6aee9ae6ced7d2ea0099bb48ff9248b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 22:47:36 +0200 Subject: [PATCH 4/9] Refactor ChannelRequestParser to reject oversized raw channels parameter before splitting --- docs/configuration.md | 2 +- docs/troubleshooting.md | 3 +- src/HTTP/ChannelRequestParser.php | 46 +++++++++++++++++++++---- tests/HTTP/ChannelRequestParserTest.php | 8 +++++ 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 935a4fe..9ba6f0e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -95,7 +95,7 @@ then connects directly to the authorized Hub URL. | `retryMilliseconds` | `3000` | SSE reconnect delay hint sent to the browser. | | `heartbeatInterval` | `15` | Seconds between heartbeat comments while idle. | | `maxConnectionSeconds` | `300` | Finite lifetime of one HTTP stream. | -| `maxChannelsPerConnection` | `20` | Maximum unique requested logical channels. | +| `maxChannelsPerConnection` | `20` | Maximum unique requested logical channels. The raw `channels` query input is also bounded from this value before splitting. | | `emitConnectedEvent` | `true` | Send `sse.connected` after opening the stream. | The browser automatically opens a new stream after the server reaches diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c475fe1..df722d3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -50,7 +50,8 @@ Valid requests include: /sse?channels[]=users.42&channels[]=orders.918 ``` -Check the channel syntax and `maxChannelsPerConnection`. Patterns are rejected +Check the channel syntax and `maxChannelsPerConnection`. Oversized raw +`channels` query input is rejected before splitting. Patterns are rejected unless explicitly enabled. ## The endpoint returns 403 diff --git a/src/HTTP/ChannelRequestParser.php b/src/HTTP/ChannelRequestParser.php index f8f1073..d184c56 100644 --- a/src/HTTP/ChannelRequestParser.php +++ b/src/HTTP/ChannelRequestParser.php @@ -10,6 +10,8 @@ final readonly class ChannelRequestParser { + private const MAXIMUM_CHANNEL_BYTES = 200; + public function __construct( private int $maximumChannels = 20, private ?ChannelSelectorValidatorInterface $validator = null, @@ -32,22 +34,25 @@ public function parse(array|string|null $input): array $values = is_array($input) ? $input : [$input]; $parts = []; + $bytes = 0; foreach ($values as $value) { if (! is_string($value)) { throw new InvalidChannelRequestException('The channels query parameter must contain strings.'); } - foreach (explode(',', $value) as $part) { - $part = trim($part); + $bytes += strlen($value); - if ($part !== '') { - $parts[] = $part; - } + if ($bytes > $this->maximumInputBytes()) { + throw new InvalidChannelRequestException( + sprintf('The channels query parameter must not exceed %d bytes.', $this->maximumInputBytes()), + ); } + + $this->parseValue($value, $parts); } - $parts = array_values(array_unique($parts)); + $parts = array_keys($parts); if ($parts === []) { throw new InvalidChannelRequestException('At least one channel is required.'); @@ -67,4 +72,33 @@ public function parse(array|string|null $input): array return $parts; } + + /** + * @param array $parts + */ + private function parseValue(string $value, array &$parts): void + { + $length = strlen($value); + $start = 0; + + for ($offset = 0; $offset <= $length; $offset++) { + if ($offset !== $length && $value[$offset] !== ',') { + continue; + } + + $part = trim(substr($value, $start, $offset - $start)); + + if ($part !== '') { + $parts[$part] = true; + } + + $start = $offset + 1; + } + } + + private function maximumInputBytes(): int + { + return $this->maximumChannels * self::MAXIMUM_CHANNEL_BYTES + + max(0, $this->maximumChannels - 1); + } } diff --git a/tests/HTTP/ChannelRequestParserTest.php b/tests/HTTP/ChannelRequestParserTest.php index 265c7d0..46b1c26 100644 --- a/tests/HTTP/ChannelRequestParserTest.php +++ b/tests/HTTP/ChannelRequestParserTest.php @@ -32,6 +32,14 @@ public function testChannelLimitIsEnforced(): void (new ChannelRequestParser(1))->parse('public.news,public.alerts'); } + public function testOversizedRawChannelsParameterIsRejectedBeforeSplitting(): void + { + $this->expectException(InvalidChannelRequestException::class); + $this->expectExceptionMessage('The channels query parameter must not exceed 200 bytes.'); + + (new ChannelRequestParser(1))->parse(str_repeat('public.news,', 20) . 'public.news'); + } + public function testPatternsAreOptIn(): void { $this->expectException(InvalidChannelException::class); From 4971dfa3eb6443bf3a2e10f6ea4d8977848fe83c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 22:52:50 +0200 Subject: [PATCH 5/9] Added default topic selector and global publisher selector options for Mercure configuration. Updated tests to reflect these changes. --- docs/configuration.md | 3 + docs/mercure.md | 6 +- src/Broker/Mercure/MercureConfig.php | 11 ++++ src/Broker/Mercure/MercureConfigFactory.php | 17 +++++- src/Config/Sse.php | 37 ++++++------ tests/Broker/Mercure/MercureConfigTest.php | 63 +++++++++++++++------ 6 files changed, 99 insertions(+), 38 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 9ba6f0e..0d72562 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -273,6 +273,9 @@ public array $mercure = [ 'publisherKey' => null, 'subscriberKey' => null, + // Defaults to topicPrefix . '{channel}'. + 'publisherTopicSelectors' => null, + 'allowGlobalPublisherSelector' => false, 'cookie' => [ 'name' => 'mercureAuthorization', diff --git a/docs/mercure.md b/docs/mercure.md index d6a98a2..c392a77 100644 --- a/docs/mercure.md +++ b/docs/mercure.md @@ -98,6 +98,9 @@ final class Sse extends BaseSse 'publisherKey' => null, 'subscriberKey' => null, + // Defaults to topicPrefix . '{channel}'. + 'publisherTopicSelectors' => null, + 'allowGlobalPublisherSelector' => false, 'cookie' => [ 'name' => 'mercureAuthorization', @@ -316,7 +319,8 @@ pod. Do not expose the entire Caddy admin API publicly. | `subscriberAlgorithm` | `HS256` | Subscriber JWT algorithm: HS256, HS384, or HS512. | | `publisherTokenTtl` | `300` | Generated publisher token lifetime in seconds. | | `subscriberTokenTtl` | `3600` | Browser token lifetime in seconds. | -| `publisherTopicSelectors` | `['*']` | Topics the generated publisher JWT may publish. | +| `publisherTopicSelectors` | `null` | Topics the generated publisher JWT may publish. `null` becomes `topicPrefix . '{channel}'`. | +| `allowGlobalPublisherSelector` | `false` | Allows `publisherTopicSelectors` to contain `*`. Enable only for an intentionally global publisher. | | `connectTimeout` | `2.5` | Hub connection timeout in seconds. | | `timeout` | `5.0` | Complete publish request timeout in seconds. | | `verifyTls` | `true` | TLS verification flag or CA bundle path. | diff --git a/src/Broker/Mercure/MercureConfig.php b/src/Broker/Mercure/MercureConfig.php index 0ebbf52..7593b69 100644 --- a/src/Broker/Mercure/MercureConfig.php +++ b/src/Broker/Mercure/MercureConfig.php @@ -27,6 +27,7 @@ public function __construct( public int $publisherTokenTtl, public int $subscriberTokenTtl, public array $publisherTopicSelectors, + public bool $allowGlobalPublisherSelector, public float $connectTimeout, public float $timeout, public bool|string $verifyTls, @@ -82,6 +83,16 @@ public function __construct( ); } + if ( + in_array('*', $this->publisherTopicSelectors, true) + && ! $this->allowGlobalPublisherSelector + ) { + throw new MercureConfigurationException( + 'The global Mercure publisher selector "*" is disabled. ' + . 'Enable it explicitly only when required.', + ); + } + foreach ($this->publisherTopicSelectors as $selector) { if ($selector === '' || strpbrk($selector, "\r\n\0") !== false) { throw new MercureConfigurationException( diff --git a/src/Broker/Mercure/MercureConfigFactory.php b/src/Broker/Mercure/MercureConfigFactory.php index e65dab7..1c3f75c 100644 --- a/src/Broker/Mercure/MercureConfigFactory.php +++ b/src/Broker/Mercure/MercureConfigFactory.php @@ -26,7 +26,8 @@ public function create(Sse $config): MercureConfig subscriberAlgorithm: strtoupper((string) $mercure['subscriberAlgorithm']), publisherTokenTtl: (int) $mercure['publisherTokenTtl'], subscriberTokenTtl: (int) $mercure['subscriberTokenTtl'], - publisherTopicSelectors: self::stringList($mercure['publisherTopicSelectors'] ?? null), + publisherTopicSelectors: self::publisherTopicSelectors($mercure), + allowGlobalPublisherSelector: (bool) $mercure['allowGlobalPublisherSelector'], connectTimeout: (float) $mercure['connectTimeout'], timeout: (float) $mercure['timeout'], verifyTls: is_string($mercure['verifyTls']) @@ -48,6 +49,20 @@ private static function nullableString(mixed $value): ?string return is_string($value) && $value !== '' ? $value : null; } + /** + * @param array $mercure + * + * @return list + */ + private static function publisherTopicSelectors(array $mercure): array + { + if (($mercure['publisherTopicSelectors'] ?? null) === null) { + return [(string) $mercure['topicPrefix'] . '{channel}']; + } + + return self::stringList($mercure['publisherTopicSelectors']); + } + /** * @return list */ diff --git a/src/Config/Sse.php b/src/Config/Sse.php index de7df4e..3562a86 100644 --- a/src/Config/Sse.php +++ b/src/Config/Sse.php @@ -50,24 +50,25 @@ class Sse extends BaseConfig * @var array */ private const DEFAULT_MERCURE = [ - 'hubUrl' => 'http://127.0.0.1:3000/.well-known/mercure', - 'publicHubUrl' => 'http://127.0.0.1:3000/.well-known/mercure', - 'topicPrefix' => 'urn:codeigniter4-sse:', - 'private' => true, - 'authorizeSubscribers' => true, - 'publisherJwt' => null, - 'publisherKey' => null, - 'subscriberKey' => null, - 'publisherAlgorithm' => 'HS256', - 'subscriberAlgorithm' => 'HS256', - 'publisherTokenTtl' => 300, - 'subscriberTokenTtl' => 3600, - 'publisherTopicSelectors' => ['*'], - 'connectTimeout' => 2.5, - 'timeout' => 5.0, - 'verifyTls' => true, - 'maxPayloadBytes' => 1_048_576, - 'cookie' => [ + 'hubUrl' => 'http://127.0.0.1:3000/.well-known/mercure', + 'publicHubUrl' => 'http://127.0.0.1:3000/.well-known/mercure', + 'topicPrefix' => 'urn:codeigniter4-sse:', + 'private' => true, + 'authorizeSubscribers' => true, + 'publisherJwt' => null, + 'publisherKey' => null, + 'subscriberKey' => null, + 'publisherAlgorithm' => 'HS256', + 'subscriberAlgorithm' => 'HS256', + 'publisherTokenTtl' => 300, + 'subscriberTokenTtl' => 3600, + 'publisherTopicSelectors' => null, + 'allowGlobalPublisherSelector' => false, + 'connectTimeout' => 2.5, + 'timeout' => 5.0, + 'verifyTls' => true, + 'maxPayloadBytes' => 1_048_576, + 'cookie' => [ 'name' => 'mercureAuthorization', 'domain' => '', 'path' => '/.well-known/mercure', diff --git a/tests/Broker/Mercure/MercureConfigTest.php b/tests/Broker/Mercure/MercureConfigTest.php index dda95e1..281539c 100644 --- a/tests/Broker/Mercure/MercureConfigTest.php +++ b/tests/Broker/Mercure/MercureConfigTest.php @@ -20,24 +20,25 @@ public function testFactoryMapsMercureOptions(): void $config = new Sse(); $config->retryMilliseconds = 1250; $config->mercure = [ - 'hubUrl' => 'http://mercure/.well-known/mercure', - 'publicHubUrl' => 'https://example.test/.well-known/mercure', - 'topicPrefix' => 'urn:example:sse:', - 'private' => false, - 'authorizeSubscribers' => false, - 'publisherJwt' => 'static-publisher-token', - 'publisherKey' => null, - 'subscriberKey' => null, - 'publisherAlgorithm' => 'hs384', - 'subscriberAlgorithm' => 'hs512', - 'publisherTokenTtl' => 90, - 'subscriberTokenTtl' => 600, - 'publisherTopicSelectors' => ['*', 'users.*', 17], - 'connectTimeout' => 1.5, - 'timeout' => 4.5, - 'verifyTls' => '/etc/ssl/certs/ca.pem', - 'maxPayloadBytes' => 4096, - 'cookie' => [ + 'hubUrl' => 'http://mercure/.well-known/mercure', + 'publicHubUrl' => 'https://example.test/.well-known/mercure', + 'topicPrefix' => 'urn:example:sse:', + 'private' => false, + 'authorizeSubscribers' => false, + 'publisherJwt' => 'static-publisher-token', + 'publisherKey' => null, + 'subscriberKey' => null, + 'publisherAlgorithm' => 'hs384', + 'subscriberAlgorithm' => 'hs512', + 'publisherTokenTtl' => 90, + 'subscriberTokenTtl' => 600, + 'publisherTopicSelectors' => ['*', 'users.*', 17], + 'allowGlobalPublisherSelector' => true, + 'connectTimeout' => 1.5, + 'timeout' => 4.5, + 'verifyTls' => '/etc/ssl/certs/ca.pem', + 'maxPayloadBytes' => 4096, + 'cookie' => [ 'name' => 'mercureAuth', 'domain' => 'example.test', 'path' => '/mercure', @@ -62,6 +63,7 @@ public function testFactoryMapsMercureOptions(): void $this->assertSame(90, $mercure->publisherTokenTtl); $this->assertSame(600, $mercure->subscriberTokenTtl); $this->assertSame(['*', 'users.*'], $mercure->publisherTopicSelectors); + $this->assertTrue($mercure->allowGlobalPublisherSelector); $this->assertSame(1.5, $mercure->connectTimeout); $this->assertSame(4.5, $mercure->timeout); $this->assertSame('/etc/ssl/certs/ca.pem', $mercure->verifyTls); @@ -75,6 +77,21 @@ public function testFactoryMapsMercureOptions(): void $this->assertSame('Lax', $mercure->cookieSameSite); } + public function testDefaultsPublisherSelectorsFromTopicPrefix(): void + { + $config = new Sse(); + $config->mercure = [ + 'topicPrefix' => 'urn:herceg:sse:', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + + $mercure = (new MercureConfigFactory())->create($config); + + $this->assertSame(['urn:herceg:sse:{channel}'], $mercure->publisherTopicSelectors); + $this->assertFalse($mercure->allowGlobalPublisherSelector); + } + #[DataProvider('provideRejectsInvalidMercureConfig')] public function testRejectsInvalidMercureConfig(callable $configure): void { @@ -127,6 +144,16 @@ static function (Sse $config): void { }, ]; + yield 'global publisher selector requires opt in' => [ + static function (Sse $config): void { + $config->mercure = [ + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + 'publisherTopicSelectors' => ['*'], + ]; + }, + ]; + foreach (['{', '}', '*', '?', '[', ']'] as $character) { yield sprintf('topic prefix rejects "%s"', $character) => [ static function (Sse $config) use ($character): void { From a9f1545bac2a3c2c97a1e19d91e24ab84dff0150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 22:57:46 +0200 Subject: [PATCH 6/9] Add Mercure JWT codec implementation and tests --- docs/mercure.md | 12 +- src/Broker/Mercure/MercureConfig.php | 62 ++++++++ src/Broker/Mercure/MercureJwtCodec.php | 91 +++++++++++ src/Broker/Mercure/MercureJwtFactory.php | 36 +---- tests/Broker/Mercure/MercureConfigTest.php | 153 ++++++++++++++++++- tests/Broker/Mercure/MercureJwtCodecTest.php | 43 ++++++ 6 files changed, 358 insertions(+), 39 deletions(-) create mode 100644 src/Broker/Mercure/MercureJwtCodec.php create mode 100644 tests/Broker/Mercure/MercureJwtCodecTest.php diff --git a/docs/mercure.md b/docs/mercure.md index c392a77..0e1a787 100644 --- a/docs/mercure.md +++ b/docs/mercure.md @@ -330,10 +330,14 @@ pod. Do not expose the entire Caddy admin API publicly. The built-in JWT issuer supports HMAC algorithms and requires HMAC signing keys to be at least 32 bytes. Generated JWTs include `iss`, `aud`, `sub`, `jti`, `iat`, and `exp` claims plus the Mercure scope. `publisherJwt` can contain a -token issued by an external system, but dynamic subscriber authorization still -requires the configured HMAC subscriber key. Applications using an external -OAuth/JWKS issuer should replace the authorization controller rather than -exposing signing keys to the browser. +token issued by an external system. The package does not cryptographically +verify pre-generated publisher tokens, but it rejects malformed JWTs, unsupported +`alg` values, missing or expired `exp` claims, missing `mercure.publish` rights, +and publish selectors outside `topicPrefix` unless the global `*` selector was +explicitly enabled. Dynamic subscriber authorization still requires the +configured HMAC subscriber key. Applications using an external OAuth/JWKS issuer +should replace the authorization controller rather than exposing signing keys to +the browser. The adapter currently maps exact logical channels. Redis glob patterns are not accepted by the Mercure transport; pattern selectors remain a Redis adapter diff --git a/src/Broker/Mercure/MercureConfig.php b/src/Broker/Mercure/MercureConfig.php index 7593b69..0a90999 100644 --- a/src/Broker/Mercure/MercureConfig.php +++ b/src/Broker/Mercure/MercureConfig.php @@ -101,6 +101,10 @@ public function __construct( } } + if ($this->publisherJwt !== null) { + $this->assertPublisherJwt($this->publisherJwt); + } + if ($this->connectTimeout <= 0.0 || $this->timeout <= 0.0) { throw new MercureConfigurationException( 'Mercure HTTP timeouts must be greater than zero.', @@ -143,6 +147,64 @@ public function __construct( } } + private function assertPublisherJwt(string $jwt): void + { + $codec = new MercureJwtCodec(); + [$encodedHeader, $encodedClaims] = $codec->split($jwt, 'publisherJwt'); + $header = $codec->decodeJsonObjectSegment($encodedHeader, 'publisherJwt header'); + $claims = $codec->decodeJsonObjectSegment($encodedClaims, 'publisherJwt payload'); + + $algorithm = $header['alg'] ?? null; + + if (! is_string($algorithm) || ! in_array($algorithm, self::ALGORITHMS, true)) { + throw new MercureConfigurationException( + sprintf('Mercure publisherJwt alg must be one of %s.', implode(', ', self::ALGORITHMS)), + ); + } + + $expiresAt = $claims['exp'] ?? null; + + if (! is_int($expiresAt) && ! is_float($expiresAt)) { + throw new MercureConfigurationException('Mercure publisherJwt must contain an exp claim.'); + } + + if ($expiresAt <= time()) { + throw new MercureConfigurationException('Mercure publisherJwt has expired.'); + } + + $mercure = $claims['mercure'] ?? null; + $publish = is_array($mercure) ? ($mercure['publish'] ?? null) : null; + + if (! is_array($publish) || $publish === []) { + throw new MercureConfigurationException('Mercure publisherJwt must contain mercure.publish rights.'); + } + + foreach ($publish as $selector) { + if (! is_string($selector) || $selector === '' || strpbrk($selector, "\r\n\0") !== false) { + throw new MercureConfigurationException( + 'Mercure publisherJwt publish selectors must be non-empty single-line strings.', + ); + } + + if ($selector === '*') { + if (! $this->allowGlobalPublisherSelector) { + throw new MercureConfigurationException( + 'The global Mercure publisher selector "*" is disabled. ' + . 'Enable it explicitly only when required.', + ); + } + + continue; + } + + if (! str_starts_with($selector, $this->topicPrefix)) { + throw new MercureConfigurationException( + 'Mercure publisherJwt publish selectors must stay within topicPrefix.', + ); + } + } + } + private function assertTopicPrefix(string $prefix): void { if ( diff --git a/src/Broker/Mercure/MercureJwtCodec.php b/src/Broker/Mercure/MercureJwtCodec.php new file mode 100644 index 0000000..8e6d94e --- /dev/null +++ b/src/Broker/Mercure/MercureJwtCodec.php @@ -0,0 +1,91 @@ + $header + * @param array $claims + */ + public function unsigned(array $header, array $claims): string + { + return $this->encodeJsonObject($header, 'header') + . '.' . $this->encodeJsonObject($claims, 'payload'); + } + + /** + * @return array{0: string, 1: string, 2: string} + */ + public function split(string $jwt, string $label = 'JWT'): array + { + $parts = explode('.', $jwt); + + if (count($parts) !== 3 || in_array('', $parts, true)) { + throw new MercureConfigurationException( + sprintf('Mercure %s must be a compact JWT with three segments.', $label), + ); + } + + return [$parts[0], $parts[1], $parts[2]]; + } + + /** + * @return array + */ + public function decodeJsonObjectSegment(string $segment, string $label): array + { + $decoded = base64_decode( + strtr($segment . str_repeat('=', (4 - strlen($segment) % 4) % 4), '-_', '+/'), + true, + ); + + if ($decoded === false) { + throw new MercureConfigurationException(sprintf('Mercure JWT %s is not valid base64url.', $label)); + } + + try { + $value = json_decode($decoded, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw new MercureConfigurationException( + sprintf('Mercure JWT %s is not valid JSON.', $label), + 0, + $exception, + ); + } + + if (! is_array($value)) { + throw new MercureConfigurationException(sprintf('Mercure JWT %s must be a JSON object.', $label)); + } + + return $value; + } + + public function encodeBytes(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } + + /** + * @param array $value + */ + public function encodeJsonObject(array $value, string $label): string + { + try { + return $this->encodeBytes( + json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + ); + } catch (JsonException $exception) { + throw new MercureConfigurationException( + sprintf('Mercure JWT %s could not be encoded.', $label), + 0, + $exception, + ); + } + } +} diff --git a/src/Broker/Mercure/MercureJwtFactory.php b/src/Broker/Mercure/MercureJwtFactory.php index dab4db1..97c9b22 100644 --- a/src/Broker/Mercure/MercureJwtFactory.php +++ b/src/Broker/Mercure/MercureJwtFactory.php @@ -4,7 +4,6 @@ namespace Maniaba\CodeIgniterSse\Broker\Mercure; -use JsonException; use Maniaba\CodeIgniterSse\Broker\Mercure\Exception\MercureConfigurationException; use Random\RandomException; @@ -21,6 +20,11 @@ final class MercureJwtFactory private const PUBLISHER_SUBJECT = 'mercure-publisher'; private const SUBSCRIBER_SUBJECT = 'mercure-subscriber'; + public function __construct( + private readonly ?MercureJwtCodec $codec = null, + ) { + } + /** * @param array{publish?: list, subscribe?: list} $mercure */ @@ -50,8 +54,8 @@ public function create( } $issuedAt ??= time(); - $header = $this->encode(['alg' => $algorithm, 'typ' => 'JWT']); - $claims = $this->encode([ + $codec = $this->codec ?? new MercureJwtCodec(); + $unsigned = $codec->unsigned(['alg' => $algorithm, 'typ' => 'JWT'], [ 'iss' => self::ISSUER, 'aud' => self::AUDIENCE, 'sub' => self::subject($mercure), @@ -60,28 +64,9 @@ public function create( 'exp' => $issuedAt + $ttl, 'mercure' => $mercure, ]); - $unsigned = $header . '.' . $claims; $signature = hash_hmac($hash, $unsigned, $key, true); - return $unsigned . '.' . self::base64UrlEncode($signature); - } - - /** - * @param array $value - */ - private function encode(array $value): string - { - try { - return self::base64UrlEncode( - json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), - ); - } catch (JsonException $exception) { - throw new MercureConfigurationException( - 'The Mercure JWT claims could not be encoded.', - 0, - $exception, - ); - } + return $unsigned . '.' . $codec->encodeBytes($signature); } /** @@ -112,9 +97,4 @@ private static function jwtId(): string ); } } - - private static function base64UrlEncode(string $value): string - { - return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); - } } diff --git a/tests/Broker/Mercure/MercureConfigTest.php b/tests/Broker/Mercure/MercureConfigTest.php index 281539c..354ce60 100644 --- a/tests/Broker/Mercure/MercureConfigTest.php +++ b/tests/Broker/Mercure/MercureConfigTest.php @@ -6,6 +6,7 @@ use Maniaba\CodeIgniterSse\Broker\Mercure\Exception\MercureConfigurationException; use Maniaba\CodeIgniterSse\Broker\Mercure\MercureConfigFactory; +use Maniaba\CodeIgniterSse\Broker\Mercure\MercureJwtCodec; use Maniaba\CodeIgniterSse\Config\Sse; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -20,12 +21,18 @@ public function testFactoryMapsMercureOptions(): void $config = new Sse(); $config->retryMilliseconds = 1250; $config->mercure = [ - 'hubUrl' => 'http://mercure/.well-known/mercure', - 'publicHubUrl' => 'https://example.test/.well-known/mercure', - 'topicPrefix' => 'urn:example:sse:', - 'private' => false, - 'authorizeSubscribers' => false, - 'publisherJwt' => 'static-publisher-token', + 'hubUrl' => 'http://mercure/.well-known/mercure', + 'publicHubUrl' => 'https://example.test/.well-known/mercure', + 'topicPrefix' => 'urn:example:sse:', + 'private' => false, + 'authorizeSubscribers' => false, + 'publisherJwt' => self::publisherJwt( + ['alg' => 'HS384', 'typ' => 'JWT'], + [ + 'exp' => time() + 3600, + 'mercure' => ['publish' => ['*']], + ], + ), 'publisherKey' => null, 'subscriberKey' => null, 'publisherAlgorithm' => 'hs384', @@ -55,7 +62,7 @@ public function testFactoryMapsMercureOptions(): void $this->assertSame('urn:example:sse:', $mercure->topicPrefix); $this->assertFalse($mercure->privateUpdates); $this->assertFalse($mercure->authorizeSubscribers); - $this->assertSame('static-publisher-token', $mercure->publisherJwt); + $this->assertIsString($mercure->publisherJwt); $this->assertNull($mercure->publisherKey); $this->assertNull($mercure->subscriberKey); $this->assertSame('HS384', $mercure->publisherAlgorithm); @@ -92,6 +99,29 @@ public function testDefaultsPublisherSelectorsFromTopicPrefix(): void $this->assertFalse($mercure->allowGlobalPublisherSelector); } + public function testAcceptsScopedPublisherJwt(): void + { + $jwt = self::publisherJwt( + ['alg' => 'HS256', 'typ' => 'JWT'], + [ + 'exp' => time() + 3600, + 'mercure' => ['publish' => ['urn:herceg:sse:{channel}']], + ], + ); + + $config = new Sse(); + $config->mercure = [ + 'topicPrefix' => 'urn:herceg:sse:', + 'publisherJwt' => $jwt, + 'publisherKey' => null, + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + + $mercure = (new MercureConfigFactory())->create($config); + + $this->assertSame($jwt, $mercure->publisherJwt); + } + #[DataProvider('provideRejectsInvalidMercureConfig')] public function testRejectsInvalidMercureConfig(callable $configure): void { @@ -154,6 +184,104 @@ static function (Sse $config): void { }, ]; + yield 'publisher jwt must have three segments' => [ + static function (Sse $config): void { + $config->mercure = [ + 'publisherJwt' => 'not-a-jwt', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + }, + ]; + + yield 'publisher jwt rejects invalid algorithm' => [ + static function (Sse $config): void { + $config->mercure = [ + 'publisherJwt' => self::publisherJwt( + ['alg' => 'none'], + [ + 'exp' => time() + 3600, + 'mercure' => ['publish' => ['urn:codeigniter4-sse:{channel}']], + ], + ), + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + }, + ]; + + yield 'publisher jwt requires exp' => [ + static function (Sse $config): void { + $config->mercure = [ + 'publisherJwt' => self::publisherJwt( + ['alg' => 'HS256'], + [ + 'mercure' => ['publish' => ['urn:codeigniter4-sse:{channel}']], + ], + ), + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + }, + ]; + + yield 'publisher jwt rejects expired token' => [ + static function (Sse $config): void { + $config->mercure = [ + 'publisherJwt' => self::publisherJwt( + ['alg' => 'HS256'], + [ + 'exp' => time() - 1, + 'mercure' => ['publish' => ['urn:codeigniter4-sse:{channel}']], + ], + ), + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + }, + ]; + + yield 'publisher jwt requires publish rights' => [ + static function (Sse $config): void { + $config->mercure = [ + 'publisherJwt' => self::publisherJwt( + ['alg' => 'HS256'], + [ + 'exp' => time() + 3600, + 'mercure' => ['subscribe' => ['urn:codeigniter4-sse:{channel}']], + ], + ), + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + }, + ]; + + yield 'publisher jwt publish rights must stay inside topic prefix' => [ + static function (Sse $config): void { + $config->mercure = [ + 'publisherJwt' => self::publisherJwt( + ['alg' => 'HS256'], + [ + 'exp' => time() + 3600, + 'mercure' => ['publish' => ['urn:other:sse:{channel}']], + ], + ), + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + }, + ]; + + yield 'publisher jwt global publish rights require opt in' => [ + static function (Sse $config): void { + $config->mercure = [ + 'publisherJwt' => self::publisherJwt( + ['alg' => 'HS256'], + [ + 'exp' => time() + 3600, + 'mercure' => ['publish' => ['*']], + ], + ), + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + ]; + }, + ]; + foreach (['{', '}', '*', '?', '[', ']'] as $character) { yield sprintf('topic prefix rejects "%s"', $character) => [ static function (Sse $config) use ($character): void { @@ -166,4 +294,15 @@ static function (Sse $config) use ($character): void { ]; } } + + /** + * @param array $header + * @param array $claims + */ + private static function publisherJwt(array $header, array $claims): string + { + $codec = new MercureJwtCodec(); + + return $codec->unsigned($header, $claims) . '.signature'; + } } diff --git a/tests/Broker/Mercure/MercureJwtCodecTest.php b/tests/Broker/Mercure/MercureJwtCodecTest.php new file mode 100644 index 0000000..db5a44f --- /dev/null +++ b/tests/Broker/Mercure/MercureJwtCodecTest.php @@ -0,0 +1,43 @@ +unsigned( + ['alg' => 'HS256', 'typ' => 'JWT'], + ['exp' => 1_700_000_000, 'mercure' => ['publish' => ['urn:sse:{channel}']]], + ); + + [$header, $claims, $signature] = $codec->split($unsigned . '.signature'); + + $this->assertSame('signature', $signature); + $this->assertSame( + ['alg' => 'HS256', 'typ' => 'JWT'], + $codec->decodeJsonObjectSegment($header, 'header'), + ); + $this->assertSame( + ['exp' => 1_700_000_000, 'mercure' => ['publish' => ['urn:sse:{channel}']]], + $codec->decodeJsonObjectSegment($claims, 'payload'), + ); + } + + public function testRejectsNonCompactJwt(): void + { + $this->expectException(MercureConfigurationException::class); + + (new MercureJwtCodec())->split('not-a-jwt'); + } +} From 25a9282617f4bb8a4016dba7b17a5e39c8d2f2f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 23:09:54 +0200 Subject: [PATCH 7/9] Add option to reject cross-site bootstrap requests in Mercure authorization --- docs/configuration.md | 7 ++ docs/mercure.md | 5 ++ .../Mercure/MercureSubscriptionEndpoint.php | 14 ++++ src/Config/Sse.php | 15 ++-- src/HTTP/SseController.php | 14 +++- tests/Config/SseConfigTest.php | 5 ++ tests/HTTP/SseControllerTest.php | 79 +++++++++++++++++++ 7 files changed, 128 insertions(+), 11 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 0d72562..35c9922 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -501,6 +501,7 @@ Both classes must implement their package contracts. See |---|---:|---| | `allowedOrigins` | `[]` | Exact cross-origin frontend origins. | | `withCredentials` | `true` | Allow credentialed CORS responses. | +| `rejectCrossSiteBootstrap` | `true` | Reject Mercure authorization bootstrap requests with `Sec-Fetch-Site: cross-site`. | Same-origin requests do not require an allowlist entry because browsers omit the cross-origin `Origin` case this policy is intended to control. @@ -518,3 +519,9 @@ public bool $withCredentials = true; When credentials are enabled, `*` is not a valid allowed origin. Cookie domain, `Secure`, and `SameSite` settings must also permit the browser to send the session cookie. + +`rejectCrossSiteBootstrap` adds a Fetch Metadata check for browsers that send +`Sec-Fetch-Site`. It is an extra defense for the Mercure authorization request +that sets the HttpOnly subscriber cookie; it does not replace CORS, session +authentication, or channel authorization. Disable it only for trusted legacy or +non-browser clients that cannot send Fetch Metadata headers. diff --git a/docs/mercure.md b/docs/mercure.md index 0e1a787..82343c5 100644 --- a/docs/mercure.md +++ b/docs/mercure.md @@ -275,6 +275,11 @@ For a cross-origin Hub: - serve both endpoints over HTTPS; - allow the Hub origin in Content Security Policy `connect-src`. +The CodeIgniter authorization route also rejects +`Sec-Fetch-Site: cross-site` bootstrap requests by default before issuing the +subscriber cookie. Set `rejectCrossSiteBootstrap = false` only for trusted +legacy or non-browser clients that cannot send Fetch Metadata headers. + An application on `app.example.com` can set `domain = '.example.com'` for a Hub on `hub.example.com`. An application cannot set a cookie for an unrelated site. Use a same-origin reverse proxy in that case. diff --git a/src/Broker/Mercure/MercureSubscriptionEndpoint.php b/src/Broker/Mercure/MercureSubscriptionEndpoint.php index 12dd40d..0e3ae43 100644 --- a/src/Broker/Mercure/MercureSubscriptionEndpoint.php +++ b/src/Broker/Mercure/MercureSubscriptionEndpoint.php @@ -10,6 +10,7 @@ use Maniaba\CodeIgniterSse\Contracts\ChannelSelectorValidatorInterface; use Maniaba\CodeIgniterSse\Contracts\ChannelSelectorValidatorProviderInterface; use Maniaba\CodeIgniterSse\Contracts\PreflightSubscriptionEndpointInterface; +use Maniaba\CodeIgniterSse\Exception\InvalidOriginException; use Maniaba\CodeIgniterSse\Factory\MercureSubscriptionFactory; use Maniaba\CodeIgniterSse\HTTP\AcceptHeaderNegotiator; use Maniaba\CodeIgniterSse\Support\ChannelNameValidator; @@ -61,6 +62,10 @@ public function respond( ResponseInterface $response, array $channels, ): ResponseInterface { + if ($this->config->rejectCrossSiteBootstrap) { + $this->assertSameSiteRequest($request); + } + $subscriptions = $this->subscriptions ?? new MercureSubscriptionFactory(mercure: $this->mercure); $subscription = $subscriptions->create($this->config, $channels); $mercure = $this->mercure ?? ($this->configs ?? new MercureConfigFactory())->create($this->config); @@ -100,4 +105,13 @@ public function respond( return $response; } + + private function assertSameSiteRequest(RequestInterface $request): void + { + if (strtolower($request->getHeaderLine('Sec-Fetch-Site')) === 'cross-site') { + throw new InvalidOriginException( + 'Cross-site SSE authorization requests are not allowed.', + ); + } + } } diff --git a/src/Config/Sse.php b/src/Config/Sse.php index 3562a86..a2bfafd 100644 --- a/src/Config/Sse.php +++ b/src/Config/Sse.php @@ -141,13 +141,14 @@ class Sse extends BaseConfig ], ]; - public string $channelPrefix = 'app:sse:'; - public int $retryMilliseconds = 3000; - public int $heartbeatInterval = 15; - public int $maxConnectionSeconds = 300; - public int $maxChannelsPerConnection = 20; - public bool $emitConnectedEvent = true; - public bool $requireAcceptHeader = true; + public string $channelPrefix = 'app:sse:'; + public int $retryMilliseconds = 3000; + public int $heartbeatInterval = 15; + public int $maxConnectionSeconds = 300; + public int $maxChannelsPerConnection = 20; + public bool $emitConnectedEvent = true; + public bool $requireAcceptHeader = true; + public bool $rejectCrossSiteBootstrap = true; /** * CodeIgniter Debug Toolbar publisher tracing. diff --git a/src/HTTP/SseController.php b/src/HTTP/SseController.php index 62f6611..f059f0f 100644 --- a/src/HTTP/SseController.php +++ b/src/HTTP/SseController.php @@ -59,10 +59,16 @@ public function stream(): ResponseInterface ); } - return $cors->apply( - $endpoint->respond($this->request, $this->response, $channels), - $origin, - ); + try { + $response = $endpoint->respond($this->request, $this->response, $channels); + } catch (InvalidOriginException $exception) { + return $cors->apply( + $this->error(403, 'origin_forbidden', $exception->getMessage()), + $origin, + ); + } + + return $cors->apply($response, $origin); } /** diff --git a/tests/Config/SseConfigTest.php b/tests/Config/SseConfigTest.php index 863ec91..0f877a0 100644 --- a/tests/Config/SseConfigTest.php +++ b/tests/Config/SseConfigTest.php @@ -14,6 +14,11 @@ */ final class SseConfigTest extends TestCase { + public function testRejectsCrossSiteBootstrapByDefault(): void + { + $this->assertTrue((new Sse())->rejectCrossSiteBootstrap); + } + /** * @param callable(Sse):void $configure */ diff --git a/tests/HTTP/SseControllerTest.php b/tests/HTTP/SseControllerTest.php index c44acae..eb89e03 100644 --- a/tests/HTTP/SseControllerTest.php +++ b/tests/HTTP/SseControllerTest.php @@ -155,6 +155,7 @@ public function testMercureRouteAuthorizesChannelsWithoutOpeningAPhpStream(): vo $superglobals->setGetArray(['channels' => 'public.news,public.status']); $request->removeHeader('Origin'); $request->removeHeader('Accept'); + $request->removeHeader('Sec-Fetch-Site'); try { FrameworkServices::injectMock( @@ -201,6 +202,26 @@ public function testMercureRouteAuthorizesChannelsWithoutOpeningAPhpStream(): vo } } + public function testMercureRouteRejectsCrossSiteBootstrapRequest(): void + { + $result = $this->mercureBootstrapResponse('cross-site'); + $body = $result->getBody(); + + $this->assertSame(403, $result->getStatusCode()); + $this->assertIsString($body); + $this->assertStringContainsString('origin_forbidden', $body); + $this->assertStringContainsString('Cross-site SSE authorization requests are not allowed.', $body); + $this->assertNull($result->getCookie('mercureAuthorization')); + } + + public function testMercureRouteAllowsCrossSiteBootstrapWhenFetchMetadataCheckIsDisabled(): void + { + $result = $this->mercureBootstrapResponse('cross-site', rejectCrossSiteBootstrap: false); + + $this->assertSame(200, $result->getStatusCode()); + $this->assertInstanceOf(Cookie::class, $result->getCookie('mercureAuthorization')); + } + private function controllerResponse( ?string $origin, ?string $accept, @@ -216,6 +237,7 @@ private function controllerResponse( $request->removeHeader('Origin'); $request->removeHeader('Accept'); + $request->removeHeader('Sec-Fetch-Site'); if ($origin !== null) { $request->setHeader('Origin', $origin); @@ -240,4 +262,61 @@ private function controllerResponse( } } } + + private function mercureBootstrapResponse( + ?string $secFetchSite, + bool $rejectCrossSiteBootstrap = true, + ): ResponseInterface { + $config = new Sse(); + $config->broker = 'mercure'; + $config->rejectCrossSiteBootstrap = $rejectCrossSiteBootstrap; + $config->mercure = [ + 'hubUrl' => 'http://mercure/.well-known/mercure', + 'publicHubUrl' => 'https://example.test/.well-known/mercure', + 'topicPrefix' => 'urn:example:sse:', + 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', + 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', + 'cookie' => [ + 'name' => 'mercureAuthorization', + 'secure' => true, + 'httpOnly' => true, + 'sameSite' => 'Lax', + ], + ]; + $request = single_service('request'); + $response = single_service('response'); + $logger = service('logger'); + + $this->assertInstanceOf(RequestInterface::class, $request); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertInstanceOf(LoggerInterface::class, $logger); + + $superglobals = service('superglobals'); + $this->assertInstanceOf(Superglobals::class, $superglobals); + $previousGet = $superglobals->getGetArray(); + $superglobals->setGetArray(['channels' => 'public.news']); + $request->removeHeader('Origin'); + $request->removeHeader('Accept'); + $request->removeHeader('Sec-Fetch-Site'); + + if ($secFetchSite !== null) { + $request->setHeader('Sec-Fetch-Site', $secFetchSite); + } + + try { + FrameworkServices::injectMock( + 'sseBrokerAdapter', + new BasicBrokerAdapter(endpoint: new MercureSubscriptionEndpoint($config)), + ); + + $controller = new SseController(); + $controller->initController($request, $response, $logger); + + return $controller->stream(); + } finally { + $superglobals->setGetArray($previousGet); + $request->removeHeader('Sec-Fetch-Site'); + FrameworkServices::resetSingle('sseBrokerAdapter'); + } + } } From c0b5356d1bf8df55782ebbf37e34357433306cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 23:19:34 +0200 Subject: [PATCH 8/9] Refactor Mercure route handling by removing redundant code and streamlining test setup --- tests/HTTP/SseControllerTest.php | 104 +++++++++---------------------- 1 file changed, 31 insertions(+), 73 deletions(-) diff --git a/tests/HTTP/SseControllerTest.php b/tests/HTTP/SseControllerTest.php index eb89e03..56b1c9e 100644 --- a/tests/HTTP/SseControllerTest.php +++ b/tests/HTTP/SseControllerTest.php @@ -126,80 +126,37 @@ public function testAuthorizedPublicChannelProducesAStreamingResponse(): void public function testMercureRouteAuthorizesChannelsWithoutOpeningAPhpStream(): void { - $config = new Sse(); - $config->broker = 'mercure'; - $config->mercure = [ - 'hubUrl' => 'http://mercure/.well-known/mercure', - 'publicHubUrl' => 'https://example.test/.well-known/mercure', - 'topicPrefix' => 'urn:example:sse:', - 'publisherKey' => 'publisher-test-secret-at-least-32-bytes', - 'subscriberKey' => 'subscriber-test-secret-at-least-32-bytes', - 'cookie' => [ - 'name' => 'mercureAuthorization', - 'secure' => true, - 'httpOnly' => true, - 'sameSite' => 'Lax', - ], - ]; - $request = single_service('request'); - $response = single_service('response'); - $logger = service('logger'); - - $this->assertInstanceOf(RequestInterface::class, $request); - $this->assertInstanceOf(ResponseInterface::class, $response); - $this->assertInstanceOf(LoggerInterface::class, $logger); - - $superglobals = service('superglobals'); - $this->assertInstanceOf(Superglobals::class, $superglobals); - $previousGet = $superglobals->getGetArray(); - $superglobals->setGetArray(['channels' => 'public.news,public.status']); - $request->removeHeader('Origin'); - $request->removeHeader('Accept'); - $request->removeHeader('Sec-Fetch-Site'); - - try { - FrameworkServices::injectMock( - 'sseBrokerAdapter', - new BasicBrokerAdapter(endpoint: new MercureSubscriptionEndpoint($config)), - ); - - $controller = new SseController(); - $controller->initController($request, $response, $logger); - $result = $controller->stream(); - $body = $result->getBody(); + $result = $this->mercureBootstrapResponse(null, channels: 'public.news,public.status'); + $body = $result->getBody(); - $this->assertSame(200, $result->getStatusCode()); - $this->assertStringStartsWith('application/json', $result->getHeaderLine('Content-Type')); - $this->assertStringContainsString('private', $result->getHeaderLine('Cache-Control')); - $this->assertStringContainsString('no-store', $result->getHeaderLine('Cache-Control')); - $this->assertSame( - '; rel="mercure"', - $result->getHeaderLine('Link'), - ); - $this->assertIsString($body); - $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); - $this->assertSame( - 'https://example.test/.well-known/mercure', - $decoded['hub'], - ); - $this->assertSame( - [ - 'urn:example:sse:public.news', - 'urn:example:sse:public.status', - ], - $decoded['topics'], - ); - $this->assertIsInt($decoded['expiresAt']); + $this->assertSame(200, $result->getStatusCode()); + $this->assertStringStartsWith('application/json', $result->getHeaderLine('Content-Type')); + $this->assertStringContainsString('private', $result->getHeaderLine('Cache-Control')); + $this->assertStringContainsString('no-store', $result->getHeaderLine('Cache-Control')); + $this->assertSame( + '; rel="mercure"', + $result->getHeaderLine('Link'), + ); + $this->assertIsString($body); + $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + $this->assertSame( + 'https://example.test/.well-known/mercure', + $decoded['hub'], + ); + $this->assertSame( + [ + 'urn:example:sse:public.news', + 'urn:example:sse:public.status', + ], + $decoded['topics'], + ); + $this->assertIsInt($decoded['expiresAt']); - $cookie = $result->getCookie('mercureAuthorization'); - $this->assertInstanceOf(Cookie::class, $cookie); - $this->assertTrue($cookie->isSecure()); - $this->assertTrue($cookie->isHTTPOnly()); - $this->assertSame('Lax', $cookie->getSameSite()); - } finally { - $superglobals->setGetArray($previousGet); - FrameworkServices::resetSingle('sseBrokerAdapter'); - } + $cookie = $result->getCookie('mercureAuthorization'); + $this->assertInstanceOf(Cookie::class, $cookie); + $this->assertTrue($cookie->isSecure()); + $this->assertTrue($cookie->isHTTPOnly()); + $this->assertSame('Lax', $cookie->getSameSite()); } public function testMercureRouteRejectsCrossSiteBootstrapRequest(): void @@ -266,6 +223,7 @@ private function controllerResponse( private function mercureBootstrapResponse( ?string $secFetchSite, bool $rejectCrossSiteBootstrap = true, + string $channels = 'public.news', ): ResponseInterface { $config = new Sse(); $config->broker = 'mercure'; @@ -294,7 +252,7 @@ private function mercureBootstrapResponse( $superglobals = service('superglobals'); $this->assertInstanceOf(Superglobals::class, $superglobals); $previousGet = $superglobals->getGetArray(); - $superglobals->setGetArray(['channels' => 'public.news']); + $superglobals->setGetArray(['channels' => $channels]); $request->removeHeader('Origin'); $request->removeHeader('Accept'); $request->removeHeader('Sec-Fetch-Site'); From 55388bca9da3a327f35a16ba9768145bd85bcb8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 23:21:29 +0200 Subject: [PATCH 9/9] chore: bump version to 1.0.0-rc2 Release candidate focused on Mercure hardening, JWT validation, safer request parsing, and test cleanup before the initial stable `1.0.0` release. --- CHANGELOG.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb88d2a..1ff76e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,58 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +## [v1.0.0-rc2] - 2026-08-01 + +Second release candidate focused on Mercure hardening, JWT validation, safer +request parsing, and test cleanup before the initial stable `1.0.0` release. + +### Added + +- Added Mercure JWT standard claims for generated publisher and subscriber + tokens: `iss`, `aud`, `sub`, and unique `jti`. +- Added `MercureJwtCodec` for compact JWT splitting, base64url encoding, + unsigned JWT creation, and JSON header/payload decoding. +- Added structural validation for configured `publisherJwt` values: + - compact JWT format with three segments; + - supported HMAC `alg`; + - required and non-expired `exp`; + - required `mercure.publish` rights; + - publish selectors constrained to `topicPrefix` unless the global selector + is explicitly enabled. +- Added `allowGlobalPublisherSelector` Mercure option. The global publisher + selector `*` is now allowed only when explicitly opted in. +- Added `rejectCrossSiteBootstrap` option. Mercure authorization bootstrap + requests with `Sec-Fetch-Site: cross-site` are rejected by default before a + subscriber cookie is issued. + +### Changed + +- Changed the default Mercure publisher selector from global `*` to a scoped + selector derived from `topicPrefix`: `topicPrefix . '{channel}'`. +- Refactored Mercure JWT encoding/decoding into a dedicated codec used by both + generated JWT creation and configured publisher JWT validation. +- Refactored Mercure route tests to remove duplicated setup and share the + bootstrap response helper. + +### Security + +- Enforced a minimum 32-byte Mercure JWT HMAC signing key. +- Rejected Mercure `topicPrefix` values containing wildcard or URI-template + characters such as `{`, `}`, `*`, `?`, `[`, and `]`. +- Bounded the raw `channels` query input before parsing so oversized requests + are rejected before channel splitting/deduplication work. +- Kept unauthorized channel responses generic while logging server-side audit + metadata for denied channel authorization attempts. +- Added Fetch Metadata protection for Mercure subscriber authorization cookie + bootstrap requests. + +### Tests + +- Added and updated tests for Mercure JWT key length, standard JWT claims, JWT + codec behavior, configured publisher JWT validation, scoped publisher + selectors, `topicPrefix` safety, raw `channels` limits, and cross-site + bootstrap rejection. + ## [v1.0.0-rc] - 2026-08-01 First release candidate for CodeIgniter SSE. This version establishes the