diff --git a/CHANGELOG.md b/CHANGELOG.md index b7d744c..5528c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [0.3.4] - 2026-08-07 +### Added +- `CspPolicy` — builds the Content-Security-Policy directives the host page needs (`script-src`, `connect-src`, `style-src`, `img-src`, `frame-src`); exposes `getDirectives()`, `getHeaderName()`, `getHeaderValue()` and `getMetaTag()`. Only the CDN origin comes from the loader URL; the API and application origins are taken from the environment or from the `apiUrl` / `applicationUrl` arguments, and an unknown origin throws a `CspException` instead of producing a guessed policy that would silently block the widget +- `OriginAwareEnvironmentInterface` — extends `EnvironmentInterface` with `getApiUrl()` and `getApplicationUrl()`. Implemented by `Environment` (real values per case) and by `CustomEnvironment`, which accepts both as optional constructor arguments. Existing `EnvironmentInterface` implementations are unaffected +- `SnippetClient::csp()` — policy pre-filled with the client's environment and nonce +- `nonce` parameter on `SnippetClient`, `SnippetClientFactory::create()` and `SnippetCode` — `getHTML()` renders ` +``` +The nonce must be a non-empty base64 value (`[A-Za-z0-9+/=_-]`); anything else throws a `CspException`. `getCode()` is unaffected — it still returns the raw JavaScript. Without a nonce the output is unchanged, so upgrading changes nothing for existing integrations. + +A runnable version of these examples is in [`examples/csp.php`](examples/csp.php). + ## Code hashing User and thread `code` values should be hard to guess. Instead of hashing IDs manually, enable automatic hashing and every `user()` / `thread()` call will HMAC-hash the code for you. @@ -175,7 +258,7 @@ $client = new SnippetClient('key', 'secret', codeHasher: new MyCustomHasher()); | `home(string $selector)` | Embeds the notification center into a DOM element. | All methods return a `SnippetCode` object with: - `->getCode()` — raw JavaScript string -- `->getHTML()` — wrapped in `` +- `->getHTML()` — wrapped in `` (with a `nonce` attribute when a nonce is configured) ## Options reference ### `UserOptions` | Parameter | Type | Required | Description | diff --git a/examples/csp.php b/examples/csp.php new file mode 100644 index 0000000..89f2394 --- /dev/null +++ b/examples/csp.php @@ -0,0 +1,78 @@ + on every generated tag +); + + +/** 1. Send the policy as a header */ +// $client->csp() reuses the client's environment and nonce. +$policy = $client->csp(); + +// header($policy->getHeaderName() . ': ' . $policy->getHeaderValue()); +echo $policy->getHeaderName() . ': ' . $policy->getHeaderValue() . "\n\n"; + + +/** 2. …or render it as a tag */ +echo $policy->getMetaTag() . "\n\n"; + + +/** 3. Merge the directives into an existing policy */ +// getDirectives() returns "directive => list of sources", so you can append the +// STROMCOM sources to whatever your application already allows. +$ownPolicy = [ + 'default-src' => ["'self'"], + 'script-src' => ["'self'"], + 'connect-src' => ["'self'"], +]; + +foreach ($policy->getDirectives() as $directive => $sources) { + $ownPolicy[$directive] = array_values(array_unique([...$ownPolicy[$directive] ?? [], ...$sources])); +} + +foreach ($ownPolicy as $directive => $sources) { + echo $directive . ' ' . implode(' ', $sources) . ";\n"; +} +echo "\n"; + + +/** 4. The snippet carries the nonce */ +echo $client->snippet()->getHTML() . "\n\n"; + + +/** 5. A policy without a client */ +// Useful when the policy is built somewhere else than the snippet (middleware, edge config…). +echo (new CspPolicy(Environment::STAGING))->getHeaderValue() . "\n\n"; + + +/** 6. A custom environment has to state its origins */ +// Only the CDN origin can be read from the loader URL; the API and the application run on +// separate hosts, so they are never guessed from it. +$environment = new CustomEnvironment( + 'https://cdn.example.com/loader.js', + 'https://example.com', // API — the origin the widget polls + 'https://chat.example.com', // application — the origin of the iframe +); + +echo (new CspPolicy($environment))->getHeaderValue() . "\n\n"; + +// Without them the policy fails loudly instead of silently blocking the widget at runtime. +try { + new CspPolicy(new CustomEnvironment('https://cdn.example.com/loader.js')); +} catch (CspException $Exception) { + echo $Exception->getMessage() . "\n"; +} diff --git a/src/CspPolicy.php b/src/CspPolicy.php new file mode 100644 index 0000000..4d08015 --- /dev/null +++ b/src/CspPolicy.php @@ -0,0 +1,181 @@ +getHeaderName() . ': ' . $policy->getHeaderValue()); + * ``` + * + * The CDN origin comes from the loader URL, which contains it. The API and application + * origins are separate hosts and are never guessed — they are read from an + * {@see OriginAwareEnvironmentInterface} or passed as `$apiUrl` / `$applicationUrl`. + * When neither provides them, a {@see CspException} is thrown rather than a policy that + * silently blocks the widget. + */ +class CspPolicy { + + public const HEADER_NAME = 'Content-Security-Policy'; + + public const DIRECTIVE_SCRIPT_SRC = 'script-src'; + public const DIRECTIVE_CONNECT_SRC = 'connect-src'; + public const DIRECTIVE_STYLE_SRC = 'style-src'; + public const DIRECTIVE_IMG_SRC = 'img-src'; + public const DIRECTIVE_FRAME_SRC = 'frame-src'; + + private const SOURCE_DATA_URI = 'data:'; + + private string $cdnOrigin; + private string $apiOrigin; + private string $applicationOrigin; + private ?string $nonce; + + /** + * @param EnvironmentInterface $environment Target environment (default: production) + * @param string|null $nonce CSP nonce of the page; when set it is added to `script-src` + * @param string|null $apiUrl API origin; required unless the environment provides it + * @param string|null $applicationUrl Application (iframe) origin; required unless the environment provides it + * + * @throws CspException when the nonce is invalid or an origin is neither known nor given + * @throws EnvironmentException when an URL cannot be reduced to an origin + */ + public function __construct( + EnvironmentInterface $environment = Environment::PRODUCTION, + ?string $nonce = null, + ?string $apiUrl = null, + ?string $applicationUrl = null, + ) { + $this->cdnOrigin = self::toOrigin($environment->getLoaderUrl()); + + $this->apiOrigin = self::toOrigin(self::resolveUrl( + $apiUrl ?? self::apiUrlOf($environment), + $environment, + 'apiUrl', + self::DIRECTIVE_CONNECT_SRC, + )); + + $this->applicationOrigin = self::toOrigin(self::resolveUrl( + $applicationUrl ?? self::applicationUrlOf($environment), + $environment, + 'applicationUrl', + self::DIRECTIVE_FRAME_SRC, + )); + + $this->nonce = NonceValidator::validate($nonce); + } + + /** + * Directive name => list of sources, so the integrator can merge them into an existing policy. + * + * @return array> + */ + public function getDirectives(): array { + $scriptSources = [$this->cdnOrigin]; + + if ($this->nonce !== null) { + $scriptSources[] = "'nonce-{$this->nonce}'"; + } + + return [ + self::DIRECTIVE_SCRIPT_SRC => $scriptSources, + self::DIRECTIVE_CONNECT_SRC => [$this->apiOrigin], + self::DIRECTIVE_STYLE_SRC => [$this->cdnOrigin], + self::DIRECTIVE_IMG_SRC => [self::SOURCE_DATA_URI], + self::DIRECTIVE_FRAME_SRC => [$this->applicationOrigin], + ]; + } + + public function getHeaderName(): string { + return self::HEADER_NAME; + } + + public function getHeaderValue(): string { + $directives = []; + + foreach ($this->getDirectives() as $directive => $sources) { + $directives[] = $directive . ' ' . implode(' ', $sources); + } + + return implode('; ', $directives); + } + + public function getMetaTag(): string { + return sprintf( + '', + self::HEADER_NAME, + htmlspecialchars($this->getHeaderValue(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), + ); + } + + public function getNonce(): ?string { + return $this->nonce; + } + + private static function apiUrlOf(EnvironmentInterface $environment): ?string { + return $environment instanceof OriginAwareEnvironmentInterface ? $environment->getApiUrl() : null; + } + + private static function applicationUrlOf(EnvironmentInterface $environment): ?string { + return $environment instanceof OriginAwareEnvironmentInterface ? $environment->getApplicationUrl() : null; + } + + /** + * @throws CspException + */ + private static function resolveUrl(?string $url, EnvironmentInterface $environment, string $parameterName, string $directive): string { + if ($url !== null) { + return $url; + } + + throw new CspException(sprintf( + 'Cannot build the "%s" directive: environment %s does not provide the URL. ' + . 'Pass it as the "%s" argument of %s, or make the environment implement %s. ' + . 'The origin is deliberately not guessed from the loader URL — a wrong guess would silently block the widget.', + $directive, + get_debug_type($environment), + $parameterName, + self::class, + OriginAwareEnvironmentInterface::class, + )); + } + + /** + * @throws EnvironmentException + */ + private static function toOrigin(string $url): string { + $parsed = parse_url($url); + + if ($parsed === false || ($parsed['scheme'] ?? '') === '' || ($parsed['host'] ?? '') === '') { + throw new EnvironmentException(sprintf( + 'Cannot derive a CSP source from "%s". An absolute URL including scheme and host is required.', + $url, + )); + } + + $origin = "{$parsed['scheme']}://{$parsed['host']}"; + + return isset($parsed['port']) ? "{$origin}:{$parsed['port']}" : $origin; + } + +} diff --git a/src/Environment/CustomEnvironment.php b/src/Environment/CustomEnvironment.php index 1459a4a..7823f0f 100644 --- a/src/Environment/CustomEnvironment.php +++ b/src/Environment/CustomEnvironment.php @@ -9,14 +9,35 @@ * Example: * new CustomEnvironment('http://localhost:8082/loader.js') * new CustomEnvironment('https://cdn.your-custom-domain.com/loader.js') + * + * The API and application origins are only needed to build a {@see \Stromcom\Snippet\CspPolicy}; + * pass them when you want the policy to be generated for this environment: + * new CustomEnvironment('https://cdn.example.com/loader.js', 'https://example.com', 'https://chat.example.com') */ -class CustomEnvironment implements EnvironmentInterface { +class CustomEnvironment implements OriginAwareEnvironmentInterface { - public function __construct(private string $loaderUrl) { + /** + * @param string $loaderUrl URL of the loader script + * @param string|null $apiUrl Origin the widget polls for notifications + * @param string|null $applicationUrl Origin the widget iframe is embedded from + */ + public function __construct( + private string $loaderUrl, + private ?string $apiUrl = null, + private ?string $applicationUrl = null, + ) { } public function getLoaderUrl(): string { return $this->loaderUrl; } + public function getApiUrl(): ?string { + return $this->apiUrl; + } + + public function getApplicationUrl(): ?string { + return $this->applicationUrl; + } + } diff --git a/src/Environment/Environment.php b/src/Environment/Environment.php index 2ac5ad9..a095c9b 100644 --- a/src/Environment/Environment.php +++ b/src/Environment/Environment.php @@ -3,7 +3,7 @@ namespace Stromcom\Snippet\Environment; -enum Environment: string implements EnvironmentInterface { +enum Environment: string implements OriginAwareEnvironmentInterface { case PRODUCTION = 'https://cdn.stromcom.cz/loader.js'; case STAGING = 'https://cdn.staging.stromcom.cz/loader.js'; @@ -12,4 +12,18 @@ public function getLoaderUrl(): string { return $this->value; } + public function getApiUrl(): string { + return match ($this) { + self::PRODUCTION => 'https://www.stromcom.cz', + self::STAGING => 'https://staging.stromcom.cz', + }; + } + + public function getApplicationUrl(): string { + return match ($this) { + self::PRODUCTION => 'https://app.stromcom.cz', + self::STAGING => 'https://app.staging.stromcom.cz', + }; + } + } diff --git a/src/Environment/OriginAwareEnvironmentInterface.php b/src/Environment/OriginAwareEnvironmentInterface.php new file mode 100644 index 0000000..6593e5f --- /dev/null +++ b/src/Environment/OriginAwareEnvironmentInterface.php @@ -0,0 +1,28 @@ +dataLayer = $dataLayer ?? $this->dataLayer; $this->withDocs = $withDocs; + $this->nonce = $nonce; } public function generateSnippet(string $loaderUrl, string $clientKey, string $clientSecret): SnippetCode { @@ -37,13 +39,13 @@ public function generateSnippet(string $loaderUrl, string $clientKey, string $cl $secret = $this->jsonEncode($clientSecret); return new SnippetCode(<<nonce); } catch (JsonEncodingException $Exception) { throw new SnippetGenerationException('Failed to generate snippet code.', 0, $Exception); } @@ -71,7 +73,7 @@ public function generateConf(ConfOptions $options, ?bool $withDocs = null): Snip return new SnippetCode(<<dataLayer}.conf({$json}); - JS); + JS, $this->nonce); } catch (JsonEncodingException $Exception) { throw new ConfGenerationException('Failed to generate conf code.', 0, $Exception); } @@ -83,7 +85,7 @@ public function generateUser(UserOptions $options, ?bool $withDocs = null): Snip return new SnippetCode(<<dataLayer}.initUser({$json}); - JS); + JS, $this->nonce); } catch (JsonEncodingException $Exception) { throw new UserGenerationException('Failed to generate user code.', 0, $Exception); } @@ -96,7 +98,7 @@ public function generateThread(string $querySelector, ThreadOptions $options, ?b return new SnippetCode(<<dataLayer}.thread(document.querySelector({$selector}), {$json}); - JS); + JS, $this->nonce); } catch (JsonEncodingException $Exception) { throw new ThreadGenerationException('Failed to generate thread code.', 0, $Exception); } @@ -108,7 +110,7 @@ public function generateHome(string $querySelector): SnippetCode { return new SnippetCode(<<dataLayer}.home(document.querySelector({$selector})); - JS); + JS, $this->nonce); } catch (JsonEncodingException $Exception) { throw new HomeGenerationException('Failed to generate home code.', 0, $Exception); } diff --git a/src/Internal/NonceValidator.php b/src/Internal/NonceValidator.php new file mode 100644 index 0000000..11145e6 --- /dev/null +++ b/src/Internal/NonceValidator.php @@ -0,0 +1,39 @@ + tag is rendered with it + * + * @throws CspException when the nonce is not a valid base64 value */ public function __construct( private string $clientKey, @@ -36,8 +41,18 @@ public function __construct( ?string $dataLayer = null, bool $withDocs = false, private ?CodeHasherInterface $codeHasher = null, + private ?string $nonce = null, ) { - $this->generator = new Generator($dataLayer, $withDocs); + $this->nonce = NonceValidator::validate($nonce); + $this->generator = new Generator($dataLayer, $withDocs, $this->nonce); + } + + /** + * Content-Security-Policy the host page needs for the widget, pre-filled with this + * client's environment and nonce. + */ + public function csp(): CspPolicy { + return new CspPolicy($this->environment, $this->nonce); } /** diff --git a/src/SnippetClientFactory.php b/src/SnippetClientFactory.php index e4bcbf2..208fb9b 100644 --- a/src/SnippetClientFactory.php +++ b/src/SnippetClientFactory.php @@ -5,6 +5,7 @@ use Stromcom\Snippet\Environment\Environment; use Stromcom\Snippet\Environment\EnvironmentInterface; +use Stromcom\Snippet\Exception\CspException; use Stromcom\Snippet\Hashing\Base62CodeHasher; use Stromcom\Snippet\Hashing\CodeHasherInterface; use Stromcom\Snippet\Hashing\HashAlgorithm; @@ -29,6 +30,9 @@ class SnippetClientFactory { * @param EnvironmentInterface $environment Target environment (default: production) * @param string|null $dataLayer Custom JS data-layer name (default: "stromCom") * @param bool $withDocs Output annotated code with inline JSDoc comments + * @param string|null $nonce CSP nonce of the page; every generated ", $code->getHTML()); + $this->assertNull($code->getNonce()); + } + + #[Test] + public function html_with_nonce_renders_the_nonce_attribute(): void { + $code = new SnippetCode(self::CODE, self::NONCE); + + $this->assertSame('", $code->getHTML()); + $this->assertSame(self::NONCE, $code->getNonce()); + } + + #[Test] + public function raw_code_is_not_affected_by_the_nonce(): void { + $this->assertSame(self::CODE, (new SnippetCode(self::CODE, self::NONCE))->getCode()); + } + + #[Test] + #[TestWith([''])] + #[TestWith(['nonce with space'])] + #[TestWith(['">'])] + #[TestWith(["nonce'value"])] + public function invalid_nonce_is_rejected(string $nonce): void { + $this->expectException(CspException::class); + + new SnippetCode(self::CODE, $nonce); + } + +}