Skip to content

Commit 4c2fc01

Browse files
authored
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.
1 parent f0aafa1 commit 4c2fc01

File tree

4 files changed

+233
-142
lines changed

4 files changed

+233
-142
lines changed

packages/react-dom-bindings/src/client/DOMPropertyOperations.js

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {
1717
} from '../shared/DOMProperty';
1818
import sanitizeURL from '../shared/sanitizeURL';
1919
import {
20-
disableJavaScriptURLs,
2120
enableTrustedTypesIntegration,
2221
enableCustomElementPropertySupport,
2322
enableFilterEmptyStringAttributesDOM,
@@ -43,15 +42,6 @@ export function getValueForProperty(
4342
const {propertyName} = propertyInfo;
4443
return (node: any)[propertyName];
4544
}
46-
if (!disableJavaScriptURLs && propertyInfo.sanitizeURL) {
47-
// If we haven't fully disabled javascript: URLs, and if
48-
// the hydration is successful of a javascript: URL, we
49-
// still want to warn on the client.
50-
if (__DEV__) {
51-
checkAttributeStringCoercion(expected, name);
52-
}
53-
sanitizeURL('' + (expected: any));
54-
}
5545

5646
const attributeName = propertyInfo.attributeName;
5747

@@ -134,6 +124,11 @@ export function getValueForProperty(
134124
}
135125

136126
// shouldRemoveAttribute
127+
switch (typeof expected) {
128+
case 'function':
129+
case 'symbol': // eslint-disable-line
130+
return value;
131+
}
137132
switch (propertyInfo.type) {
138133
case BOOLEAN: {
139134
if (expected) {
@@ -175,6 +170,16 @@ export function getValueForProperty(
175170
if (__DEV__) {
176171
checkAttributeStringCoercion(expected, name);
177172
}
173+
if (propertyInfo.sanitizeURL) {
174+
// We have already verified this above.
175+
// eslint-disable-next-line react-internal/safe-string-coercion
176+
if (value === '' + (sanitizeURL(expected): any)) {
177+
return expected;
178+
}
179+
return value;
180+
}
181+
// We have already verified this above.
182+
// eslint-disable-next-line react-internal/safe-string-coercion
178183
if (value === '' + (expected: any)) {
179184
return expected;
180185
}
@@ -395,19 +400,25 @@ export function setValueForProperty(node: Element, name: string, value: mixed) {
395400
}
396401
break;
397402
default: {
403+
if (__DEV__) {
404+
checkAttributeStringCoercion(value, attributeName);
405+
}
398406
let attributeValue;
399407
// `setAttribute` with objects becomes only `[object]` in IE8/9,
400408
// ('' + value) makes it output the correct toString()-value.
401409
if (enableTrustedTypesIntegration) {
402-
attributeValue = (value: any);
403-
} else {
404-
if (__DEV__) {
405-
checkAttributeStringCoercion(value, attributeName);
410+
if (propertyInfo.sanitizeURL) {
411+
attributeValue = (sanitizeURL(value): any);
412+
} else {
413+
attributeValue = (value: any);
406414
}
415+
} else {
416+
// We have already verified this above.
417+
// eslint-disable-next-line react-internal/safe-string-coercion
407418
attributeValue = '' + (value: any);
408-
}
409-
if (propertyInfo.sanitizeURL) {
410-
sanitizeURL(attributeValue.toString());
419+
if (propertyInfo.sanitizeURL) {
420+
attributeValue = sanitizeURL(attributeValue);
421+
}
411422
}
412423
const attributeNamespace = propertyInfo.attributeNamespace;
413424
if (attributeNamespace) {

packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -736,12 +736,13 @@ function pushAttribute(
736736
}
737737
break;
738738
default:
739+
if (__DEV__) {
740+
checkAttributeStringCoercion(value, attributeName);
741+
}
739742
if (propertyInfo.sanitizeURL) {
740-
if (__DEV__) {
741-
checkAttributeStringCoercion(value, attributeName);
742-
}
743-
value = '' + (value: any);
744-
sanitizeURL(value);
743+
// We've already checked above.
744+
// eslint-disable-next-line react-internal/safe-string-coercion
745+
value = sanitizeURL('' + (value: any));
745746
}
746747
target.push(
747748
attributeSeparator,
@@ -3844,15 +3845,12 @@ function writeStyleResourceDependencyHrefOnlyInJS(
38443845

38453846
function writeStyleResourceDependencyInJS(
38463847
destination: Destination,
3847-
href: string,
3848-
precedence: string,
3848+
href: mixed,
3849+
precedence: mixed,
38493850
props: Object,
38503851
) {
3851-
if (__DEV__) {
3852-
checkAttributeStringCoercion(href, 'href');
3853-
}
3854-
const coercedHref = '' + (href: any);
3855-
sanitizeURL(coercedHref);
3852+
// eslint-disable-next-line react-internal/safe-string-coercion
3853+
const coercedHref = sanitizeURL('' + (href: any));
38563854
writeChunk(
38573855
destination,
38583856
stringToChunk(escapeJSObjectForInstructionScripts(coercedHref)),
@@ -3939,8 +3937,7 @@ function writeStyleResourceAttributeInJS(
39393937
if (__DEV__) {
39403938
checkAttributeStringCoercion(value, attributeName);
39413939
}
3942-
attributeValue = '' + (value: any);
3943-
sanitizeURL(attributeValue);
3940+
value = sanitizeURL(value);
39443941
break;
39453942
}
39463943
default: {
@@ -4041,15 +4038,12 @@ function writeStyleResourceDependencyHrefOnlyInAttr(
40414038

40424039
function writeStyleResourceDependencyInAttr(
40434040
destination: Destination,
4044-
href: string,
4045-
precedence: string,
4041+
href: mixed,
4042+
precedence: mixed,
40464043
props: Object,
40474044
) {
4048-
if (__DEV__) {
4049-
checkAttributeStringCoercion(href, 'href');
4050-
}
4051-
const coercedHref = '' + (href: any);
4052-
sanitizeURL(coercedHref);
4045+
// eslint-disable-next-line react-internal/safe-string-coercion
4046+
const coercedHref = sanitizeURL('' + (href: any));
40534047
writeChunk(
40544048
destination,
40554049
stringToChunk(escapeTextForBrowser(JSON.stringify(coercedHref))),
@@ -4136,8 +4130,7 @@ function writeStyleResourceAttributeInAttr(
41364130
if (__DEV__) {
41374131
checkAttributeStringCoercion(value, attributeName);
41384132
}
4139-
attributeValue = '' + (value: any);
4140-
sanitizeURL(attributeValue);
4133+
value = sanitizeURL(value);
41414134
break;
41424135
}
41434136
default: {

packages/react-dom-bindings/src/shared/sanitizeURL.js

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,24 +24,29 @@ const isJavaScriptProtocol =
2424

2525
let didWarn = false;
2626

27-
function sanitizeURL(url: string) {
27+
function sanitizeURL<T>(url: T): T | string {
28+
// We should never have symbols here because they get filtered out elsewhere.
29+
// eslint-disable-next-line react-internal/safe-string-coercion
30+
const stringifiedURL = '' + (url: any);
2831
if (disableJavaScriptURLs) {
29-
if (isJavaScriptProtocol.test(url)) {
30-
throw new Error(
31-
'React has blocked a javascript: URL as a security precaution.',
32-
);
32+
if (isJavaScriptProtocol.test(stringifiedURL)) {
33+
// Return a different javascript: url that doesn't cause any side-effects and just
34+
// throws if ever visited.
35+
// eslint-disable-next-line no-script-url
36+
return "javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')";
3337
}
3438
} else if (__DEV__) {
35-
if (!didWarn && isJavaScriptProtocol.test(url)) {
39+
if (!didWarn && isJavaScriptProtocol.test(stringifiedURL)) {
3640
didWarn = true;
3741
console.error(
3842
'A future version of React will block javascript: URLs as a security precaution. ' +
3943
'Use event handlers instead if you can. If you need to generate unsafe HTML try ' +
4044
'using dangerouslySetInnerHTML instead. React was passed %s.',
41-
JSON.stringify(url),
45+
JSON.stringify(stringifiedURL),
4246
);
4347
}
4448
}
49+
return url;
4550
}
4651

4752
export default sanitizeURL;

0 commit comments

Comments
 (0)