Skip to content

Commit 63c4a90

Browse files
authored
feat: Add options for passing nonces to feedback integration (#13347)
1 parent 140b81d commit 63c4a90

File tree

13 files changed

+175
-12
lines changed

13 files changed

+175
-12
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as Sentry from '@sentry/browser';
2+
// Import this separately so that generatePlugin can handle it for CDN scenarios
3+
import { feedbackIntegration } from '@sentry/browser';
4+
5+
window.Sentry = Sentry;
6+
7+
Sentry.init({
8+
dsn: 'https://[email protected]/1337',
9+
integrations: [
10+
feedbackIntegration({ tags: { from: 'integration init' }, styleNonce: 'foo1234', scriptNonce: 'foo1234' }),
11+
],
12+
});
13+
14+
document.addEventListener('securitypolicyviolation', () => {
15+
const container = document.querySelector('#csp-violation');
16+
if (container) {
17+
container.innerText = 'CSP Violation';
18+
}
19+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta
6+
http-equiv="Content-Security-Policy"
7+
content="style-src 'nonce-foo1234'; script-src sentry-test.io 'nonce-foo1234';"
8+
/>
9+
</head>
10+
<body>
11+
<div id="csp-violation" />
12+
</body>
13+
</html>
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { TEST_HOST, sentryTest } from '../../../utils/fixtures';
4+
import { envelopeRequestParser, getEnvelopeType, shouldSkipFeedbackTest } from '../../../utils/helpers';
5+
6+
sentryTest('should capture feedback', async ({ getLocalTestUrl, page }) => {
7+
if (shouldSkipFeedbackTest()) {
8+
sentryTest.skip();
9+
}
10+
11+
const feedbackRequestPromise = page.waitForResponse(res => {
12+
const req = res.request();
13+
14+
const postData = req.postData();
15+
if (!postData) {
16+
return false;
17+
}
18+
19+
try {
20+
return getEnvelopeType(req) === 'feedback';
21+
} catch (err) {
22+
return false;
23+
}
24+
});
25+
26+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
27+
return route.fulfill({
28+
status: 200,
29+
contentType: 'application/json',
30+
body: JSON.stringify({ id: 'test-id' }),
31+
});
32+
});
33+
34+
const url = await getLocalTestUrl({ testDir: __dirname });
35+
36+
await page.goto(url);
37+
await page.getByText('Report a Bug').click();
38+
expect(await page.locator(':visible:text-is("Report a Bug")').count()).toEqual(1);
39+
await page.locator('[name="name"]').fill('Jane Doe');
40+
await page.locator('[name="email"]').fill('[email protected]');
41+
await page.locator('[name="message"]').fill('my example feedback');
42+
await page.locator('[data-sentry-feedback] .btn--primary').click();
43+
44+
const feedbackEvent = envelopeRequestParser((await feedbackRequestPromise).request());
45+
expect(feedbackEvent).toEqual({
46+
type: 'feedback',
47+
breadcrumbs: expect.any(Array),
48+
contexts: {
49+
feedback: {
50+
contact_email: '[email protected]',
51+
message: 'my example feedback',
52+
name: 'Jane Doe',
53+
source: 'widget',
54+
url: `${TEST_HOST}/index.html`,
55+
},
56+
trace: {
57+
trace_id: expect.stringMatching(/\w{32}/),
58+
span_id: expect.stringMatching(/\w{16}/),
59+
},
60+
},
61+
level: 'info',
62+
tags: {
63+
from: 'integration init',
64+
},
65+
timestamp: expect.any(Number),
66+
event_id: expect.stringMatching(/\w{32}/),
67+
environment: 'production',
68+
sdk: {
69+
integrations: expect.arrayContaining(['Feedback']),
70+
version: expect.any(String),
71+
name: 'sentry.javascript.browser',
72+
packages: expect.anything(),
73+
},
74+
request: {
75+
url: `${TEST_HOST}/index.html`,
76+
headers: {
77+
'User-Agent': expect.stringContaining(''),
78+
},
79+
},
80+
platform: 'javascript',
81+
});
82+
const cspContainer = await page.locator('#csp-violation');
83+
expect(cspContainer).not.toContainText('CSP Violation');
84+
});

packages/browser/src/utils/lazyLoadIntegration.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ const WindowWithMaybeIntegration = WINDOW as {
3131
* Lazy load an integration from the CDN.
3232
* Rejects if the integration cannot be loaded.
3333
*/
34-
export async function lazyLoadIntegration(name: keyof typeof LazyLoadableIntegrations): Promise<IntegrationFn> {
34+
export async function lazyLoadIntegration(
35+
name: keyof typeof LazyLoadableIntegrations,
36+
scriptNonce?: string,
37+
): Promise<IntegrationFn> {
3538
const bundle = LazyLoadableIntegrations[name];
3639

3740
// `window.Sentry` is only set when using a CDN bundle, but this method can also be used via the NPM package
@@ -56,6 +59,10 @@ export async function lazyLoadIntegration(name: keyof typeof LazyLoadableIntegra
5659
script.crossOrigin = 'anonymous';
5760
script.referrerPolicy = 'origin';
5861

62+
if (scriptNonce) {
63+
script.setAttribute('nonce', scriptNonce);
64+
}
65+
5966
const waitForLoad = new Promise<void>((resolve, reject) => {
6067
script.addEventListener('load', () => resolve());
6168
script.addEventListener('error', reject);

packages/feedback/src/core/components/Actor.css.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { DOCUMENT } from '../../constants';
33
/**
44
* Creates <style> element for widget actor (button that opens the dialog)
55
*/
6-
export function createActorStyles(): HTMLStyleElement {
6+
export function createActorStyles(styleNonce?: string): HTMLStyleElement {
77
const style = DOCUMENT.createElement('style');
88
style.textContent = `
99
.widget__actor {
@@ -58,5 +58,9 @@ export function createActorStyles(): HTMLStyleElement {
5858
}
5959
`;
6060

61+
if (styleNonce) {
62+
style.setAttribute('nonce', styleNonce);
63+
}
64+
6165
return style;
6266
}

packages/feedback/src/core/components/Actor.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export interface ActorProps {
66
triggerLabel: string;
77
triggerAriaLabel: string;
88
shadow: ShadowRoot;
9+
styleNonce?: string;
910
}
1011

1112
export interface ActorComponent {
@@ -23,7 +24,7 @@ export interface ActorComponent {
2324
/**
2425
* The sentry-provided button to open the feedback modal
2526
*/
26-
export function Actor({ triggerLabel, triggerAriaLabel, shadow }: ActorProps): ActorComponent {
27+
export function Actor({ triggerLabel, triggerAriaLabel, shadow, styleNonce }: ActorProps): ActorComponent {
2728
const el = DOCUMENT.createElement('button');
2829
el.type = 'button';
2930
el.className = 'widget__actor';
@@ -36,7 +37,7 @@ export function Actor({ triggerLabel, triggerAriaLabel, shadow }: ActorProps): A
3637
el.appendChild(label);
3738
}
3839

39-
const style = createActorStyles();
40+
const style = createActorStyles(styleNonce);
4041

4142
return {
4243
el,

packages/feedback/src/core/createMainStyles.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,12 @@ function getThemedCssVariables(theme: InternalTheme): string {
5151
/**
5252
* Creates <style> element for widget actor (button that opens the dialog)
5353
*/
54-
export function createMainStyles({ colorScheme, themeDark, themeLight }: FeedbackInternalOptions): HTMLStyleElement {
54+
export function createMainStyles({
55+
colorScheme,
56+
themeDark,
57+
themeLight,
58+
styleNonce,
59+
}: FeedbackInternalOptions): HTMLStyleElement {
5560
const style = DOCUMENT.createElement('style');
5661
style.textContent = `
5762
:host {
@@ -86,5 +91,9 @@ ${
8691
}
8792
`;
8893

94+
if (styleNonce) {
95+
style.setAttribute('nonce', styleNonce);
96+
}
97+
8998
return style;
9099
}

packages/feedback/src/core/integration.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ type Unsubscribe = () => void;
4343
interface BuilderOptions {
4444
// The type here should be `keyof typeof LazyLoadableIntegrations`, but that'll cause a cicrular
4545
// dependency with @sentry/core
46-
lazyLoadIntegration: (name: 'feedbackModalIntegration' | 'feedbackScreenshotIntegration') => Promise<IntegrationFn>;
46+
lazyLoadIntegration: (
47+
name: 'feedbackModalIntegration' | 'feedbackScreenshotIntegration',
48+
scriptNonce?: string,
49+
) => Promise<IntegrationFn>;
4750
getModalIntegration?: null | (() => IntegrationFn);
4851
getScreenshotIntegration?: null | (() => IntegrationFn);
4952
}
@@ -77,6 +80,8 @@ export const buildFeedbackIntegration = ({
7780
name: 'username',
7881
},
7982
tags,
83+
styleNonce,
84+
scriptNonce,
8085

8186
// FeedbackThemeConfiguration
8287
colorScheme = 'system',
@@ -119,6 +124,8 @@ export const buildFeedbackIntegration = ({
119124
enableScreenshot,
120125
useSentryUser,
121126
tags,
127+
styleNonce,
128+
scriptNonce,
122129

123130
colorScheme,
124131
themeDark,
@@ -176,7 +183,7 @@ export const buildFeedbackIntegration = ({
176183
if (existing) {
177184
return existing as I;
178185
}
179-
const integrationFn = (getter && getter()) || (await lazyLoadIntegration(functionMethodName));
186+
const integrationFn = (getter && getter()) || (await lazyLoadIntegration(functionMethodName, scriptNonce));
180187
const integration = integrationFn();
181188
client && client.addIntegration(integration);
182189
return integration as I;
@@ -272,6 +279,7 @@ export const buildFeedbackIntegration = ({
272279
triggerLabel: mergedOptions.triggerLabel,
273280
triggerAriaLabel: mergedOptions.triggerAriaLabel,
274281
shadow,
282+
styleNonce,
275283
});
276284
_attachTo(actor.el, {
277285
...mergedOptions,

packages/feedback/src/modal/components/Dialog.css.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ const SUCCESS = `
273273
/**
274274
* Creates <style> element for widget dialog
275275
*/
276-
export function createDialogStyles(): HTMLStyleElement {
276+
export function createDialogStyles(styleNonce?: string): HTMLStyleElement {
277277
const style = DOCUMENT.createElement('style');
278278

279279
style.textContent = `
@@ -288,5 +288,9 @@ ${BUTTON}
288288
${SUCCESS}
289289
`;
290290

291+
if (styleNonce) {
292+
style.setAttribute('nonce', styleNonce);
293+
}
294+
291295
return style;
292296
}

packages/feedback/src/modal/integration.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export const feedbackModalIntegration = ((): FeedbackModalIntegration => {
3030
const user = getUser();
3131

3232
const el = DOCUMENT.createElement('div');
33-
const style = createDialogStyles();
33+
const style = createDialogStyles(options.styleNonce);
3434

3535
let originalOverflow = '';
3636
const dialog: ReturnType<FeedbackModalIntegration['createDialog']> = {

packages/feedback/src/screenshot/components/ScreenshotEditor.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export function ScreenshotEditorFactory({
7474
const useTakeScreenshot = useTakeScreenshotFactory({ hooks });
7575

7676
return function ScreenshotEditor({ onError }: Props): VNode {
77-
const styles = hooks.useMemo(() => ({ __html: createScreenshotInputStyles().innerText }), []);
77+
const styles = hooks.useMemo(() => ({ __html: createScreenshotInputStyles(options.styleNonce).innerText }), []);
7878
const CropCorner = CropCornerFactory({ h });
7979

8080
const canvasContainerRef = hooks.useRef<HTMLDivElement>(null);
@@ -313,7 +313,7 @@ export function ScreenshotEditorFactory({
313313

314314
return (
315315
<div class="editor">
316-
<style dangerouslySetInnerHTML={styles} />
316+
<style nonce={options.styleNonce} dangerouslySetInnerHTML={styles} />
317317
<div class="editor__canvas-container" ref={canvasContainerRef}>
318318
<div class="editor__crop-container" style={{ position: 'absolute', zIndex: 1 }} ref={cropContainerRef}>
319319
<canvas

packages/feedback/src/screenshot/components/ScreenshotInput.css.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { DOCUMENT } from '../../constants';
33
/**
44
* Creates <style> element for widget dialog
55
*/
6-
export function createScreenshotInputStyles(): HTMLStyleElement {
6+
export function createScreenshotInputStyles(styleNonce?: string): HTMLStyleElement {
77
const style = DOCUMENT.createElement('style');
88

99
const surface200 = '#1A141F';
@@ -86,5 +86,9 @@ export function createScreenshotInputStyles(): HTMLStyleElement {
8686
}
8787
`;
8888

89+
if (styleNonce) {
90+
style.setAttribute('nonce', styleNonce);
91+
}
92+
8993
return style;
9094
}

packages/types/src/feedback/config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ export interface FeedbackGeneralConfiguration {
6161
* Set an object that will be merged sent as tags data with the event.
6262
*/
6363
tags?: { [key: string]: Primitive };
64+
65+
/**
66+
* Set a nonce to be passed to the injected <style> tag for enforcing CSP
67+
*/
68+
styleNonce?: string;
69+
70+
/**
71+
* Set a nonce to be passed to the injected <script> tag for enforcing CSP
72+
*/
73+
scriptNonce?: string;
6474
}
6575

6676
/**

0 commit comments

Comments
 (0)