feat(linter/react/jsx-no-target-blank): implement suggestion (#27181)

this PR implements suggestion for `react/jsx-no-target-blank` rule. Used
all upstream tests.

issue #1022
This commit is contained in:
Mikhail Baev authored and GitHub committed 2026-09-29 16:47:34 +01:00
1 parent 0b9799dc26
commit 1a7c902a96
1 file changed
+443 -12
@@ -3,8 +3,8 @@ use std::ops::Deref;
use oxc_ast::{
AstKind,
ast::{
Expression, JSXAttributeItem, JSXAttributeName, JSXAttributeValue, JSXExpression,
StringLiteral, match_expression,
Expression, JSXAttribute, JSXAttributeItem, JSXAttributeName, JSXAttributeValue,
JSXExpression, JSXOpeningElement, StringLiteral, match_expression,
},
};
use oxc_diagnostics::OxcDiagnostic;
@@ -17,6 +17,7 @@ use serde::{Deserialize, Serialize};
use crate::{
AstNode,
context::{ContextHost, LintContext},
fixer::{RuleFix, RuleFixer},
rule::{DefaultRuleConfig, Rule},
utils::is_same_expression,
};
@@ -77,11 +78,92 @@ impl Default for JsxNoTargetBlank {
}
impl JsxNoTargetBlank {
fn diagnostic(&self, span: Span, ctx: &LintContext) {
if self.allow_referrer {
ctx.diagnostic(target_blank_without_noopener(span));
fn diagnostic<'a>(
&self,
span: Span,
jsx_elem: &JSXOpeningElement<'a>,
is_link: bool,
ctx: &LintContext<'a>,
) {
let diagnostic = if self.allow_referrer {
target_blank_without_noopener(span)
} else {
ctx.diagnostic(target_blank_without_noreferrer(span));
target_blank_without_noreferrer(span)
};
self.report(diagnostic, jsx_elem, is_link, ctx);
}
fn report<'a>(
&self,
diagnostic: OxcDiagnostic,
jsx_elem: &JSXOpeningElement<'a>,
is_link: bool,
ctx: &LintContext<'a>,
) {
if is_link {
ctx.diagnostic_with_suggestion(diagnostic, |fixer| {
self.add_secure_rel(jsx_elem, fixer, ctx)
});
} else {
ctx.diagnostic(diagnostic);
}
}
fn add_secure_rel<'a>(
&self,
jsx_ele: &JSXOpeningElement<'a>,
fixer: RuleFixer<'_, 'a>,
ctx: &LintContext<'a>,
) -> RuleFix {
let mut target_index = None;
let mut spread_index = None;
let mut rel_attribute: Option<&JSXAttribute<'a>> = None;
for (index, attribute) in jsx_ele.attributes.iter().enumerate() {
match attribute {
JSXAttributeItem::Attribute(attribute) => {
if attribute.is_identifier("target") {
target_index = Some(index);
} else if attribute.is_identifier("rel") {
rel_attribute = Some(attribute);
}
}
JSXAttributeItem::SpreadAttribute(_) => spread_index = Some(index),
}
}
if let Some(spread_index) = spread_index
&& (rel_attribute.is_none()
|| target_index.is_none_or(|target_index| target_index < spread_index))
{
return fixer.noop();
}
let rel_value = if self.allow_referrer { "noopener" } else { "noreferrer" };
let Some(rel_attribute) = rel_attribute else {
let Some(last_attribute) = jsx_ele.attributes.last() else {
return fixer.noop();
};
return fixer.insert_text_after(last_attribute, format!(" rel=\"{rel_value}\""));
};
match &rel_attribute.value {
None => fixer.insert_text_after(rel_attribute, format!("=\"{rel_value}\"")),
Some(JSXAttributeValue::StringLiteral(lit)) => {
fixer.replace(lit.span, append_noreferrer(ctx.source_range(lit.span)))
}
Some(JSXAttributeValue::ExpressionContainer(container)) => {
match &container.expression {
JSXExpression::StringLiteral(lit) => {
fixer.replace(lit.span, append_noreferrer(ctx.source_range(lit.span)))
}
expr if expr.as_expression().is_some_and(Expression::is_literal) => {
fixer.replace(container.span, "\"noreferrer\"")
}
_ => fixer.noop(),
}
}
_ => fixer.noop(),
}
}
@@ -144,7 +226,7 @@ declare_oxc_lint!(
JsxNoTargetBlank,
react,
pedantic,
pending,
conditional_suggestion,
config = JsxNoTargetBlank,
version = "0.2.5",
short_description = "This rule aims to prevent user-generated link hrefs and form actions from creating security vulnerabilities.",
@@ -160,7 +242,8 @@ impl Rule for JsxNoTargetBlank {
let Some(tag_name) = &jsx_ele.name.get_identifier_name() else {
return;
};
if self.check_is_link(tag_name, ctx) || self.check_is_forms(tag_name, ctx) {
let is_link = self.check_is_link(tag_name, ctx);
if is_link || self.check_is_forms(tag_name, ctx) {
let mut target_blank_tuple = (false, None, false, false);
let mut rel_valid_tuple = (false, None, false, false);
let mut is_href_valid = true;
@@ -222,7 +305,12 @@ impl Rule for JsxNoTargetBlank {
if (has_href_value && is_href_valid) || rel_valid_tuple.0 {
return;
}
ctx.diagnostic(explicit_props_in_spread_attributes(spread_span));
self.report(
explicit_props_in_spread_attributes(spread_span),
jsx_ele,
is_link,
ctx,
);
return;
}
@@ -235,13 +323,13 @@ impl Rule for JsxNoTargetBlank {
if (target_blank_tuple.2 && !rel_valid_tuple.2)
|| (target_blank_tuple.3 && !rel_valid_tuple.3)
{
self.diagnostic(span, ctx);
self.diagnostic(span, jsx_ele, is_link, ctx);
}
return;
}
if target_blank_tuple.0 && !rel_valid_tuple.0 {
self.diagnostic(span, ctx);
self.diagnostic(span, jsx_ele, is_link, ctx);
}
}
}
@@ -253,6 +341,14 @@ impl Rule for JsxNoTargetBlank {
}
}
fn append_noreferrer(raw: &str) -> String {
let quote = &raw[..1];
let content = &raw[1..raw.len() - 1];
let mut parts = content.split("noreferrer").filter(|part| !part.is_empty()).collect::<Vec<_>>();
parts.push("noreferrer");
format!("{quote}{}{quote}", parts.join(" "))
}
fn check_is_external_link(link: &str) -> bool {
link.contains("//")
}
@@ -791,5 +887,340 @@ fn test() {
),
];
Tester::new(JsxNoTargetBlank::NAME, JsxNoTargetBlank::PLUGIN, pass, fail).test_and_snapshot();
let fix: Vec<(&str, &str, Option<serde_json::Value>, Option<serde_json::Value>)> = vec![
(
r#"<a target="_blank" href="https://example.com/1"></a>"#,
r#"<a target="_blank" href="https://example.com/1" rel="noreferrer"></a>"#,
None,
None,
),
(
r#"<a target="_blank" rel="" href="https://example.com/2"></a>"#,
r#"<a target="_blank" rel="noreferrer" href="https://example.com/2"></a>"#,
None,
None,
),
(
r#"<a target="_blank" rel={0} href="https://example.com/3"></a>"#,
r#"<a target="_blank" rel="noreferrer" href="https://example.com/3"></a>"#,
None,
None,
),
(
r#"<a target="_blank" rel={1} href="https://example.com/3"></a>"#,
r#"<a target="_blank" rel="noreferrer" href="https://example.com/3"></a>"#,
None,
None,
),
(
r#"<a target="_blank" rel={false} href="https://example.com/4"></a>"#,
r#"<a target="_blank" rel="noreferrer" href="https://example.com/4"></a>"#,
None,
None,
),
(
r#"<a target="_blank" rel={null} href="https://example.com/5"></a>"#,
r#"<a target="_blank" rel="noreferrer" href="https://example.com/5"></a>"#,
None,
None,
),
(
r#"<a target="_blank" rel="noopenernoreferrer" href="https://example.com/6"></a>"#,
r#"<a target="_blank" rel="noopener noreferrer" href="https://example.com/6"></a>"#,
None,
None,
),
(
r#"<a target="_blank" rel="no referrer" href="https://example.com/7"></a>"#,
r#"<a target="_blank" rel="no referrer noreferrer" href="https://example.com/7"></a>"#,
None,
None,
),
(
r#"<a target="_BLANK" href="https://example.com/8"></a>"#,
r#"<a target="_BLANK" href="https://example.com/8" rel="noreferrer"></a>"#,
None,
None,
),
(
r#"<a target="_blank" href="//example.com/9"></a>"#,
r#"<a target="_blank" href="//example.com/9" rel="noreferrer"></a>"#,
None,
None,
),
(
r#"<a target="_blank" href="//example.com/10" rel={true}></a>"#,
r#"<a target="_blank" href="//example.com/10" rel="noreferrer"></a>"#,
None,
None,
),
(
r#"<a target="_blank" href="//example.com/11" rel={3}></a>"#,
r#"<a target="_blank" href="//example.com/11" rel="noreferrer"></a>"#,
None,
None,
),
(
r#"<a target="_blank" href="//example.com/12" rel={null}></a>"#,
r#"<a target="_blank" href="//example.com/12" rel="noreferrer"></a>"#,
None,
None,
),
(
r#"<a target="_blank" href="//example.com/13" rel={getRel()}></a>"#,
r#"<a target="_blank" href="//example.com/13" rel={getRel()}></a>"#,
None,
None,
),
(
r#"<a target="_blank" href="//example.com/14" rel={"noopenernoreferrer"}></a>"#,
r#"<a target="_blank" href="//example.com/14" rel={"noopener noreferrer"}></a>"#,
None,
None,
),
(
r#"<a target={"_blank"} href={"//example.com/15"} rel={"noopenernoreferrer"}></a>"#,
r#"<a target={"_blank"} href={"//example.com/15"} rel={"noopener noreferrer"}></a>"#,
None,
None,
),
(
r#"<a target={"_blank"} href={"//example.com/16"} rel={"noopenernoreferrernoreferrernoreferrernoreferrernoreferrer"}></a>"#,
r#"<a target={"_blank"} href={"//example.com/16"} rel={"noopener noreferrer"}></a>"#,
None,
None,
),
(
r#"<a target="_blank" href="//example.com/17" rel></a>"#,
r#"<a target="_blank" href="//example.com/17" rel="noreferrer"></a>"#,
None,
None,
),
(
r#"<a target="_blank" href={ dynamicLink }></a>"#,
r#"<a target="_blank" href={ dynamicLink } rel="noreferrer"></a>"#,
None,
None,
),
(
r#"<a target={'_blank'} href="//example.com/18"></a>"#,
r#"<a target={'_blank'} href="//example.com/18" rel="noreferrer"></a>"#,
None,
None,
),
(
r#"<a target={"_blank"} href="//example.com/19"></a>"#,
r#"<a target={"_blank"} href="//example.com/19" rel="noreferrer"></a>"#,
None,
None,
),
(
r#"<a href="https://example.com/20" target="_blank" rel></a>"#,
r#"<a href="https://example.com/20" target="_blank" rel="noopener"></a>"#,
Some(serde_json::json!([{ "allowReferrer": true }])),
None,
),
(
r#"<a href="https://example.com/20" target="_blank"></a>"#,
r#"<a href="https://example.com/20" target="_blank" rel="noopener"></a>"#,
Some(serde_json::json!([{ "allowReferrer": true }])),
None,
),
(
r#"<a target="_blank" href={ dynamicLink }></a>"#,
r#"<a target="_blank" href={ dynamicLink } rel="noreferrer"></a>"#,
Some(serde_json::json!([{ "enforceDynamicLinks": "always" }])),
None,
),
(
r"<a {...someObject}></a>",
r"<a {...someObject}></a>",
Some(
serde_json::json!([{ "enforceDynamicLinks": "always", "warnOnSpreadAttributes": true }]),
),
None,
),
(
r#"<a {...someObject} target="_blank"></a>"#,
r#"<a {...someObject} target="_blank"></a>"#,
Some(
serde_json::json!([{ "enforceDynamicLinks": "always", "warnOnSpreadAttributes": true }]),
),
None,
),
(
r#"<a href="foobar" {...someObject} target="_blank"></a>"#,
r#"<a href="foobar" {...someObject} target="_blank"></a>"#,
Some(
serde_json::json!([{ "enforceDynamicLinks": "always", "warnOnSpreadAttributes": true }]),
),
None,
),
(
r#"<a href="foobar" target="_blank" rel="noreferrer" {...someObject}></a>"#,
r#"<a href="foobar" target="_blank" rel="noreferrer" {...someObject}></a>"#,
Some(
serde_json::json!([{ "enforceDynamicLinks": "always", "warnOnSpreadAttributes": true }]),
),
None,
),
(
r#"<a href="foobar" target="_blank" {...someObject}></a>"#,
r#"<a href="foobar" target="_blank" {...someObject}></a>"#,
Some(
serde_json::json!([{ "enforceDynamicLinks": "always", "warnOnSpreadAttributes": true }]),
),
None,
),
(
r#"<Link target="_blank" href={ dynamicLink }></Link>"#,
r#"<Link target="_blank" href={ dynamicLink } rel="noreferrer"></Link>"#,
Some(serde_json::json!([{ "enforceDynamicLinks": "always" }])),
Some(serde_json::json!({ "settings": { "react": { "linkComponents": ["Link"] } } })),
),
(
r#"<Link target="_blank" to={ dynamicLink }></Link>"#,
r#"<Link target="_blank" to={ dynamicLink } rel="noreferrer"></Link>"#,
Some(serde_json::json!([{ "enforceDynamicLinks": "always" }])),
Some(
serde_json::json!({ "settings": { "react": { "linkComponents": [{ "name": "Link", "linkAttribute": "to" }] } } }),
),
),
(
r#"<a href="some-link" {...otherProps} target="some-non-blank-target"></a>"#,
r#"<a href="some-link" {...otherProps} target="some-non-blank-target"></a>"#,
Some(serde_json::json!([{ "warnOnSpreadAttributes": true }])),
None,
),
(
r#"<a href="some-link" target="some-non-blank-target" {...otherProps}></a>"#,
r#"<a href="some-link" target="some-non-blank-target" {...otherProps}></a>"#,
Some(serde_json::json!([{ "warnOnSpreadAttributes": true }])),
None,
),
(
r#"<a target="_blank" href="//example.com" rel></a>"#,
r#"<a target="_blank" href="//example.com" rel="noreferrer"></a>"#,
Some(serde_json::json!([{ "links": true }])),
None,
),
(
r#"<a target="_blank" href="//example.com" rel></a>"#,
r#"<a target="_blank" href="//example.com" rel="noreferrer"></a>"#,
Some(serde_json::json!([{ "links": true, "forms": true }])),
None,
),
(
r#"<a target="_blank" href="//example.com" rel></a>"#,
r#"<a target="_blank" href="//example.com" rel="noreferrer"></a>"#,
Some(serde_json::json!([{ "links": true, "forms": false }])),
None,
),
(
r#"<form method="POST" action="https://example.com" target="_blank"></form>"#,
r#"<form method="POST" action="https://example.com" target="_blank"></form>"#,
Some(serde_json::json!([{ "forms": true }])),
None,
),
(
r#"<form method="POST" action="https://example.com" rel="" target="_blank"></form>"#,
r#"<form method="POST" action="https://example.com" rel="" target="_blank"></form>"#,
Some(serde_json::json!([{ "forms": true }])),
None,
),
(
r#"<form method="POST" action="https://example.com" rel="noopenernoreferrer" target="_blank"></form>"#,
r#"<form method="POST" action="https://example.com" rel="noopenernoreferrer" target="_blank"></form>"#,
Some(serde_json::json!([{ "forms": true }])),
None,
),
(
r#"<form method="POST" action="https://example.com" rel="noopenernoreferrer" target="_blank"></form>"#,
r#"<form method="POST" action="https://example.com" rel="noopenernoreferrer" target="_blank"></form>"#,
Some(serde_json::json!([{ "forms": true, "links": false }])),
None,
),
(
r#"<a href={href} target="_blank" rel={isExternal ? "undefined" : "undefined"} />"#,
r#"<a href={href} target="_blank" rel={isExternal ? "undefined" : "undefined"} />"#,
None,
None,
),
(
r#"<a href={href} target="_blank" rel={isExternal ? "noopener" : undefined} />"#,
r#"<a href={href} target="_blank" rel={isExternal ? "noopener" : undefined} />"#,
None,
None,
),
(
r#"<a href={href} target="_blank" rel={isExternal ? "undefined" : "noopener"} />"#,
r#"<a href={href} target="_blank" rel={isExternal ? "undefined" : "noopener"} />"#,
None,
None,
),
(
r#"<a href={href} target={isExternal ? "_blank" : undefined} rel={isExternal ? undefined : "noopener noreferrer"} />"#,
r#"<a href={href} target={isExternal ? "_blank" : undefined} rel={isExternal ? undefined : "noopener noreferrer"} />"#,
None,
None,
),
(
r#"<a href={href} target="_blank" rel={isExternal ? 3 : "noopener noreferrer"} />"#,
r#"<a href={href} target="_blank" rel={isExternal ? 3 : "noopener noreferrer"} />"#,
None,
None,
),
(
r#"<a href={href} target="_blank" rel={isExternal ? "noopener noreferrer" : "3"} />"#,
r#"<a href={href} target="_blank" rel={isExternal ? "noopener noreferrer" : "3"} />"#,
None,
None,
),
(
r#"<a href={href} target="_blank" rel={isExternal ? "noopener" : "2"} />"#,
r#"<a href={href} target="_blank" rel={isExternal ? "noopener" : "2"} />"#,
Some(serde_json::json!([{ "allowReferrer": true }])),
None,
),
(
r#"<form action={action} target="_blank" />"#,
r#"<form action={action} target="_blank" />"#,
Some(serde_json::json!([{ "allowReferrer": true, "forms": true }])),
None,
),
(
r#"<form action={action} target="_blank" />"#,
r#"<form action={action} target="_blank" />"#,
Some(serde_json::json!([{ "forms": true }])),
None,
),
(
r"<form action={action} {...spread} />",
r"<form action={action} {...spread} />",
Some(serde_json::json!([{ "forms": true, "warnOnSpreadAttributes": true }])),
None,
),
(
r"<a target='_blank' href='//example.com' rel='noopener'></a>",
r"<a target='_blank' href='//example.com' rel='noopener noreferrer'></a>",
None,
None,
),
(
r#"<a target="_blank" href="//example.com" rel={'noopener'}></a>"#,
r#"<a target="_blank" href="//example.com" rel={'noopener noreferrer'}></a>"#,
None,
None,
),
(
r#"<a {...props} target="_blank" rel="noopener" href="//example.com"></a>"#,
r#"<a {...props} target="_blank" rel="noopener noreferrer" href="//example.com"></a>"#,
Some(serde_json::json!([{ "warnOnSpreadAttributes": true }])),
None,
),
];
Tester::new(JsxNoTargetBlank::NAME, JsxNoTargetBlank::PLUGIN, pass, fail)
.expect_fix(fix)
.test_and_snapshot();
}