diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index bf0a1f8a312ab..21c215dbbf8de 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -250,6 +250,7 @@ mod react { pub mod jsx_key; pub mod jsx_no_comment_textnodes; pub mod jsx_no_duplicate_props; + pub mod jsx_no_script_url; pub mod jsx_no_target_blank; pub mod jsx_no_undef; pub mod jsx_no_useless_fragment; @@ -805,6 +806,7 @@ oxc_macros::declare_all_lint_rules! { react::jsx_key, react::jsx_no_comment_textnodes, react::jsx_no_duplicate_props, + react::jsx_no_script_url, react::jsx_no_target_blank, react::jsx_no_undef, react::jsx_no_useless_fragment, diff --git a/crates/oxc_linter/src/rules/react/jsx_no_script_url.rs b/crates/oxc_linter/src/rules/react/jsx_no_script_url.rs new file mode 100644 index 0000000000000..40ebfa68a5a36 --- /dev/null +++ b/crates/oxc_linter/src/rules/react/jsx_no_script_url.rs @@ -0,0 +1,279 @@ +use crate::context::ContextHost; +use crate::{context::LintContext, rule::Rule, AstNode}; +use lazy_static::lazy_static; +use oxc_ast::ast::JSXAttributeItem; +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::{CompactStr, GetSpan, Span}; +use regex::Regex; +use rustc_hash::FxHashMap; +use serde_json::Value; + +fn jsx_no_script_url_diagnostic(span: Span) -> OxcDiagnostic { + // See for details + OxcDiagnostic::warn("A future version of React will block javascript: URLs as a security precaution.") + .with_help("Use event handlers instead if you can. If you need to generate unsafe HTML, try using dangerouslySetInnerHTML instead.") + .with_label(span) +} + +lazy_static! { + static ref JS_SCRIPT_REGEX: Regex = + Regex::new(r"(j|J)[\r\n\t]*(a|A)[\r\n\t]*(v|V)[\r\n\t]*(a|A)[\r\n\t]*(s|S)[\r\n\t]*(c|C)[\r\n\t]*(r|R)[\r\n\t]*(i|I)[\r\n\t]*(p|P)[\r\n\t]*(t|T)[\r\n\t]*:").unwrap(); +} + +#[derive(Debug, Default, Clone)] +pub struct JsxNoScriptUrl(Box); + +#[derive(Debug, Default, Clone)] +pub struct JsxNoScriptUrlConfig { + include_from_settings: bool, + components: FxHashMap>, +} + +impl std::ops::Deref for JsxNoScriptUrl { + type Target = JsxNoScriptUrlConfig; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +declare_oxc_lint!( + /// ### What it does + /// + /// Disallow usage of `javascript:` URLs + /// + /// ### Why is this bad? + /// + /// URLs starting with javascript: are a dangerous attack surface because it’s easy to accidentally include unsanitized output in a tag like and create a security hole. + /// In React 16.9 any URLs starting with javascript: scheme log a warning. + /// In a future major release, React will throw an error if it encounters a javascript: URL. + /// + /// ### Examples + /// + /// Examples of **incorrect** code for this rule: + /// ```jsx + /// Test + /// ``` + /// + /// Examples of **correct** code for this rule: + /// ```jsx + /// + /// ``` + JsxNoScriptUrl, + suspicious, + pending +); + +fn is_link_attribute(tag_name: &str, prop_value_literal: String, ctx: &LintContext) -> bool { + tag_name == "a" + || ctx.settings().react.get_link_component_attrs(tag_name).is_some_and( + |link_component_attrs| { + link_component_attrs.contains(&CompactStr::from(prop_value_literal)) + }, + ) +} + +impl JsxNoScriptUrl { + fn is_link_tag(&self, tag_name: &str, ctx: &LintContext) -> bool { + if !self.include_from_settings { + return tag_name == "a"; + } + if tag_name == "a" { + return true; + } + ctx.settings().react.get_link_component_attrs(tag_name).is_some() + } +} + +impl Rule for JsxNoScriptUrl { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + if let AstKind::JSXOpeningElement(element) = node.kind() { + let Some(component_name) = element.name.get_identifier_name() else { + return; + }; + if let Some(link_props) = self.components.get(component_name.as_str()) { + for jsx_attribute in &element.attributes { + if let JSXAttributeItem::Attribute(attr) = jsx_attribute { + let Some(prop_value) = &attr.value else { + return; + }; + if prop_value.as_string_literal().is_some_and(|val| { + link_props.contains(&attr.name.get_identifier().name.to_string()) + && JS_SCRIPT_REGEX.captures(&val.value).is_some() + }) { + ctx.diagnostic(jsx_no_script_url_diagnostic(attr.span())); + } + } + } + } else if self.is_link_tag(component_name.as_str(), ctx) { + for jsx_attribute in &element.attributes { + if let JSXAttributeItem::Attribute(attr) = jsx_attribute { + let Some(prop_value) = &attr.value else { + return; + }; + if prop_value.as_string_literal().is_some_and(|val| { + is_link_attribute( + component_name.as_str(), + attr.name.get_identifier().name.to_string(), + ctx, + ) && JS_SCRIPT_REGEX.captures(&val.value).is_some() + }) { + ctx.diagnostic(jsx_no_script_url_diagnostic(attr.span())); + } + } + } + } + } + } + + fn from_configuration(value: Value) -> Self { + let mut components: FxHashMap> = FxHashMap::default(); + if let Some(arr) = value.get(0).and_then(Value::as_array) { + for component in arr { + let name = component.get("name").and_then(Value::as_str).unwrap_or("").to_string(); + let props = + component.get("props").and_then(Value::as_array).map_or(vec![], |array| { + array + .iter() + .map(|prop| prop.as_str().map_or(String::new(), String::from)) + .collect::>() + }); + components.insert(name, props); + } + Self(Box::new(JsxNoScriptUrlConfig { + include_from_settings: value.get(1).is_some_and(|conf| { + conf.get("includeFromSettings").and_then(Value::as_bool).is_some_and(|v| v) + }), + components, + })) + } else { + Self(Box::new(JsxNoScriptUrlConfig { + include_from_settings: value.get(0).is_some_and(|conf| { + conf.get("includeFromSettings").and_then(Value::as_bool).is_some_and(|v| v) + }), + components: FxHashMap::default(), + })) + } + } + + fn should_run(&self, ctx: &ContextHost) -> bool { + ctx.source_type().is_jsx() + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + (r#""#, None, None), + (r#""#, None, None), + (r##""##, None, None), + (r#""#, None, None), + (r#""#, None, None), + (r#""#, None, None), + (r#""#, None, None), + ("", None, None), + ( + r#""#, + Some(serde_json::json!([ [{ "name": "Foo", "props": ["to", "href"] }], ])), + None, + ), + ( + r#""#, + None, + Some( + serde_json::json!({ "settings": {"react": {"linkComponents": [{ "name": "Foo", "linkAttribute": ["to", "href"] }]} } }), + ), + ), + ( + r#""#, + Some(serde_json::json!([[], { "includeFromSettings": true }])), + Some( + serde_json::json!({ "settings": {"react": {"linkComponents": [{ "name": "Foo", "linkAttribute": ["to", "href"] }]} } }), + ), + ), + ( + r#""#, + Some(serde_json::json!([[], { "includeFromSettings": false }])), + Some( + serde_json::json!({ "settings": {"react": {"linkComponents": [{ "name": "Foo", "linkAttribute": ["to", "href"] }]} } }), + ), + ), + ]; + + let fail = vec![ + (r#""#, None, None), + (r#""#, None, None), + ( + r#""#, + None, + None, + ), + ( + r#""#, + Some(serde_json::json!([ [{ "name": "Foo", "props": ["to", "href"] }], ])), + None, + ), + ( + r#""#, + Some(serde_json::json!([ [{ "name": "Foo", "props": ["to", "href"] }], ])), + None, + ), + ( + r#""#, + Some(serde_json::json!([ [{ "name": "Foo", "props": ["to", "href"] }], ])), + None, + ), + ( + r#""#, + Some( + serde_json::json!([ [{ "name": "Bar", "props": ["to", "href"] }], { "includeFromSettings": true }, ]), + ), + Some( + serde_json::json!({ "settings": {"react": {"linkComponents": [{ "name": "Foo", "linkAttribute": "to" }]}}}), + ), + ), + ( + r#""#, + Some(serde_json::json!([{ "includeFromSettings": true }])), + Some( + serde_json::json!({ "settings": {"react": {"linkComponents": [{ "name": "Foo", "linkAttribute": ["to", "href"] }]} }}), + ), + ), + ( + r#" +
+ + +
+ "#, + Some( + serde_json::json!([ [{ "name": "Bar", "props": ["link"] }], { "includeFromSettings": true }, ]), + ), + Some( + serde_json::json!({ "settings": {"react": {"linkComponents": [{ "name": "Foo", "linkAttribute": ["to", "href"] }]}} }), + ), + ), + ( + r#" +
+ + +
+ "#, + Some(serde_json::json!([ [{ "name": "Bar", "props": ["link"] }], ])), + Some( + serde_json::json!({ "settings": {"react": {"linkComponents": [{ "name": "Foo", "linkAttribute": ["to", "href"] }]}} }), + ), + ), + ]; + + Tester::new(JsxNoScriptUrl::NAME, pass, fail).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/jsx_no_script_url.snap b/crates/oxc_linter/src/snapshots/jsx_no_script_url.snap new file mode 100644 index 0000000000000..68dc58dbf73e8 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/jsx_no_script_url.snap @@ -0,0 +1,88 @@ +--- +source: crates/oxc_linter/src/tester.rs +--- + ⚠ eslint-plugin-react(jsx-no-script-url): A future version of React will block javascript: URLs as a security precaution. + ╭─[jsx_no_script_url.tsx:1:4] + 1 │ + · ────────────────── + ╰──── + help: Use event handlers instead if you can. If you need to generate unsafe HTML, try using dangerouslySetInnerHTML instead. + + ⚠ eslint-plugin-react(jsx-no-script-url): A future version of React will block javascript: URLs as a security precaution. + ╭─[jsx_no_script_url.tsx:1:4] + 1 │ + · ───────────────────────── + ╰──── + help: Use event handlers instead if you can. If you need to generate unsafe HTML, try using dangerouslySetInnerHTML instead. + + ⚠ eslint-plugin-react(jsx-no-script-url): A future version of React will block javascript: URLs as a security precaution. + ╭─[jsx_no_script_url.tsx:1:4] + 1 │ ╭─▶ + ╰──── + help: Use event handlers instead if you can. If you need to generate unsafe HTML, try using dangerouslySetInnerHTML instead. + + ⚠ eslint-plugin-react(jsx-no-script-url): A future version of React will block javascript: URLs as a security precaution. + ╭─[jsx_no_script_url.tsx:1:6] + 1 │ + · ──────────────── + ╰──── + help: Use event handlers instead if you can. If you need to generate unsafe HTML, try using dangerouslySetInnerHTML instead. + + ⚠ eslint-plugin-react(jsx-no-script-url): A future version of React will block javascript: URLs as a security precaution. + ╭─[jsx_no_script_url.tsx:1:6] + 1 │ + · ────────────────── + ╰──── + help: Use event handlers instead if you can. If you need to generate unsafe HTML, try using dangerouslySetInnerHTML instead. + + ⚠ eslint-plugin-react(jsx-no-script-url): A future version of React will block javascript: URLs as a security precaution. + ╭─[jsx_no_script_url.tsx:1:4] + 1 │ + · ───────────────────────── + ╰──── + help: Use event handlers instead if you can. If you need to generate unsafe HTML, try using dangerouslySetInnerHTML instead. + + ⚠ eslint-plugin-react(jsx-no-script-url): A future version of React will block javascript: URLs as a security precaution. + ╭─[jsx_no_script_url.tsx:1:6] + 1 │ + · ──────────────── + ╰──── + help: Use event handlers instead if you can. If you need to generate unsafe HTML, try using dangerouslySetInnerHTML instead. + + ⚠ eslint-plugin-react(jsx-no-script-url): A future version of React will block javascript: URLs as a security precaution. + ╭─[jsx_no_script_url.tsx:1:6] + 1 │ + · ────────────────── + ╰──── + help: Use event handlers instead if you can. If you need to generate unsafe HTML, try using dangerouslySetInnerHTML instead. + + ⚠ eslint-plugin-react(jsx-no-script-url): A future version of React will block javascript: URLs as a security precaution. + ╭─[jsx_no_script_url.tsx:3:17] + 2 │
+ 3 │ + · ────────────────── + 4 │ + ╰──── + help: Use event handlers instead if you can. If you need to generate unsafe HTML, try using dangerouslySetInnerHTML instead. + + ⚠ eslint-plugin-react(jsx-no-script-url): A future version of React will block javascript: URLs as a security precaution. + ╭─[jsx_no_script_url.tsx:4:17] + 3 │ + 4 │ + · ────────────────── + 5 │
+ ╰──── + help: Use event handlers instead if you can. If you need to generate unsafe HTML, try using dangerouslySetInnerHTML instead. + + ⚠ eslint-plugin-react(jsx-no-script-url): A future version of React will block javascript: URLs as a security precaution. + ╭─[jsx_no_script_url.tsx:4:17] + 3 │ + 4 │ + · ────────────────── + 5 │ + ╰──── + help: Use event handlers instead if you can. If you need to generate unsafe HTML, try using dangerouslySetInnerHTML instead.