Skip to content
Merged
52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 20 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -273,6 +273,9 @@ public array $mercure = [

'publisherKey' => null,
'subscriberKey' => null,
// Defaults to topicPrefix . '{channel}'.
'publisherTopicSelectors' => null,
'allowGlobalPublisherSelector' => false,

'cookie' => [
'name' => 'mercureAuthorization',
Expand All @@ -285,6 +288,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
Expand All @@ -298,9 +306,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

Expand Down Expand Up @@ -492,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.
Expand All @@ -509,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.
29 changes: 22 additions & 7 deletions docs/mercure.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ final class Sse extends BaseSse

'publisherKey' => null,
'subscriberKey' => null,
// Defaults to topicPrefix . '{channel}'.
'publisherTopicSelectors' => null,
'allowGlobalPublisherSelector' => false,

'cookie' => [
'name' => 'mercureAuthorization',
Expand Down Expand Up @@ -272,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.
Expand Down Expand Up @@ -306,7 +314,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. |
Expand All @@ -316,18 +324,25 @@ 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. |
| `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. 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. 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
Expand Down
3 changes: 2 additions & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 88 additions & 10 deletions src/Broker/Mercure/MercureConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -41,16 +42,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(
Expand Down Expand Up @@ -91,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(
Expand All @@ -99,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.',
Expand Down Expand Up @@ -141,6 +147,78 @@ 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 (
$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) {
Expand Down
17 changes: 16 additions & 1 deletion src/Broker/Mercure/MercureConfigFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand All @@ -48,6 +49,20 @@ private static function nullableString(mixed $value): ?string
return is_string($value) && $value !== '' ? $value : null;
}

/**
* @param array<string, mixed> $mercure
*
* @return list<string>
*/
private static function publisherTopicSelectors(array $mercure): array
{
if (($mercure['publisherTopicSelectors'] ?? null) === null) {
return [(string) $mercure['topicPrefix'] . '{channel}'];
}

return self::stringList($mercure['publisherTopicSelectors']);
}

/**
* @return list<string>
*/
Expand Down
Loading