Skip to content

Commit a256da1

Browse files
JosephSamirLclaude
andcommitted
feat(context): stamp CSP nonce on injected style/script in runVariation
Mirrors the tracking-script monolith's contentSecurityPolicyNonce plumbing (public/js/tracking/src/render.ts and workflow.ts in the backend repo). Without this, runVariation's <style> and <script> elements are silently blocked on customer sites enforcing `style-src 'nonce-…'; script-src 'nonce-…'` — the visitor is bucketed as having seen the variation but the DOM mutation never lands, biasing experiment results toward "variation has no effect." Resolution order matches the tracking script's getContentSecurityPolicyNonce(): 1. Config.contentSecurityPolicyNonce (explicit, set at SDK init). 2. document.querySelector('[nonce]') — first nonced element on the page. Reads `el.nonce` IDL first, falls back to getAttribute. The IDL property persists after the browser hides the HTML attribute post-connection, so this works for the bootstrap <script> the server already stamped. Cached per-Context so the DOM scan happens at most once. Applied via setAttribute('nonce', value) before appendChild in both _injectStyle and _executeScript — the DOM-API equivalent of the monolith's `nonce="${value}"` HTML-string injection. Tests: three new Playwright cases covering the configured path, the DOM-auto-detect path, and the no-nonce path (verifies no spurious empty `nonce=""` attribute is added when there's nothing to stamp, which would itself break some strict CSPs). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 41d77ad commit a256da1

3 files changed

Lines changed: 173 additions & 0 deletions

File tree

packages/js-sdk/src/context.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ export class Context implements ContextInterface {
5656
private _visitorId: string;
5757
private _visitorProperties: Record<string, any>;
5858
private _environment: string;
59+
private _contentSecurityPolicyNonce?: string;
60+
// `undefined` = not yet resolved; once resolved (either from config or
61+
// DOM auto-detect) we cache to avoid re-querying the DOM on every change.
62+
private _cspNonceResolved: boolean = false;
5963

6064
/**
6165
* @param {Config} config
@@ -94,6 +98,7 @@ export class Context implements ContextInterface {
9498
this._visitorId = visitorId;
9599

96100
this._config = config;
101+
this._contentSecurityPolicyNonce = config?.contentSecurityPolicyNonce;
97102
this._eventManager = eventManager;
98103
this._experienceManager = experienceManager;
99104
this._featureManager = featureManager;
@@ -515,6 +520,45 @@ export class Context implements ContextInterface {
515520
}
516521
}
517522

523+
/**
524+
* Resolve the CSP nonce to stamp on injected <style>/<script> elements.
525+
* Mirrors the tracking-script monolith's `getContentSecurityPolicyNonce()`
526+
* (workflow.ts in the backend repo):
527+
* 1. Prefer the explicit `Config.contentSecurityPolicyNonce` value
528+
* captured at construction.
529+
* 2. Otherwise scan the live DOM for any element carrying a `nonce`
530+
* attribute — read the IDL property first (it persists after the
531+
* browser hides the HTML attribute post-connection), fall back to
532+
* `getAttribute('nonce')` for non-script/style elements where the
533+
* attribute is still visible.
534+
* Cached after first call so the DOM scan happens at most once per
535+
* Context instance.
536+
* @private
537+
*/
538+
private _getCspNonce(): string | undefined {
539+
if (this._cspNonceResolved) return this._contentSecurityPolicyNonce;
540+
this._cspNonceResolved = true;
541+
if (this._contentSecurityPolicyNonce) {
542+
return this._contentSecurityPolicyNonce;
543+
}
544+
if (typeof document === 'undefined') return undefined;
545+
try {
546+
const nonceElement = document.querySelector('[nonce]');
547+
if (nonceElement) {
548+
this._contentSecurityPolicyNonce =
549+
(nonceElement as HTMLElement & {nonce?: string}).nonce ||
550+
nonceElement.getAttribute('nonce') ||
551+
undefined;
552+
}
553+
} catch (error) {
554+
this._loggerManager?.error?.(
555+
'Context.runVariation()',
556+
`Error reading nonce from DOM: ${(error as Error)?.message}`
557+
);
558+
}
559+
return this._contentSecurityPolicyNonce;
560+
}
561+
518562
/**
519563
* Inject a <style> tag identified by markerId. No-op if already injected.
520564
* @private
@@ -525,6 +569,8 @@ export class Context implements ContextInterface {
525569
const style = document.createElement('style');
526570
style.id = markerId;
527571
style.setAttribute('type', 'text/css');
572+
const nonce = this._getCspNonce();
573+
if (nonce) style.setAttribute('nonce', nonce);
528574
style.appendChild(document.createTextNode(css));
529575
(document.head || document.documentElement).appendChild(style);
530576
} catch (error) {
@@ -547,6 +593,8 @@ export class Context implements ContextInterface {
547593
const script = document.createElement('script');
548594
script.id = markerId;
549595
script.setAttribute('type', 'text/javascript');
596+
const nonce = this._getCspNonce();
597+
if (nonce) script.setAttribute('nonce', nonce);
550598
script.appendChild(document.createTextNode(code));
551599
(document.head || document.documentElement).appendChild(script);
552600
} catch (error) {

packages/js-sdk/tests/browser/umd-bundle.spec.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,114 @@ test.describe('UMD bundle browser tests', () => {
709709
expect(result.errored).toBe(false);
710710
expect(result.cssApplied).toBe(true);
711711
});
712+
713+
test('Should stamp Config.contentSecurityPolicyNonce on injected style and script', async ({
714+
page
715+
}) => {
716+
// CSP parity with the tracking-script monolith: every injected
717+
// <style> / <script> must carry the configured nonce so customer
718+
// sites enforcing `style-src 'nonce-…'; script-src 'nonce-…'` accept
719+
// the elements instead of blocking them as CSP violations.
720+
await setup(page);
721+
await resetDomMarkers(page);
722+
const result = await page.evaluate((fixture) => {
723+
const context = (window as any).__defaultContext({
724+
contentSecurityPolicyNonce: 'unit-test-nonce-abc'
725+
});
726+
context.runVariation(fixture.variation, {
727+
experience: fixture.experience
728+
});
729+
const read = (id: string) =>
730+
document.getElementById(id)?.getAttribute('nonce');
731+
return {
732+
globalCssNonce: read('conv-exp-rv-exp-1-global-css'),
733+
globalJsNonce: read('conv-exp-rv-exp-1-global-js'),
734+
changeCssNonce: read('conv-chg-rv-exp-1-999-101-css'),
735+
changeJsNonce: read('conv-chg-rv-exp-1-999-101-js'),
736+
customCssNonce: read('conv-chg-rv-exp-1-999-102-css'),
737+
customJsNonce: read('conv-chg-rv-exp-1-999-102-custom-js')
738+
};
739+
}, makeFixture());
740+
expect(result.globalCssNonce).toBe('unit-test-nonce-abc');
741+
expect(result.globalJsNonce).toBe('unit-test-nonce-abc');
742+
expect(result.changeCssNonce).toBe('unit-test-nonce-abc');
743+
expect(result.changeJsNonce).toBe('unit-test-nonce-abc');
744+
expect(result.customCssNonce).toBe('unit-test-nonce-abc');
745+
expect(result.customJsNonce).toBe('unit-test-nonce-abc');
746+
});
747+
748+
test('Should auto-detect CSP nonce from the DOM when not configured', async ({
749+
page
750+
}) => {
751+
// Mirrors the tracking-script's getContentSecurityPolicyNonce():
752+
// when Config.contentSecurityPolicyNonce is absent, scan the DOM for
753+
// any element carrying a `nonce` attribute and reuse that value.
754+
// Most server-rendered pages with a real CSP already have one such
755+
// element (typically the bootstrap inline <script>), so the SDK
756+
// works on CSP-strict sites without explicit configuration.
757+
await setup(page);
758+
await resetDomMarkers(page);
759+
const result = await page.evaluate((fixture) => {
760+
// Stamp a nonce on an existing inline element so the DOM scan
761+
// picks it up. Reading via IDL first matches the tracking
762+
// script's `nonceElement['nonce'] || getAttribute('nonce')`.
763+
const seedScript = document.createElement('script');
764+
seedScript.id = 'csp-seed-script';
765+
seedScript.setAttribute('nonce', 'dom-detected-nonce-xyz');
766+
document.head.appendChild(seedScript);
767+
768+
const context = (window as any).__defaultContext();
769+
context.runVariation(fixture.variation, {
770+
experience: fixture.experience
771+
});
772+
773+
const read = (id: string) =>
774+
document.getElementById(id)?.getAttribute('nonce');
775+
const out = {
776+
globalCssNonce: read('conv-exp-rv-exp-1-global-css'),
777+
changeCssNonce: read('conv-chg-rv-exp-1-999-101-css'),
778+
customJsNonce: read('conv-chg-rv-exp-1-999-102-custom-js')
779+
};
780+
seedScript.remove();
781+
return out;
782+
}, makeFixture());
783+
expect(result.globalCssNonce).toBe('dom-detected-nonce-xyz');
784+
expect(result.changeCssNonce).toBe('dom-detected-nonce-xyz');
785+
expect(result.customJsNonce).toBe('dom-detected-nonce-xyz');
786+
});
787+
788+
test('Should not stamp a nonce attribute when none is configured or in DOM', async ({
789+
page
790+
}) => {
791+
// Preserves the previous behavior (no nonce attribute at all) for
792+
// sites without a CSP — adding an empty `nonce=""` would invalidate
793+
// some strict CSPs that require a specific nonce value.
794+
await setup(page);
795+
await resetDomMarkers(page);
796+
const result = await page.evaluate((fixture) => {
797+
// Defensive: drop any pre-existing nonced elements so the DOM
798+
// auto-detect doesn't pick up something the test page injected.
799+
document
800+
.querySelectorAll('[nonce]')
801+
.forEach((el) => el.removeAttribute('nonce'));
802+
const context = (window as any).__defaultContext();
803+
context.runVariation(fixture.variation, {
804+
experience: fixture.experience
805+
});
806+
const hasNonce = (id: string) =>
807+
document.getElementById(id)?.hasAttribute('nonce') ?? null;
808+
return {
809+
globalCss: hasNonce('conv-exp-rv-exp-1-global-css'),
810+
globalJs: hasNonce('conv-exp-rv-exp-1-global-js'),
811+
changeCss: hasNonce('conv-chg-rv-exp-1-999-101-css'),
812+
changeJs: hasNonce('conv-chg-rv-exp-1-999-101-js')
813+
};
814+
}, makeFixture());
815+
expect(result.globalCss).toBe(false);
816+
expect(result.globalJs).toBe(false);
817+
expect(result.changeCss).toBe(false);
818+
expect(result.changeJs).toBe(false);
819+
});
712820
});
713821

714822
test.describe('Test invalid visitor', () => {

packages/types/src/Config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,23 @@ type ConfigBase = {
4040
* contract.
4141
*/
4242
ruleDataProvider?: RuleDataProvider;
43+
/**
44+
* Optional CSP nonce stamped on `<style>` and `<script>` elements
45+
* injected by `Context.runVariation` (web variation rendering).
46+
* Mirrors the tracking-script monolith's `state.contentSecurityPolicyNonce`
47+
* (see `public/js/tracking/src/render.ts` and `workflow.ts` in the
48+
* backend repo) so customer sites that enforce
49+
* `script-src 'nonce-…'; style-src 'nonce-…'` accept the injected
50+
* elements instead of blocking them as CSP violations.
51+
*
52+
* When omitted, `runVariation` falls back to reading the nonce from
53+
* the live DOM (`document.querySelector('[nonce]')`) at first use,
54+
* matching the tracking script's `getContentSecurityPolicyNonce()`
55+
* heuristic — most server-rendered pages already carry a nonce on at
56+
* least one inline `<script>` or `<style>`, so auto-detection covers
57+
* the common case without requiring explicit configuration.
58+
*/
59+
contentSecurityPolicyNonce?: string;
4360
dataRefreshInterval?: number;
4461
events?: {
4562
batch_size?: number;

0 commit comments

Comments
 (0)