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);
+ }
+
+}