use std::path::Path;
use oxc_ast::ast::*;
use oxc_ast_visit::Visit;
use oxc_span::GetSpan;
use crate::rules::{Issue, IssueCategory, IssueSource, Rule, RuleContext, Severity};
use crate::utils::offset_to_line_col;
pub struct NoDangerouslySetInnerHtmlUnescaped;
impl Rule for NoDangerouslySetInnerHtmlUnescaped {
fn name(&self) -> &str {
"no_dangerously_set_inner_html_unescaped"
}
fn run(&self, ctx: &RuleContext<'_>) -> Vec<Issue> {
let mut visitor = Visitor {
source_text: ctx.source_text,
file_path: ctx.file_path,
issues: vec![],
};
visitor.visit_program(ctx.program);
visitor.issues
}
}
struct Visitor<'a> {
source_text: &'a str,
file_path: &'a Path,
issues: Vec<Issue>,
}
const UNSAFE_SANITIZERS: &[&str] = &[
"marked",
"marked.parse",
"showdown",
"sanitizeHtml", "micromark",
"snarkdown",
"commonmark",
];
const SAFE_SANITIZERS: &[&str] = &[
"DOMPurify.sanitize",
"sanitize", "xss",
"escapeHtml",
"escape",
];
impl<'a, 'b> Visit<'b> for Visitor<'a> {
fn visit_jsx_attribute(&mut self, attr: &JSXAttribute<'b>) {
let JSXAttributeName::Identifier(name) = &attr.name else {
return;
};
if name.name.as_str() != "dangerouslySetInnerHTML" {
return;
}
let Some(JSXAttributeValue::ExpressionContainer(container)) = &attr.value else {
return;
};
let Some(expr) = container.expression.as_expression() else {
return;
};
let Expression::ObjectExpression(obj) = expr else {
return;
};
for prop in &obj.properties {
let ObjectPropertyKind::ObjectProperty(kv) = prop else {
continue;
};
let is_html_key = match &kv.key {
PropertyKey::StaticIdentifier(id) => id.name.as_str() == "__html",
PropertyKey::StringLiteral(s) => s.value.as_str() == "__html",
_ => false,
};
if !is_html_key {
continue;
}
self.check_html_value(&kv.value);
}
}
}
impl<'a> Visitor<'a> {
fn check_html_value<'b>(&mut self, value: &Expression<'b>) {
match value {
Expression::StringLiteral(_) => {}
Expression::CallExpression(call) => {
let callee_name = callee_as_string(&call.callee);
if SAFE_SANITIZERS.iter().any(|s| callee_name.contains(s)) {
return;
}
if let Some(unsafe_name) =
UNSAFE_SANITIZERS.iter().find(|s| callee_name.contains(*s))
{
let (line, col) = offset_to_line_col(self.source_text, call.span.start);
self.issues.push(Issue {
rule: "no_dangerously_set_inner_html_unescaped".into(),
message: format!(
"`{unsafe_name}()` does not sanitise HTML — \
use DOMPurify.sanitize() to prevent XSS"
),
file: self.file_path.to_path_buf(),
line,
column: col,
severity: Severity::Critical,
source: IssueSource::ReactPerfAnalyzer,
category: IssueCategory::Security,
});
} else {
let (line, col) = offset_to_line_col(self.source_text, call.span.start);
self.issues.push(Issue {
rule: "no_dangerously_set_inner_html_unescaped".into(),
message: format!(
"`__html: {callee_name}(...)` — verify this function \
sanitises HTML with DOMPurify to prevent XSS"
),
file: self.file_path.to_path_buf(),
line,
column: col,
severity: Severity::High,
source: IssueSource::ReactPerfAnalyzer,
category: IssueCategory::Security,
});
}
}
_ => {
let (line, col) = offset_to_line_col(self.source_text, value.span().start);
self.issues.push(Issue {
rule: "no_dangerously_set_inner_html_unescaped".into(),
message: "Raw expression in `dangerouslySetInnerHTML.__html` — \
sanitise with DOMPurify.sanitize() to prevent XSS"
.into(),
file: self.file_path.to_path_buf(),
line,
column: col,
severity: Severity::Critical,
source: IssueSource::ReactPerfAnalyzer,
category: IssueCategory::Security,
});
}
}
}
}
fn callee_as_string(callee: &Expression<'_>) -> String {
match callee {
Expression::Identifier(id) => id.name.as_str().to_string(),
Expression::StaticMemberExpression(mem) => {
let obj = callee_as_string(&mem.object);
let prop = mem.property.name.as_str();
format!("{obj}.{prop}")
}
_ => String::new(),
}
}