Skip to content

Commit

Permalink
feat: Add options for passing nonces to feedback integration (#13347)
Browse files Browse the repository at this point in the history
  • Loading branch information
chargome authored Aug 14, 2024
1 parent 140b81d commit 63c4a90
Show file tree
Hide file tree
Showing 13 changed files with 175 additions and 12 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as Sentry from '@sentry/browser';
// Import this separately so that generatePlugin can handle it for CDN scenarios
import { feedbackIntegration } from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://[email protected]/1337',
integrations: [
feedbackIntegration({ tags: { from: 'integration init' }, styleNonce: 'foo1234', scriptNonce: 'foo1234' }),
],
});

document.addEventListener('securitypolicyviolation', () => {
const container = document.querySelector('#csp-violation');
if (container) {
container.innerText = 'CSP Violation';
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="style-src 'nonce-foo1234'; script-src sentry-test.io 'nonce-foo1234';"
/>
</head>
<body>
<div id="csp-violation" />
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { expect } from '@playwright/test';

import { TEST_HOST, sentryTest } from '../../../utils/fixtures';
import { envelopeRequestParser, getEnvelopeType, shouldSkipFeedbackTest } from '../../../utils/helpers';

sentryTest('should capture feedback', async ({ getLocalTestUrl, page }) => {
if (shouldSkipFeedbackTest()) {
sentryTest.skip();
}

const feedbackRequestPromise = page.waitForResponse(res => {
const req = res.request();

const postData = req.postData();
if (!postData) {
return false;
}

try {
return getEnvelopeType(req) === 'feedback';
} catch (err) {
return false;
}
});

await page.route('https://dsn.ingest.sentry.io/**/*', route => {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'test-id' }),
});
});

const url = await getLocalTestUrl({ testDir: __dirname });

await page.goto(url);
await page.getByText('Report a Bug').click();
expect(await page.locator(':visible:text-is("Report a Bug")').count()).toEqual(1);
await page.locator('[name="name"]').fill('Jane Doe');
await page.locator('[name="email"]').fill('[email protected]');
await page.locator('[name="message"]').fill('my example feedback');
await page.locator('[data-sentry-feedback] .btn--primary').click();

const feedbackEvent = envelopeRequestParser((await feedbackRequestPromise).request());
expect(feedbackEvent).toEqual({
type: 'feedback',
breadcrumbs: expect.any(Array),
contexts: {
feedback: {
contact_email: '[email protected]',
message: 'my example feedback',
name: 'Jane Doe',
source: 'widget',
url: `${TEST_HOST}/index.html`,
},
trace: {
trace_id: expect.stringMatching(/\w{32}/),
span_id: expect.stringMatching(/\w{16}/),
},
},
level: 'info',
tags: {
from: 'integration init',
},
timestamp: expect.any(Number),
event_id: expect.stringMatching(/\w{32}/),
environment: 'production',
sdk: {
integrations: expect.arrayContaining(['Feedback']),
version: expect.any(String),
name: 'sentry.javascript.browser',
packages: expect.anything(),
},
request: {
url: `${TEST_HOST}/index.html`,
headers: {
'User-Agent': expect.stringContaining(''),
},
},
platform: 'javascript',
});
const cspContainer = await page.locator('#csp-violation');
expect(cspContainer).not.toContainText('CSP Violation');
});
9 changes: 8 additions & 1 deletion packages/browser/src/utils/lazyLoadIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ const WindowWithMaybeIntegration = WINDOW as {
* Lazy load an integration from the CDN.
* Rejects if the integration cannot be loaded.
*/
export async function lazyLoadIntegration(name: keyof typeof LazyLoadableIntegrations): Promise<IntegrationFn> {
export async function lazyLoadIntegration(
name: keyof typeof LazyLoadableIntegrations,
scriptNonce?: string,
): Promise<IntegrationFn> {
const bundle = LazyLoadableIntegrations[name];

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

if (scriptNonce) {
script.setAttribute('nonce', scriptNonce);
}

const waitForLoad = new Promise<void>((resolve, reject) => {
script.addEventListener('load', () => resolve());
script.addEventListener('error', reject);
Expand Down
6 changes: 5 additions & 1 deletion packages/feedback/src/core/components/Actor.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { DOCUMENT } from '../../constants';
/**
* Creates <style> element for widget actor (button that opens the dialog)
*/
export function createActorStyles(): HTMLStyleElement {
export function createActorStyles(styleNonce?: string): HTMLStyleElement {
const style = DOCUMENT.createElement('style');
style.textContent = `
.widget__actor {
Expand Down Expand Up @@ -58,5 +58,9 @@ export function createActorStyles(): HTMLStyleElement {
}
`;

if (styleNonce) {
style.setAttribute('nonce', styleNonce);
}

return style;
}
5 changes: 3 additions & 2 deletions packages/feedback/src/core/components/Actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface ActorProps {
triggerLabel: string;
triggerAriaLabel: string;
shadow: ShadowRoot;
styleNonce?: string;
}

export interface ActorComponent {
Expand All @@ -23,7 +24,7 @@ export interface ActorComponent {
/**
* The sentry-provided button to open the feedback modal
*/
export function Actor({ triggerLabel, triggerAriaLabel, shadow }: ActorProps): ActorComponent {
export function Actor({ triggerLabel, triggerAriaLabel, shadow, styleNonce }: ActorProps): ActorComponent {
const el = DOCUMENT.createElement('button');
el.type = 'button';
el.className = 'widget__actor';
Expand All @@ -36,7 +37,7 @@ export function Actor({ triggerLabel, triggerAriaLabel, shadow }: ActorProps): A
el.appendChild(label);
}

const style = createActorStyles();
const style = createActorStyles(styleNonce);

return {
el,
Expand Down
11 changes: 10 additions & 1 deletion packages/feedback/src/core/createMainStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,12 @@ function getThemedCssVariables(theme: InternalTheme): string {
/**
* Creates <style> element for widget actor (button that opens the dialog)
*/
export function createMainStyles({ colorScheme, themeDark, themeLight }: FeedbackInternalOptions): HTMLStyleElement {
export function createMainStyles({
colorScheme,
themeDark,
themeLight,
styleNonce,
}: FeedbackInternalOptions): HTMLStyleElement {
const style = DOCUMENT.createElement('style');
style.textContent = `
:host {
Expand Down Expand Up @@ -86,5 +91,9 @@ ${
}
`;

if (styleNonce) {
style.setAttribute('nonce', styleNonce);
}

return style;
}
12 changes: 10 additions & 2 deletions packages/feedback/src/core/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ type Unsubscribe = () => void;
interface BuilderOptions {
// The type here should be `keyof typeof LazyLoadableIntegrations`, but that'll cause a cicrular
// dependency with @sentry/core
lazyLoadIntegration: (name: 'feedbackModalIntegration' | 'feedbackScreenshotIntegration') => Promise<IntegrationFn>;
lazyLoadIntegration: (
name: 'feedbackModalIntegration' | 'feedbackScreenshotIntegration',
scriptNonce?: string,
) => Promise<IntegrationFn>;
getModalIntegration?: null | (() => IntegrationFn);
getScreenshotIntegration?: null | (() => IntegrationFn);
}
Expand Down Expand Up @@ -77,6 +80,8 @@ export const buildFeedbackIntegration = ({
name: 'username',
},
tags,
styleNonce,
scriptNonce,

// FeedbackThemeConfiguration
colorScheme = 'system',
Expand Down Expand Up @@ -119,6 +124,8 @@ export const buildFeedbackIntegration = ({
enableScreenshot,
useSentryUser,
tags,
styleNonce,
scriptNonce,

colorScheme,
themeDark,
Expand Down Expand Up @@ -176,7 +183,7 @@ export const buildFeedbackIntegration = ({
if (existing) {
return existing as I;
}
const integrationFn = (getter && getter()) || (await lazyLoadIntegration(functionMethodName));
const integrationFn = (getter && getter()) || (await lazyLoadIntegration(functionMethodName, scriptNonce));
const integration = integrationFn();
client && client.addIntegration(integration);
return integration as I;
Expand Down Expand Up @@ -272,6 +279,7 @@ export const buildFeedbackIntegration = ({
triggerLabel: mergedOptions.triggerLabel,
triggerAriaLabel: mergedOptions.triggerAriaLabel,
shadow,
styleNonce,
});
_attachTo(actor.el, {
...mergedOptions,
Expand Down
6 changes: 5 additions & 1 deletion packages/feedback/src/modal/components/Dialog.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ const SUCCESS = `
/**
* Creates <style> element for widget dialog
*/
export function createDialogStyles(): HTMLStyleElement {
export function createDialogStyles(styleNonce?: string): HTMLStyleElement {
const style = DOCUMENT.createElement('style');

style.textContent = `
Expand All @@ -288,5 +288,9 @@ ${BUTTON}
${SUCCESS}
`;

if (styleNonce) {
style.setAttribute('nonce', styleNonce);
}

return style;
}
2 changes: 1 addition & 1 deletion packages/feedback/src/modal/integration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const feedbackModalIntegration = ((): FeedbackModalIntegration => {
const user = getUser();

const el = DOCUMENT.createElement('div');
const style = createDialogStyles();
const style = createDialogStyles(options.styleNonce);

let originalOverflow = '';
const dialog: ReturnType<FeedbackModalIntegration['createDialog']> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function ScreenshotEditorFactory({
const useTakeScreenshot = useTakeScreenshotFactory({ hooks });

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

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

return (
<div class="editor">
<style dangerouslySetInnerHTML={styles} />
<style nonce={options.styleNonce} dangerouslySetInnerHTML={styles} />
<div class="editor__canvas-container" ref={canvasContainerRef}>
<div class="editor__crop-container" style={{ position: 'absolute', zIndex: 1 }} ref={cropContainerRef}>
<canvas
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { DOCUMENT } from '../../constants';
/**
* Creates <style> element for widget dialog
*/
export function createScreenshotInputStyles(): HTMLStyleElement {
export function createScreenshotInputStyles(styleNonce?: string): HTMLStyleElement {
const style = DOCUMENT.createElement('style');

const surface200 = '#1A141F';
Expand Down Expand Up @@ -86,5 +86,9 @@ export function createScreenshotInputStyles(): HTMLStyleElement {
}
`;

if (styleNonce) {
style.setAttribute('nonce', styleNonce);
}

return style;
}
10 changes: 10 additions & 0 deletions packages/types/src/feedback/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ export interface FeedbackGeneralConfiguration {
* Set an object that will be merged sent as tags data with the event.
*/
tags?: { [key: string]: Primitive };

/**
* Set a nonce to be passed to the injected <style> tag for enforcing CSP
*/
styleNonce?: string;

/**
* Set a nonce to be passed to the injected <script> tag for enforcing CSP
*/
scriptNonce?: string;
}

/**
Expand Down

0 comments on commit 63c4a90

Please sign in to comment.