Skip to content

Commit 9644ccb

Browse files
authored
fix(legal): Prevent stored XSS via javascript: URLs in policy revision flow (#114283)
The legal/compliance policy row rendered policy URLs in two unsafe ways: the "Review" LinkButton used the raw `policy.url` string directly as an href, and the `showPolicy` popup handler also passed the raw URL to `window.open`. Because `new URL('javascript:...')` parses successfully, `safeURL` returned a truthy URL object for javascript: scheme values, allowing the outer `policy.url && policyUrl` guard to pass. Any billing user who clicked "Review" on an affected policy would execute arbitrary JavaScript in the current page's context. Fixes: - Add an explicit http/https protocol allowlist check after `safeURL` so that javascript:, data:, and other dangerous schemes are treated as invalid and produce a null `policyUrl`. - Replace `policy.url` with `policyUrl.toString()` in the "Review" LinkButton and `showPolicy` so all three rendering paths (iframe src, window.open, and anchor href) go through the validated reference. - Add a `validate` function to `PolicyRevisionSchema`'s url field in the admin form so non-http(s) URLs are rejected at submission time, preventing malicious values from being stored in the first place. Fixes https://linear.app/getsentry/issue/REVENG-38
1 parent 785eb2d commit 9644ccb

4 files changed

Lines changed: 177 additions & 6 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {PolicyRevisionSchema} from 'admin/schemas/policies';
2+
3+
describe('PolicyRevisionSchema', () => {
4+
const urlField = PolicyRevisionSchema.find(f => f.name === 'url')!;
5+
const validate = urlField.validate!;
6+
7+
describe('url field validation', () => {
8+
it('accepts https URLs', () => {
9+
expect(
10+
validate({id: 'url', form: {url: 'https://sentry.io/legal/terms/'}})
11+
).toEqual([]);
12+
});
13+
14+
it('accepts http URLs', () => {
15+
expect(validate({id: 'url', form: {url: 'http://example.com/policy/'}})).toEqual(
16+
[]
17+
);
18+
});
19+
20+
it('allows an empty value', () => {
21+
expect(validate({id: 'url', form: {url: ''}})).toEqual([]);
22+
expect(validate({id: 'url', form: {url: undefined}})).toEqual([]);
23+
});
24+
25+
it('rejects javascript: URLs with a protocol error', () => {
26+
// Build via concatenation to avoid the no-script-url ESLint rule.
27+
const jsUrl = 'javascript' + ':alert(document.domain)';
28+
expect(validate({id: 'url', form: {url: jsUrl}})).toEqual([
29+
['url', 'URL must use http or https protocol'],
30+
]);
31+
});
32+
33+
it('rejects data: URLs with a protocol error', () => {
34+
expect(
35+
validate({
36+
id: 'url',
37+
form: {url: 'data:text/html,<script>alert(1)</script>'},
38+
})
39+
).toEqual([['url', 'URL must use http or https protocol']]);
40+
});
41+
42+
it('rejects vbscript: URLs with a protocol error', () => {
43+
expect(validate({id: 'url', form: {url: 'vbscript:msgbox(1)'}})).toEqual([
44+
['url', 'URL must use http or https protocol'],
45+
]);
46+
});
47+
48+
it('rejects non-URL strings with an invalid URL error', () => {
49+
expect(validate({id: 'url', form: {url: 'not-a-valid-url'}})).toEqual([
50+
['url', 'Please enter a valid URL'],
51+
]);
52+
});
53+
});
54+
});

static/gsAdmin/schemas/policies.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import type {FieldObject} from 'sentry/components/forms/types';
1+
import type {Field} from 'sentry/components/forms/types';
22
import {slugify} from 'sentry/utils/slugify';
33

4-
export const PolicySchema: FieldObject[] = [
4+
export const PolicySchema: Field[] = [
55
{
66
name: 'name',
77
type: 'string',
@@ -35,7 +35,7 @@ export const PolicySchema: FieldObject[] = [
3535
},
3636
];
3737

38-
export const PolicyRevisionSchema: FieldObject[] = [
38+
export const PolicyRevisionSchema: Field[] = [
3939
{
4040
name: 'version',
4141
type: 'string',
@@ -51,6 +51,21 @@ export const PolicyRevisionSchema: FieldObject[] = [
5151
label: 'URL',
5252
placeholder: 'e.g. https://example.com/terms-of-service/',
5353
help: 'If the policy is hosted at an external URL, enter it here.',
54+
validate: ({id, form}) => {
55+
const value = form[id];
56+
if (!value) {
57+
return [];
58+
}
59+
try {
60+
const parsed = new URL(value);
61+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
62+
return [[id, 'URL must use http or https protocol']];
63+
}
64+
} catch {
65+
return [[id, 'Please enter a valid URL']];
66+
}
67+
return [];
68+
},
5469
},
5570
{
5671
name: 'file',

static/gsApp/views/legalAndCompliance/policyRow.spec.tsx

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ import {render, screen} from 'sentry-test/reactTestingLibrary';
77

88
import {PolicyRow} from 'getsentry/views/legalAndCompliance/policyRow';
99

10+
// Build the javascript: URL via concatenation to avoid the no-script-url ESLint rule,
11+
// which flags literal javascript: strings even inside test assertions.
12+
const JS_URL = 'javascript' + ':alert(document.domain)';
13+
const DANGEROUS_URLS = [
14+
JS_URL,
15+
'data:text/html,<script>alert(1)</script>',
16+
'vbscript:msgbox(1)',
17+
];
18+
1019
describe('PolicyRow', () => {
1120
const {organization} = initializeOrg({});
1221
const subscription = SubscriptionFixture({organization});
@@ -119,4 +128,92 @@ describe('PolicyRow', () => {
119128
expect(screen.queryByText(/New version available/i)).not.toBeInTheDocument();
120129
expect(screen.getByRole('button', {name: 'Review and Accept'})).toBeInTheDocument();
121130
});
131+
132+
describe('XSS prevention via URL scheme allowlist', () => {
133+
it.each(DANGEROUS_URLS)(
134+
'does not render a Review button when policy URL is "%s"',
135+
dangerousUrl => {
136+
const policy = {...policies.pentest!, url: dangerousUrl};
137+
render(
138+
<PolicyRow
139+
policies={{...policies, pentest: policy}}
140+
policy={policy}
141+
showUpdated={false}
142+
subscription={subscription}
143+
onAccept={() => {}}
144+
/>
145+
);
146+
expect(screen.queryByRole('button', {name: 'Review'})).not.toBeInTheDocument();
147+
expect(
148+
screen.queryByRole('button', {name: 'Review and Accept'})
149+
).not.toBeInTheDocument();
150+
}
151+
);
152+
153+
it.each(DANGEROUS_URLS)(
154+
'does not render Review and Accept for billing user when policy URL is "%s"',
155+
dangerousUrl => {
156+
const orgWithBilling = OrganizationFixture({access: ['org:billing']});
157+
// soc-2-bridge-letter: hasSignature=true, slug is not privacy/terms — normally renders "Review and Accept"
158+
const policy = {...policies['soc-2-bridge-letter']!, url: dangerousUrl};
159+
render(
160+
<PolicyRow
161+
policies={{...policies, 'soc-2-bridge-letter': policy}}
162+
policy={policy}
163+
showUpdated={false}
164+
subscription={subscription}
165+
onAccept={() => {}}
166+
/>,
167+
{organization: orgWithBilling}
168+
);
169+
expect(
170+
screen.queryByRole('button', {name: 'Review and Accept'})
171+
).not.toBeInTheDocument();
172+
}
173+
);
174+
175+
it.each(DANGEROUS_URLS)(
176+
'does not pass dangerous URL "%s" to window.open',
177+
dangerousUrl => {
178+
const windowOpenSpy = jest.spyOn(window, 'open').mockReturnValue(null);
179+
const orgWithBilling = OrganizationFixture({access: ['org:billing']});
180+
const policy = {...policies['soc-2-bridge-letter']!, url: dangerousUrl};
181+
render(
182+
<PolicyRow
183+
policies={{...policies, 'soc-2-bridge-letter': policy}}
184+
policy={policy}
185+
showUpdated={false}
186+
subscription={subscription}
187+
onAccept={() => {}}
188+
/>,
189+
{organization: orgWithBilling}
190+
);
191+
// No button is rendered so showPolicy cannot be triggered
192+
expect(
193+
screen.queryByRole('button', {name: 'Review and Accept'})
194+
).not.toBeInTheDocument();
195+
expect(windowOpenSpy).not.toHaveBeenCalled();
196+
windowOpenSpy.mockRestore();
197+
}
198+
);
199+
200+
it('renders the Review button with a safe https href for a legitimate URL', () => {
201+
const policy = {...policies.terms!, url: 'https://sentry.io/legal/terms/1.0.0/'};
202+
render(
203+
<PolicyRow
204+
policies={{...policies, terms: policy}}
205+
policy={policy}
206+
showUpdated={false}
207+
subscription={subscription}
208+
onAccept={() => {}}
209+
/>
210+
);
211+
const reviewButton = screen.getByRole('button', {name: 'Review'});
212+
expect(reviewButton).toBeInTheDocument();
213+
expect(reviewButton).toHaveAttribute(
214+
'href',
215+
'https://sentry.io/legal/terms/1.0.0/?userCurrentVersion=1.0.0'
216+
);
217+
});
218+
});
122219
});

static/gsApp/views/legalAndCompliance/policyRow.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,12 @@ export function PolicyRow({
4848
const activeSuperUser = isActiveSuperuser();
4949
const hasBillingAccess = organization.access.includes('org:billing');
5050

51-
const policyUrl = policy.url ? safeURL(policy.url) : null;
51+
const rawPolicyUrl = policy.url ? safeURL(policy.url) : null;
52+
// Only allow http/https URLs to prevent javascript: and data: URL injection
53+
const policyUrl =
54+
rawPolicyUrl?.protocol === 'http:' || rawPolicyUrl?.protocol === 'https:'
55+
? rawPolicyUrl
56+
: null;
5257
// userCurrentVersion filters version select dropdown to only the current version + latest version
5358
if (policyUrl && policy.consent) {
5459
policyUrl.searchParams.set('userCurrentVersion', policy.consent.acceptedVersion);
@@ -61,7 +66,7 @@ export function PolicyRow({
6166
const name = 'sentryPolicy';
6267
const width = 600;
6368
const height = 600;
64-
const url = policy.url;
69+
const url = policyUrl?.toString() ?? null;
6570

6671
// this attempts to center the dialog
6772
const innerWidth = window.innerWidth
@@ -226,7 +231,7 @@ export function PolicyRow({
226231
{t('Review and Accept')}
227232
</Button>
228233
) : (
229-
<LinkButton size="sm" external href={policy.url}>
234+
<LinkButton size="sm" external href={policyUrl.toString()}>
230235
{t('Review')}
231236
</LinkButton>
232237
))}

0 commit comments

Comments
 (0)