Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<script nonce="…">`, so integrators no longer need `'unsafe-inline'`. The nonce is validated against the base64 alphabet and propagates into `script-src`
- `CspException` for an invalid nonce or an origin that is neither known nor given
- CSP section in the README and a runnable `examples/csp.php`

## [0.3.1] - 2026-04-17
### Changed
- Add theme support to ConfOptions
Expand Down
85 changes: 84 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,89 @@ class MyEnv implements EnvironmentInterface {

$client = new SnippetClient('key', 'secret', new MyEnv());
```
## Content Security Policy
If your page sends a `Content-Security-Policy`, it has to allow the sources the widget uses. For **production** these are:
```
script-src https://cdn.stromcom.cz
connect-src https://www.stromcom.cz
style-src https://cdn.stromcom.cz
img-src data:
frame-src https://app.stromcom.cz
```
| Directive | Why |
|---|---|
| `script-src` | The loader and the widget bundle are served from the CDN. |
| `connect-src` | The widget polls the notification API on the STROMCOM site. |
| `style-src` | The widget stylesheets are served from the CDN. |
| `img-src` | Built-in icons (e.g. the loading spinner) are inlined as `data:` URIs. |
| `frame-src` | The chat itself runs in an iframe on the application origin. |

**Avatars, attachments, fonts and media do not need any directive on your page** — they are loaded *inside* the iframe and are therefore covered by the policy of the application origin, not by yours.

`'unsafe-inline'` is not needed either — see [Nonce for inline scripts](#nonce-for-inline-scripts) below for the `<script>` tags this library generates.

### `CspPolicy`
Instead of copying the list around, let the library build it for the environment you use:
```php
use Stromcom\Snippet\CspPolicy;
use Stromcom\Snippet\Environment\Environment;

$policy = new CspPolicy(Environment::PRODUCTION);

header($policy->getHeaderName() . ': ' . $policy->getHeaderValue());
// Content-Security-Policy: script-src https://cdn.stromcom.cz; connect-src https://www.stromcom.cz; …

echo $policy->getMetaTag();
// <meta http-equiv="Content-Security-Policy" content="script-src https://cdn.stromcom.cz; …">
```
`getDirectives()` returns `directive => list of sources`, so you can merge STROMCOM into a policy you already have:
```php
$ownPolicy = ['default-src' => ["'self'"], 'script-src' => ["'self'"]];

foreach ($policy->getDirectives() as $directive => $sources) {
$ownPolicy[$directive] = [...$ownPolicy[$directive] ?? [], ...$sources];
}
```
`Environment::PRODUCTION` and `Environment::STAGING` know all their origins, so nothing else is needed for them. For a `CustomEnvironment` the API and application origins are **not** guessed from the loader URL — they are separate hosts, and a wrong guess would silently produce a policy that blocks the widget. Give them to the environment:
```php
$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
);

$policy = new CspPolicy($environment);
```
…or straight to the policy, which also overrides what the environment says:
```php
$policy = new CspPolicy($environment, apiUrl: 'https://example.com', applicationUrl: 'https://chat.example.com');
```
If neither provides them, `CspPolicy` throws a `CspException` naming the directive it could not build and the argument to pass.

> **Writing your own `EnvironmentInterface`?** `CspPolicy` reads the two origins from `OriginAwareEnvironmentInterface`, which extends `EnvironmentInterface` with `getApiUrl()` and `getApplicationUrl()` (both may return `null` when unknown). Either implement it as well, or pass `apiUrl` / `applicationUrl` to `CspPolicy`. Implementations of plain `EnvironmentInterface` keep working everywhere else — the extra interface only matters for CSP.

### Nonce for inline scripts
`getHTML()` emits an inline `<script>` tag. Rather than allowing `'unsafe-inline'`, pass the nonce of the current response — it is set **once** and applied to every tag the client generates:
```php
$nonce = base64_encode(random_bytes(16)); // a new value for every response

$client = SnippetClientFactory::create(
clientKey: 'key',
clientSecret: 'secret',
nonce: $nonce,
);

// The same nonce ends up in script-src
header($client->csp()->getHeaderName() . ': ' . $client->csp()->getHeaderValue());
// … script-src https://cdn.stromcom.cz 'nonce-4mB1r0EYA0lZ2Kk1J7bWpQ=='; …

echo $client->snippet()->getHTML();
// <script nonce="4mB1r0EYA0lZ2Kk1J7bWpQ==">…</script>
```
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.

Expand Down Expand Up @@ -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 `<script>…</script>`
- `->getHTML()` — wrapped in `<script>…</script>` (with a `nonce` attribute when a nonce is configured)
## Options reference
### `UserOptions`
| Parameter | Type | Required | Description |
Expand Down
78 changes: 78 additions & 0 deletions examples/csp.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use Stromcom\Snippet\CspPolicy;
use Stromcom\Snippet\Environment\CustomEnvironment;
use Stromcom\Snippet\Environment\Environment;
use Stromcom\Snippet\Exception\CspException;
use Stromcom\Snippet\SnippetClientFactory;

// A fresh nonce must be generated for every single response.
$nonce = base64_encode(random_bytes(16));

$client = SnippetClientFactory::create(
clientKey: 'your-client-key',
clientSecret: 'your-bearer-token',
nonce: $nonce, // rendered as <script nonce="…"> 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 <meta> 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";
}
181 changes: 181 additions & 0 deletions src/CspPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);

namespace Stromcom\Snippet;

use Stromcom\Snippet\Environment\Environment;
use Stromcom\Snippet\Environment\EnvironmentInterface;
use Stromcom\Snippet\Environment\OriginAwareEnvironmentInterface;
use Stromcom\Snippet\Exception\CspException;
use Stromcom\Snippet\Exception\EnvironmentException;
use Stromcom\Snippet\Internal\NonceValidator;

/**
* Content-Security-Policy directives the host page must allow for the widget to work.
*
* What the widget touches on the host page:
* - loads the loader and the widget bundle from the CDN origin (`script-src`)
* - loads the widget stylesheets from the same CDN origin (`style-src`)
* - polls the notification API on the site origin (`connect-src`)
* - renders inline SVG icons embedded as `data:` URIs (`img-src`)
* - embeds the application in an iframe (`frame-src`)
*
* Avatars, attachments, fonts and media are loaded inside that iframe and are therefore
* governed by the policy of the application origin, not by the policy of the host page.
*
* ```php
* $policy = new CspPolicy(Environment::PRODUCTION, $nonce);
* header($policy->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<string, list<string>>
*/
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(
'<meta http-equiv="%s" content="%s">',
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;
}

}
Loading