From 76752cfe89206f8ed4ea15e2ee81d62fa68d4bd2 Mon Sep 17 00:00:00 2001 From: stromek Date: Fri, 7 Aug 2026 13:00:35 +0200 Subject: [PATCH 1/2] Add CSP support: nonce for inline scripts and CspPolicy helper Integrators embedding the snippet had to allow 'unsafe-inline' because getHTML() rendered a bare +``` +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 +249,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..8bca402 --- /dev/null +++ b/examples/csp.php @@ -0,0 +1,57 @@ + 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"; diff --git a/src/CspPolicy.php b/src/CspPolicy.php new file mode 100644 index 0000000..081069f --- /dev/null +++ b/src/CspPolicy.php @@ -0,0 +1,214 @@ +getHeaderName() . ': ' . $policy->getHeaderValue()); + * ``` + * + * All origins are derived from the loader URL of the given environment, so a + * {@see \Stromcom\Snippet\Environment\CustomEnvironment} works as well. The derivation + * assumes the standard STROMCOM host layout — `cdn.` for static assets, `app.` + * for the application, and `` (or `www.` for a bare registrable domain) for the + * API. Pass `$apiUrl` / `$applicationUrl` explicitly for deployments that differ. + */ +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 const LABEL_CDN = 'cdn'; + private const LABEL_APPLICATION = 'app'; + private const LABEL_CANONICAL = 'www'; + + 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 Overrides the derived API origin + * @param string|null $applicationUrl Overrides the derived application (iframe) origin + * + * @throws CspException when the nonce is not a valid base64 value + * @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, + ) { + $loaderUrl = $environment->getLoaderUrl(); + + $this->cdnOrigin = self::toOrigin($loaderUrl); + $this->apiOrigin = $apiUrl === null ? self::deriveApiOrigin($loaderUrl) : self::toOrigin($apiUrl); + $this->applicationOrigin = $applicationUrl === null ? self::deriveApplicationOrigin($loaderUrl) : self::toOrigin($applicationUrl); + $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; + } + + /** + * The API lives on the site itself — `staging.stromcom.cz` for `cdn.staging.stromcom.cz`. + * A bare registrable domain uses its canonical `www` host — `www.stromcom.cz` for `cdn.stromcom.cz`. + * + * @throws EnvironmentException + */ + private static function deriveApiOrigin(string $loaderUrl): string { + $url = self::parseUrl($loaderUrl); + $labels = self::zoneLabels($url['host']); + + if (count($labels) < 2) { + return self::buildOrigin($url['scheme'], $url['host'], $url['port']); + } + + $zone = implode('.', $labels); + $host = count($labels) === 2 ? self::LABEL_CANONICAL . '.' . $zone : $zone; + + return self::buildOrigin($url['scheme'], $host, $url['port']); + } + + /** + * The application is embedded from the `app` subdomain of the zone — + * `app.stromcom.cz` for `cdn.stromcom.cz`, `app.staging.stromcom.cz` for `cdn.staging.stromcom.cz`. + * + * @throws EnvironmentException + */ + private static function deriveApplicationOrigin(string $loaderUrl): string { + $url = self::parseUrl($loaderUrl); + $labels = self::zoneLabels($url['host']); + + if (count($labels) < 2) { + return self::buildOrigin($url['scheme'], $url['host'], $url['port']); + } + + return self::buildOrigin($url['scheme'], self::LABEL_APPLICATION . '.' . implode('.', $labels), $url['port']); + } + + /** + * Host labels of the zone the loader belongs to — the leading `cdn` label is dropped. + * + * @return list + */ + private static function zoneLabels(string $host): array { + $labels = explode('.', $host); + + if (count($labels) > 1 && $labels[0] === self::LABEL_CDN) { + array_shift($labels); + } + + return $labels; + } + + /** + * @throws EnvironmentException + */ + private static function toOrigin(string $url): string { + $parsed = self::parseUrl($url); + + return self::buildOrigin($parsed['scheme'], $parsed['host'], $parsed['port']); + } + + /** + * @return array{scheme: string, host: string, port: int|null} + * + * @throws EnvironmentException + */ + private static function parseUrl(string $url): array { + $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, + )); + } + + return [ + 'scheme' => (string) $parsed['scheme'], + 'host' => (string) $parsed['host'], + 'port' => $parsed['port'] ?? null, + ]; + } + + private static function buildOrigin(string $scheme, string $host, ?int $port): string { + return $port === null ? "{$scheme}://{$host}" : "{$scheme}://{$host}:{$port}"; + } + +} diff --git a/src/Exception/CspException.php b/src/Exception/CspException.php new file mode 100644 index 0000000..8515f7f --- /dev/null +++ b/src/Exception/CspException.php @@ -0,0 +1,10 @@ +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); + } + +} From 57504f2219b36f115e25f35e660f4f4bb6d09450 Mon Sep 17 00:00:00 2001 From: stromek Date: Fri, 7 Aug 2026 13:09:17 +0200 Subject: [PATCH 2/2] Take CSP origins from the environment instead of guessing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deriving the API and application origins from the CDN hostname produced a silently wrong policy whenever the deployment did not follow the assumed host layout — the widget then breaks on a CSP block, which is miserable to debug. The layout also is not a rule: production needs www. while staging needs the bare . - OriginAwareEnvironmentInterface extends EnvironmentInterface with getApiUrl() and getApplicationUrl(); plain EnvironmentInterface implementations keep working - Environment carries the real values per case, CustomEnvironment takes them as optional constructor arguments - CspPolicy reads them from the environment or from its own apiUrl / applicationUrl arguments and throws a CspException naming the directive and the argument when neither knows them; only cdnOrigin is still read from the loader URL, which does contain it --- CHANGELOG.md | 5 +- README.md | 19 ++- examples/csp.php | 23 +++- src/CspPolicy.php | 127 +++++++----------- src/Environment/CustomEnvironment.php | 25 +++- src/Environment/Environment.php | 16 ++- .../OriginAwareEnvironmentInterface.php | 28 ++++ tests/CspPolicyTest.php | 105 ++++++++++----- tests/EnvironmentTest.php | 35 +++++ 9 files changed, 259 insertions(+), 124 deletions(-) create mode 100644 src/Environment/OriginAwareEnvironmentInterface.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f7f38b..5528c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,11 @@ ## [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`) from the environment's loader URL; exposes `getDirectives()`, `getHeaderName()`, `getHeaderValue()` and `getMetaTag()` +- `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 `