Generate safe javascript url instead of throwing with disableJavaScriptURLs is on (#26507)

We currently throw an error when disableJavaScriptURLs is on and trigger
an error boundary. I kind of thought that's what would happen with CSP
or Trusted Types anyway. However, that's not what happens. Instead, in
those environments what happens is that the error is triggered when you
try to actually visit those links. So if you `preventDefault()` or
something it'll never show up and since the error just logs to the
console or to a violation logger, it's effectively a noop to users.

We can simulate the same without CSP by simply generating a different
`javascript:` url that throws instead of executing the potential attack
vector.

This still allows these to be used - at least as long as you
preventDefault before using them in practice. This might be legit for
forms. We still don't recommend using them for links-as-buttons since
it'll be possible to "Open in a New Tab" and other weird artifacts. For
links we still recommend the technique of assigning a button role etc.

It also is a little nicer when an attack actually happens because at
least it doesn't allow an attacker to trigger error boundaries and
effectively deny access to a page.
This commit is contained in:
Sebastian Markbåge
2023-03-29 23:39:02 -04:00
committed by GitHub
parent f0aafa1a7e
commit 4c2fc01900
4 changed files with 298 additions and 207 deletions
@@ -17,7 +17,6 @@ import {
} from '../shared/DOMProperty';
import sanitizeURL from '../shared/sanitizeURL';
import {
disableJavaScriptURLs,
enableTrustedTypesIntegration,
enableCustomElementPropertySupport,
enableFilterEmptyStringAttributesDOM,
@@ -43,15 +42,6 @@ export function getValueForProperty(
const {propertyName} = propertyInfo;
return (node: any)[propertyName];
}
if (!disableJavaScriptURLs && propertyInfo.sanitizeURL) {
// If we haven't fully disabled javascript: URLs, and if
// the hydration is successful of a javascript: URL, we
// still want to warn on the client.
if (__DEV__) {
checkAttributeStringCoercion(expected, name);
}
sanitizeURL('' + (expected: any));
}
const attributeName = propertyInfo.attributeName;
@@ -134,6 +124,11 @@ export function getValueForProperty(
}
// shouldRemoveAttribute
switch (typeof expected) {
case 'function':
case 'symbol': // eslint-disable-line
return value;
}
switch (propertyInfo.type) {
case BOOLEAN: {
if (expected) {
@@ -175,6 +170,16 @@ export function getValueForProperty(
if (__DEV__) {
checkAttributeStringCoercion(expected, name);
}
if (propertyInfo.sanitizeURL) {
// We have already verified this above.
// eslint-disable-next-line react-internal/safe-string-coercion
if (value === '' + (sanitizeURL(expected): any)) {
return expected;
}
return value;
}
// We have already verified this above.
// eslint-disable-next-line react-internal/safe-string-coercion
if (value === '' + (expected: any)) {
return expected;
}
@@ -395,19 +400,25 @@ export function setValueForProperty(node: Element, name: string, value: mixed) {
}
break;
default: {
if (__DEV__) {
checkAttributeStringCoercion(value, attributeName);
}
let attributeValue;
// `setAttribute` with objects becomes only `[object]` in IE8/9,
// ('' + value) makes it output the correct toString()-value.
if (enableTrustedTypesIntegration) {
attributeValue = (value: any);
} else {
if (__DEV__) {
checkAttributeStringCoercion(value, attributeName);
if (propertyInfo.sanitizeURL) {
attributeValue = (sanitizeURL(value): any);
} else {
attributeValue = (value: any);
}
} else {
// We have already verified this above.
// eslint-disable-next-line react-internal/safe-string-coercion
attributeValue = '' + (value: any);
}
if (propertyInfo.sanitizeURL) {
sanitizeURL(attributeValue.toString());
if (propertyInfo.sanitizeURL) {
attributeValue = sanitizeURL(attributeValue);
}
}
const attributeNamespace = propertyInfo.attributeNamespace;
if (attributeNamespace) {
@@ -736,12 +736,13 @@ function pushAttribute(
}
break;
default:
if (__DEV__) {
checkAttributeStringCoercion(value, attributeName);
}
if (propertyInfo.sanitizeURL) {
if (__DEV__) {
checkAttributeStringCoercion(value, attributeName);
}
value = '' + (value: any);
sanitizeURL(value);
// We've already checked above.
// eslint-disable-next-line react-internal/safe-string-coercion
value = sanitizeURL('' + (value: any));
}
target.push(
attributeSeparator,
@@ -3844,15 +3845,12 @@ function writeStyleResourceDependencyHrefOnlyInJS(
function writeStyleResourceDependencyInJS(
destination: Destination,
href: string,
precedence: string,
href: mixed,
precedence: mixed,
props: Object,
) {
if (__DEV__) {
checkAttributeStringCoercion(href, 'href');
}
const coercedHref = '' + (href: any);
sanitizeURL(coercedHref);
// eslint-disable-next-line react-internal/safe-string-coercion
const coercedHref = sanitizeURL('' + (href: any));
writeChunk(
destination,
stringToChunk(escapeJSObjectForInstructionScripts(coercedHref)),
@@ -3939,8 +3937,7 @@ function writeStyleResourceAttributeInJS(
if (__DEV__) {
checkAttributeStringCoercion(value, attributeName);
}
attributeValue = '' + (value: any);
sanitizeURL(attributeValue);
value = sanitizeURL(value);
break;
}
default: {
@@ -4041,15 +4038,12 @@ function writeStyleResourceDependencyHrefOnlyInAttr(
function writeStyleResourceDependencyInAttr(
destination: Destination,
href: string,
precedence: string,
href: mixed,
precedence: mixed,
props: Object,
) {
if (__DEV__) {
checkAttributeStringCoercion(href, 'href');
}
const coercedHref = '' + (href: any);
sanitizeURL(coercedHref);
// eslint-disable-next-line react-internal/safe-string-coercion
const coercedHref = sanitizeURL('' + (href: any));
writeChunk(
destination,
stringToChunk(escapeTextForBrowser(JSON.stringify(coercedHref))),
@@ -4136,8 +4130,7 @@ function writeStyleResourceAttributeInAttr(
if (__DEV__) {
checkAttributeStringCoercion(value, attributeName);
}
attributeValue = '' + (value: any);
sanitizeURL(attributeValue);
value = sanitizeURL(value);
break;
}
default: {
+12 -7
View File
@@ -24,24 +24,29 @@ const isJavaScriptProtocol =
let didWarn = false;
function sanitizeURL(url: string) {
function sanitizeURL<T>(url: T): T | string {
// We should never have symbols here because they get filtered out elsewhere.
// eslint-disable-next-line react-internal/safe-string-coercion
const stringifiedURL = '' + (url: any);
if (disableJavaScriptURLs) {
if (isJavaScriptProtocol.test(url)) {
throw new Error(
'React has blocked a javascript: URL as a security precaution.',
);
if (isJavaScriptProtocol.test(stringifiedURL)) {
// Return a different javascript: url that doesn't cause any side-effects and just
// throws if ever visited.
// eslint-disable-next-line no-script-url
return "javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')";
}
} else if (__DEV__) {
if (!didWarn && isJavaScriptProtocol.test(url)) {
if (!didWarn && isJavaScriptProtocol.test(stringifiedURL)) {
didWarn = true;
console.error(
'A future version of React will block javascript: URLs as a security precaution. ' +
'Use event handlers instead if you can. If you need to generate unsafe HTML try ' +
'using dangerouslySetInnerHTML instead. React was passed %s.',
JSON.stringify(url),
JSON.stringify(stringifiedURL),
);
}
}
return url;
}
export default sanitizeURL;
@@ -19,134 +19,8 @@ let ReactDOM;
let ReactDOMServer;
let ReactTestUtils;
function runTests(itRenders, itRejectsRendering, expectToReject) {
itRenders('a http link with the word javascript in it', async render => {
const e = await render(
<a href="http://javascript:0/thisisfine">Click me</a>,
);
expect(e.tagName).toBe('A');
expect(e.href).toBe('http://javascript:0/thisisfine');
});
itRejectsRendering('a javascript protocol href', async render => {
// Only the first one warns. The second warning is deduped.
const e = await render(
<div>
<a href="javascript:notfine">p0wned</a>
<a href="javascript:notfineagain">p0wned again</a>
</div>,
1,
);
expect(e.firstChild.href).toBe('javascript:notfine');
expect(e.lastChild.href).toBe('javascript:notfineagain');
});
itRejectsRendering(
'a javascript protocol with leading spaces',
async render => {
const e = await render(
<a href={' \t \u0000\u001F\u0003javascript\n: notfine'}>p0wned</a>,
1,
);
// We use an approximate comparison here because JSDOM might not parse
// \u0000 in HTML properly.
expect(e.href).toContain('notfine');
},
);
itRejectsRendering(
'a javascript protocol with intermediate new lines and mixed casing',
async render => {
const e = await render(
<a href={'\t\r\n Jav\rasCr\r\niP\t\n\rt\n:notfine'}>p0wned</a>,
1,
);
expect(e.href).toBe('javascript:notfine');
},
);
itRejectsRendering('a javascript protocol area href', async render => {
const e = await render(
<map>
<area href="javascript:notfine" />
</map>,
1,
);
expect(e.firstChild.href).toBe('javascript:notfine');
});
itRejectsRendering('a javascript protocol form action', async render => {
const e = await render(<form action="javascript:notfine">p0wned</form>, 1);
expect(e.action).toBe('javascript:notfine');
});
itRejectsRendering(
'a javascript protocol button formAction',
async render => {
const e = await render(<input formAction="javascript:notfine" />, 1);
expect(e.getAttribute('formAction')).toBe('javascript:notfine');
},
);
itRejectsRendering('a javascript protocol input formAction', async render => {
const e = await render(
<button formAction="javascript:notfine">p0wned</button>,
1,
);
expect(e.getAttribute('formAction')).toBe('javascript:notfine');
});
itRejectsRendering('a javascript protocol iframe src', async render => {
const e = await render(<iframe src="javascript:notfine" />, 1);
expect(e.src).toBe('javascript:notfine');
});
itRejectsRendering('a javascript protocol frame src', async render => {
const e = await render(
<html>
<head />
<frameset>
<frame src="javascript:notfine" />
</frameset>
</html>,
1,
);
expect(e.lastChild.firstChild.src).toBe('javascript:notfine');
});
itRejectsRendering('a javascript protocol in an SVG link', async render => {
const e = await render(
<svg>
<a href="javascript:notfine" />
</svg>,
1,
);
expect(e.firstChild.getAttribute('href')).toBe('javascript:notfine');
});
itRejectsRendering(
'a javascript protocol in an SVG link with a namespace',
async render => {
const e = await render(
<svg>
<a xlinkHref="javascript:notfine" />
</svg>,
1,
);
expect(
e.firstChild.getAttributeNS('http://www.w3.org/1999/xlink', 'href'),
).toBe('javascript:notfine');
},
);
it('rejects a javascript protocol href if it is added during an update', () => {
const container = document.createElement('div');
ReactDOM.render(<a href="thisisfine">click me</a>, container);
expectToReject(() => {
ReactDOM.render(<a href="javascript:notfine">click me</a>, container);
});
});
}
const EXPECTED_SAFE_URL =
"javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')";
describe('ReactDOMServerIntegration - Untrusted URLs', () => {
// The `itRenders` helpers don't work with the gate pragma, so we have to do
@@ -177,14 +51,131 @@ describe('ReactDOMServerIntegration - Untrusted URLs', () => {
resetModules();
});
runTests(itRenders, itRenders, fn =>
expect(fn).toErrorDev(
itRenders('a http link with the word javascript in it', async render => {
const e = await render(
<a href="http://javascript:0/thisisfine">Click me</a>,
);
expect(e.tagName).toBe('A');
expect(e.href).toBe('http://javascript:0/thisisfine');
});
itRenders('a javascript protocol href', async render => {
// Only the first one warns. The second warning is deduped.
const e = await render(
<div>
<a href="javascript:notfine">p0wned</a>
<a href="javascript:notfineagain">p0wned again</a>
</div>,
1,
);
expect(e.firstChild.href).toBe('javascript:notfine');
expect(e.lastChild.href).toBe('javascript:notfineagain');
});
itRenders('a javascript protocol with leading spaces', async render => {
const e = await render(
<a href={' \t \u0000\u001F\u0003javascript\n: notfine'}>p0wned</a>,
1,
);
// We use an approximate comparison here because JSDOM might not parse
// \u0000 in HTML properly.
expect(e.href).toContain('notfine');
});
itRenders(
'a javascript protocol with intermediate new lines and mixed casing',
async render => {
const e = await render(
<a href={'\t\r\n Jav\rasCr\r\niP\t\n\rt\n:notfine'}>p0wned</a>,
1,
);
expect(e.href).toBe('javascript:notfine');
},
);
itRenders('a javascript protocol area href', async render => {
const e = await render(
<map>
<area href="javascript:notfine" />
</map>,
1,
);
expect(e.firstChild.href).toBe('javascript:notfine');
});
itRenders('a javascript protocol form action', async render => {
const e = await render(<form action="javascript:notfine">p0wned</form>, 1);
expect(e.action).toBe('javascript:notfine');
});
itRenders('a javascript protocol button formAction', async render => {
const e = await render(<input formAction="javascript:notfine" />, 1);
expect(e.getAttribute('formAction')).toBe('javascript:notfine');
});
itRenders('a javascript protocol input formAction', async render => {
const e = await render(
<button formAction="javascript:notfine">p0wned</button>,
1,
);
expect(e.getAttribute('formAction')).toBe('javascript:notfine');
});
itRenders('a javascript protocol iframe src', async render => {
const e = await render(<iframe src="javascript:notfine" />, 1);
expect(e.src).toBe('javascript:notfine');
});
itRenders('a javascript protocol frame src', async render => {
const e = await render(
<html>
<head />
<frameset>
<frame src="javascript:notfine" />
</frameset>
</html>,
1,
);
expect(e.lastChild.firstChild.src).toBe('javascript:notfine');
});
itRenders('a javascript protocol in an SVG link', async render => {
const e = await render(
<svg>
<a href="javascript:notfine" />
</svg>,
1,
);
expect(e.firstChild.getAttribute('href')).toBe('javascript:notfine');
});
itRenders(
'a javascript protocol in an SVG link with a namespace',
async render => {
const e = await render(
<svg>
<a xlinkHref="javascript:notfine" />
</svg>,
1,
);
expect(
e.firstChild.getAttributeNS('http://www.w3.org/1999/xlink', 'href'),
).toBe('javascript:notfine');
},
);
it('rejects a javascript protocol href if it is added during an update', () => {
const container = document.createElement('div');
ReactDOM.render(<a href="thisisfine">click me</a>, container);
expect(() => {
ReactDOM.render(<a href="javascript:notfine">click me</a>, container);
}).toErrorDev(
'Warning: A future version of React will block javascript: URLs as a security precaution. ' +
'Use event handlers instead if you can. If you need to generate unsafe HTML try using ' +
'dangerouslySetInnerHTML instead. React was passed "javascript:notfine".\n' +
' in a (at **)',
),
);
);
});
});
describe('ReactDOMServerIntegration - Untrusted URLs - disableJavaScriptURLs', () => {
@@ -216,34 +207,127 @@ describe('ReactDOMServerIntegration - Untrusted URLs - disableJavaScriptURLs', (
const {
resetModules,
itRenders,
itThrowsWhenRendering,
clientRenderOnBadMarkup,
clientRenderOnServerString,
} = ReactDOMServerIntegrationUtils(initModules);
const expectToReject = fn => {
let msg;
try {
fn();
} catch (x) {
msg = x.message;
}
expect(msg).toContain(
'React has blocked a javascript: URL as a security precaution.',
);
};
beforeEach(() => {
resetModules();
});
runTests(
itRenders,
(message, test) =>
itThrowsWhenRendering(message, test, 'blocked a javascript: URL'),
expectToReject,
itRenders('a http link with the word javascript in it', async render => {
const e = await render(
<a href="http://javascript:0/thisisfine">Click me</a>,
);
expect(e.tagName).toBe('A');
expect(e.href).toBe('http://javascript:0/thisisfine');
});
itRenders('a javascript protocol href', async render => {
// Only the first one warns. The second warning is deduped.
const e = await render(
<div>
<a href="javascript:notfine">p0wned</a>
<a href="javascript:notfineagain">p0wned again</a>
</div>,
);
expect(e.firstChild.href).toBe(EXPECTED_SAFE_URL);
expect(e.lastChild.href).toBe(EXPECTED_SAFE_URL);
});
itRenders('a javascript protocol with leading spaces', async render => {
const e = await render(
<a href={' \t \u0000\u001F\u0003javascript\n: notfine'}>p0wned</a>,
);
// We use an approximate comparison here because JSDOM might not parse
// \u0000 in HTML properly.
expect(e.href).toBe(EXPECTED_SAFE_URL);
});
itRenders(
'a javascript protocol with intermediate new lines and mixed casing',
async render => {
const e = await render(
<a href={'\t\r\n Jav\rasCr\r\niP\t\n\rt\n:notfine'}>p0wned</a>,
);
expect(e.href).toBe(EXPECTED_SAFE_URL);
},
);
itRenders('a javascript protocol area href', async render => {
const e = await render(
<map>
<area href="javascript:notfine" />
</map>,
);
expect(e.firstChild.href).toBe(EXPECTED_SAFE_URL);
});
itRenders('a javascript protocol form action', async render => {
const e = await render(<form action="javascript:notfine">p0wned</form>);
expect(e.action).toBe(EXPECTED_SAFE_URL);
});
itRenders('a javascript protocol button formAction', async render => {
const e = await render(<input formAction="javascript:notfine" />);
expect(e.getAttribute('formAction')).toBe(EXPECTED_SAFE_URL);
});
itRenders('a javascript protocol input formAction', async render => {
const e = await render(
<button formAction="javascript:notfine">p0wned</button>,
);
expect(e.getAttribute('formAction')).toBe(EXPECTED_SAFE_URL);
});
itRenders('a javascript protocol iframe src', async render => {
const e = await render(<iframe src="javascript:notfine" />);
expect(e.src).toBe(EXPECTED_SAFE_URL);
});
itRenders('a javascript protocol frame src', async render => {
const e = await render(
<html>
<head />
<frameset>
<frame src="javascript:notfine" />
</frameset>
</html>,
);
expect(e.lastChild.firstChild.src).toBe(EXPECTED_SAFE_URL);
});
itRenders('a javascript protocol in an SVG link', async render => {
const e = await render(
<svg>
<a href="javascript:notfine" />
</svg>,
);
expect(e.firstChild.getAttribute('href')).toBe(EXPECTED_SAFE_URL);
});
itRenders(
'a javascript protocol in an SVG link with a namespace',
async render => {
const e = await render(
<svg>
<a xlinkHref="javascript:notfine" />
</svg>,
);
expect(
e.firstChild.getAttributeNS('http://www.w3.org/1999/xlink', 'href'),
).toBe(EXPECTED_SAFE_URL);
},
);
it('rejects a javascript protocol href if it is added during an update', () => {
const container = document.createElement('div');
ReactDOM.render(<a href="http://thisisfine/">click me</a>, container);
expect(container.firstChild.href).toBe('http://thisisfine/');
ReactDOM.render(<a href="javascript:notfine">click me</a>, container);
expect(container.firstChild.href).toBe(EXPECTED_SAFE_URL);
});
itRenders('only the first invocation of toString', async render => {
let expectedToStringCalls = 1;
if (render === clientRenderOnBadMarkup) {
@@ -255,9 +339,8 @@ describe('ReactDOMServerIntegration - Untrusted URLs - disableJavaScriptURLs', (
// The hydration validation calls it one extra time.
// TODO: It would be good if we only called toString once for
// consistency but the code structure makes that hard right now.
expectedToStringCalls = 2;
}
if (__DEV__) {
expectedToStringCalls = 5;
} else if (__DEV__) {
// Checking for string coercion problems results in double the
// toString calls in DEV
expectedToStringCalls *= 2;
@@ -283,14 +366,13 @@ describe('ReactDOMServerIntegration - Untrusted URLs - disableJavaScriptURLs', (
it('rejects a javascript protocol href if it is added during an update twice', () => {
const container = document.createElement('div');
ReactDOM.render(<a href="thisisfine">click me</a>, container);
expectToReject(() => {
ReactDOM.render(<a href="javascript:notfine">click me</a>, container);
});
ReactDOM.render(<a href="http://thisisfine/">click me</a>, container);
expect(container.firstChild.href).toBe('http://thisisfine/');
ReactDOM.render(<a href="javascript:notfine">click me</a>, container);
expect(container.firstChild.href).toBe(EXPECTED_SAFE_URL);
// The second update ensures that a global flag hasn't been added to the regex
// which would fail to match the second time it is called.
expectToReject(() => {
ReactDOM.render(<a href="javascript:notfine">click me</a>, container);
});
ReactDOM.render(<a href="javascript:notfine">click me</a>, container);
expect(container.firstChild.href).toBe(EXPECTED_SAFE_URL);
});
});