Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add options for passing nonces to feedback integration #13347

Merged
merged 9 commits into from
Aug 14, 2024
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://public@dsn.ingest.sentry.io/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('janedoe@example.org');
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: 'janedoe@example.org',
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
Loading