Skip to content

Commit c4ecce9

Browse files
michael-yx-wuljharb
authored andcommitted
[New] jsx-no-target-blank: Add warnOnSpreadAttributes option
Defaults to `false`. When set to `true`, treats spread attributes as dangerous unless explicitly overriden. e.g. the following is safe: <a {...dangerousObject} rel="noreferrer" target="_blank"></a> This change also extends target="_blank" detection to include conditional expressions whose alternate or consequent is the "_blank" string (case-insensitive). Fixes #2827
1 parent 66593e5 commit c4ecce9

File tree

4 files changed

+140
-52
lines changed

4 files changed

+140
-52
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel
1111
* [`jsx-no-constructed-context-values`]: add new rule which checks when the value passed to a Context Provider will cause needless rerenders ([#2763][] @dylanOshima)
1212
* [`jsx-wrap-multilines`]: fix crash with `declaration`s that are on a new line after `=` ([#2875][] @ljharb)
1313
* [`jsx-indent-props`]: add `ignoreTernaryOperator` option ([#2846][] @SebastianZimmer)
14+
* [`jsx-no-target-blank`]: Add `warnOnSpreadAttributes` option ([#2855][] @michael-yx-wu)
1415

1516
### Fixed
1617
* [`display-name`]/component detection: avoid a crash on anonymous components ([#2840][] @ljharb)
@@ -35,6 +36,7 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel
3536
[#2871]: https://github.com/yannickcr/eslint-plugin-react/issues/2871
3637
[#2870]: https://github.com/yannickcr/eslint-plugin-react/issues/2870
3738
[#2869]: https://github.com/yannickcr/eslint-plugin-react/issues/2869
39+
[#2855]: https://github.com/yannickcr/eslint-plugin-react/pull/2855
3840
[#2852]: https://github.com/yannickcr/eslint-plugin-react/pull/2852
3941
[#2851]: https://github.com/yannickcr/eslint-plugin-react/issues/2851
4042
[#2846]: https://github.com/yannickcr/eslint-plugin-react/pull/2846

docs/rules/jsx-no-target-blank.md

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
11
# Prevent usage of unsafe `target='_blank'` (react/jsx-no-target-blank)
22

3-
When creating a JSX element that has an `a` tag, it is often desired to have
4-
the link open in a new tab using the `target='_blank'` attribute. Using this
5-
attribute unaccompanied by `rel='noreferrer'`, however, is a severe
6-
security vulnerability ([see here for more details](https://html.spec.whatwg.org/multipage/links.html#link-type-noopener))
3+
When creating a JSX element that has an `a` tag, it is often desired to have the link open in a new tab using the `target='_blank'` attribute. Using this attribute unaccompanied by `rel='noreferrer'`, however, is a severe security vulnerability ([see here for more details](https://html.spec.whatwg.org/multipage/links.html#link-type-noopener))
74
This rules requires that you accompany `target='_blank'` attributes with `rel='noreferrer'`.
85

96
## Rule Details
107

11-
This rule aims to prevent user generated links from creating security vulnerabilities by requiring
12-
`rel='noreferrer'` for external links, and optionally any dynamically generated links.
8+
This rule aims to prevent user generated links from creating security vulnerabilities by requiring `rel='noreferrer'` for external links, and optionally any dynamically generated links.
139

1410
## Rule Options
1511
```json
@@ -20,7 +16,8 @@ This rule aims to prevent user generated links from creating security vulnerabil
2016

2117
* allow-referrer: optional boolean. If `true` does not require `noreferrer`. Defaults to `false`.
2218
* enabled: for enabling the rule. 0=off, 1=warn, 2=error. Defaults to 0.
23-
* enforce: optional string, 'always' or 'never'
19+
* enforceDynamicLinks: optional string, 'always' or 'never'
20+
* warnOnSpreadAttributes: optional boolean. Defaults to `false`.
2421

2522
### `enforceDynamicLinks`
2623

@@ -56,6 +53,27 @@ Examples of **correct** code for this rule, when configured with `{ "enforceDyna
5653
var Hello = <a target='_blank' href={dynamicLink}></a>
5754
```
5855

56+
### `warnOnSpreadAttributes`
57+
58+
Spread attributes are a handy way of passing programmatically-generated props to components, but may contain unsafe props e.g.
59+
60+
```jsx
61+
const unsafeProps = {
62+
href: "http://example.com",
63+
target: "_blank",
64+
};
65+
66+
<a {...unsafeProps}></a>
67+
```
68+
69+
Defaults to false. If false, this rule will ignore all spread attributes. If true, this rule will treat all spread attributes as if they contain an unsafe combination of props, unless specifically overridden by props _after_ the last spread attribute prop e.g. the following would not be violations:
70+
71+
```jsx
72+
<a {...unsafeProps} rel="noreferrer"></a>
73+
<a {...unsafeProps} target="_self"></a>
74+
<a {...unsafeProps} href="/some-page"></a>
75+
```
76+
5977
### Custom link components
6078

6179
This rule supports the ability to use custom components for links, such as `<Link />` which is popular in libraries like `react-router`, `next.js` and `gatsby`. To enable this, define your custom link components in the global [shared settings](https://github.com/yannickcr/eslint-plugin-react/blob/master/README.md#configuration) under the `linkComponents` configuration area. Once configured, this rule will check those components as if they were `<a />` elements.
@@ -81,4 +99,4 @@ For links to a trusted host (e.g. internal links to your own site, or links to a
8199

82100
## When Not To Use It
83101

84-
If you do not have any external links, you can disable this rule.
102+
If you do not have any external links, you can disable this rule.

lib/rules/jsx-no-target-blank.js

Lines changed: 76 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -12,51 +12,77 @@ const linkComponentsUtil = require('../util/linkComponents');
1212
// Rule Definition
1313
// ------------------------------------------------------------------------------
1414

15-
function isTargetBlank(attr) {
16-
return attr.name
17-
&& attr.name.name === 'target'
18-
&& attr.value
19-
&& ((
20-
attr.value.type === 'Literal'
21-
&& attr.value.value.toLowerCase() === '_blank'
22-
) || (
23-
attr.value.type === 'JSXExpressionContainer'
24-
&& attr.value.expression
25-
&& attr.value.expression.value
26-
&& String(attr.value.expression.value).toLowerCase() === '_blank'
27-
));
15+
function lastIndexMatching(arr, condition) {
16+
return arr.map(condition).lastIndexOf(true);
2817
}
2918

30-
function hasExternalLink(element, linkAttribute) {
31-
return element.attributes.some((attr) => attr.name
32-
&& attr.name.name === linkAttribute
33-
&& attr.value.type === 'Literal'
34-
&& /^(?:\w+:|\/\/)/.test(attr.value.value));
19+
function attributeValuePossiblyBlank(attribute) {
20+
if (!attribute.value) {
21+
return false;
22+
}
23+
const value = attribute.value;
24+
if (value.type === 'Literal' && typeof value.value === 'string' && value.value.toLowerCase() === '_blank') {
25+
return true;
26+
}
27+
if (value.type === 'JSXExpressionContainer') {
28+
const expr = value.expression;
29+
if (expr.type === 'Literal' && typeof expr.value === 'string' && expr.value.toLowerCase() === '_blank') {
30+
return true;
31+
}
32+
if (expr.type === 'ConditionalExpression') {
33+
if (expr.alternate.type === 'Literal' && expr.alternate.value && expr.alternate.value.toLowerCase() === '_blank') {
34+
return true;
35+
}
36+
if (expr.consequent.type === 'Literal' && expr.consequent.value && expr.consequent.value.toLowerCase() === '_blank') {
37+
return true;
38+
}
39+
}
40+
}
41+
return false;
3542
}
3643

37-
function hasDynamicLink(element, linkAttribute) {
38-
return element.attributes.some((attr) => attr.name
44+
function hasTargetBlank(node, warnOnSpreadAttributes, spreadAttributeIndex) {
45+
const targetIndex = lastIndexMatching(node.attributes, (attr) => attr.name && attr.name.name === 'target');
46+
const foundTargetBlank = targetIndex !== -1 && attributeValuePossiblyBlank(node.attributes[targetIndex]);
47+
return foundTargetBlank || (warnOnSpreadAttributes && targetIndex < spreadAttributeIndex);
48+
}
49+
50+
function hasExternalLink(node, linkAttribute, warnOnSpreadAttributes, spreadAttributeIndex) {
51+
const linkIndex = lastIndexMatching(node.attributes, (attr) => attr.name && attr.name.name === linkAttribute);
52+
const foundExternalLink = linkIndex !== -1 && ((attr) => attr.value.type === 'Literal' && /^(?:\w+:|\/\/)/.test(attr.value.value))(
53+
node.attributes[linkIndex]);
54+
return foundExternalLink || (warnOnSpreadAttributes && linkIndex < spreadAttributeIndex);
55+
}
56+
57+
function hasDynamicLink(node, linkAttribute) {
58+
const dynamicLinkIndex = lastIndexMatching(node.attributes, (attr) => attr.name
3959
&& attr.name.name === linkAttribute
60+
&& attr.value
4061
&& attr.value.type === 'JSXExpressionContainer');
62+
if (dynamicLinkIndex !== -1) {
63+
return true;
64+
}
4165
}
4266

43-
function hasSecureRel(element, allowReferrer) {
44-
return element.attributes.find((attr) => {
45-
if (attr.type === 'JSXAttribute' && attr.name.name === 'rel') {
46-
const value = attr.value
47-
&& ((
48-
attr.value.type === 'Literal'
49-
&& attr.value.value
50-
) || (
51-
attr.value.type === 'JSXExpressionContainer'
52-
&& attr.value.expression
53-
&& attr.value.expression.value
54-
));
55-
const tags = value && value.toLowerCase && value.toLowerCase().split(' ');
56-
return tags && (allowReferrer ? tags.indexOf('noopener') >= 0 : tags.indexOf('noreferrer') >= 0);
57-
}
67+
function hasSecureRel(node, allowReferrer, warnOnSpreadAttributes, spreadAttributeIndex) {
68+
const relIndex = lastIndexMatching(node.attributes, (attr) => (attr.type === 'JSXAttribute' && attr.name.name === 'rel'));
69+
70+
if (relIndex === -1 || (warnOnSpreadAttributes && relIndex < spreadAttributeIndex)) {
5871
return false;
59-
});
72+
}
73+
74+
const relAttribute = node.attributes[relIndex];
75+
const value = relAttribute.value
76+
&& ((
77+
relAttribute.value.type === 'Literal'
78+
&& relAttribute.value.value
79+
) || (
80+
relAttribute.value.type === 'JSXExpressionContainer'
81+
&& relAttribute.value.expression
82+
&& relAttribute.value.expression.value
83+
));
84+
const tags = value && typeof value === 'string' && value.toLowerCase().split(' ');
85+
return tags && (allowReferrer ? tags.indexOf('noopener') >= 0 : tags.indexOf('noreferrer') >= 0);
6086
}
6187

6288
module.exports = {
@@ -75,6 +101,9 @@ module.exports = {
75101
},
76102
enforceDynamicLinks: {
77103
enum: ['always', 'never']
104+
},
105+
warnOnSpreadAttributes: {
106+
type: 'boolean'
78107
}
79108
},
80109
additionalProperties: false
@@ -84,22 +113,25 @@ module.exports = {
84113
create(context) {
85114
const configuration = context.options[0] || {};
86115
const allowReferrer = configuration.allowReferrer || false;
116+
const warnOnSpreadAttributes = configuration.warnOnSpreadAttributes || false;
87117
const enforceDynamicLinks = configuration.enforceDynamicLinks || 'always';
88118
const components = linkComponentsUtil.getLinkComponents(context);
89119

90120
return {
91-
JSXAttribute(node) {
92-
if (
93-
!components.has(node.parent.name.name)
94-
|| !isTargetBlank(node)
95-
|| hasSecureRel(node.parent, allowReferrer)
96-
) {
121+
JSXOpeningElement(node) {
122+
if (!components.has(node.name.name)) {
97123
return;
98124
}
99125

100-
const linkAttribute = components.get(node.parent.name.name);
126+
const spreadAttributeIndex = lastIndexMatching(node.attributes, (attr) => (attr.type === 'JSXSpreadAttribute'));
127+
if (!hasTargetBlank(node, warnOnSpreadAttributes, spreadAttributeIndex)) {
128+
return;
129+
}
101130

102-
if (hasExternalLink(node.parent, linkAttribute) || (enforceDynamicLinks === 'always' && hasDynamicLink(node.parent, linkAttribute))) {
131+
const linkAttribute = components.get(node.name.name);
132+
const hasDangerousLink = hasExternalLink(node, linkAttribute, warnOnSpreadAttributes, spreadAttributeIndex)
133+
|| (enforceDynamicLinks === 'always' && hasDynamicLink(node, linkAttribute));
134+
if (hasDangerousLink && !hasSecureRel(node, allowReferrer, warnOnSpreadAttributes, spreadAttributeIndex)) {
103135
context.report({
104136
node,
105137
message: 'Using target="_blank" without rel="noreferrer" '

tests/lib/rules/jsx-no-target-blank.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,22 @@ ruleTester.run('jsx-no-target-blank', rule, {
6464
{code: '<a target={\'targetValue\'} href="/absolute/path"></a>'},
6565
{code: '<a target={"targetValue"} href="/absolute/path"></a>'},
6666
{code: '<a target={null} href="//example.com"></a>'},
67+
{
68+
code: '<a {...someObject} href="/absolute/path"></a>',
69+
options: [{enforceDynamicLinks: 'always', warnOnSpreadAttributes: true}]
70+
},
71+
{
72+
code: '<a {...someObject} rel="noreferrer"></a>',
73+
options: [{enforceDynamicLinks: 'always', warnOnSpreadAttributes: true}]
74+
},
75+
{
76+
code: '<a {...someObject} rel="noreferrer" target="_blank"></a>',
77+
options: [{enforceDynamicLinks: 'always', warnOnSpreadAttributes: true}]
78+
},
79+
{
80+
code: '<a {...someObject} href="foobar" target="_blank"></a>',
81+
options: [{enforceDynamicLinks: 'always', warnOnSpreadAttributes: true}]
82+
},
6783
{
6884
code: '<a target="_blank" href={ dynamicLink }></a>',
6985
options: [{enforceDynamicLinks: 'never'}]
@@ -143,6 +159,26 @@ ruleTester.run('jsx-no-target-blank', rule, {
143159
code: '<a target="_blank" href={ dynamicLink }></a>',
144160
options: [{enforceDynamicLinks: 'always'}],
145161
errors: defaultErrors
162+
}, {
163+
code: '<a {...someObject}></a>',
164+
options: [{enforceDynamicLinks: 'always', warnOnSpreadAttributes: true}],
165+
errors: defaultErrors
166+
}, {
167+
code: '<a {...someObject} target="_blank"></a>',
168+
options: [{enforceDynamicLinks: 'always', warnOnSpreadAttributes: true}],
169+
errors: defaultErrors
170+
}, {
171+
code: '<a href="foobar" {...someObject} target="_blank"></a>',
172+
options: [{enforceDynamicLinks: 'always', warnOnSpreadAttributes: true}],
173+
errors: defaultErrors
174+
}, {
175+
code: '<a href="foobar" target="_blank" {...someObject}></a>',
176+
options: [{enforceDynamicLinks: 'always', warnOnSpreadAttributes: true}],
177+
errors: defaultErrors
178+
}, {
179+
code: '<a href="foobar" target="_blank" rel="noreferrer" {...someObject}></a>',
180+
options: [{enforceDynamicLinks: 'always', warnOnSpreadAttributes: true}],
181+
errors: defaultErrors
146182
}, {
147183
code: '<Link target="_blank" href={ dynamicLink }></Link>',
148184
options: [{enforceDynamicLinks: 'always'}],

0 commit comments

Comments
 (0)