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 NoUnsafeHref;
impl Rule for NoUnsafeHref {
fn name(&self) -> &str {
"no_unsafe_href"
}
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 DANGEROUS_PROPS: &[&str] = &["href", "to", "src", "action", "formAction"];
impl<'a, 'b> Visit<'b> for Visitor<'a> {
fn visit_jsx_attribute(&mut self, attr: &JSXAttribute<'b>) {
let name = match &attr.name {
JSXAttributeName::Identifier(id) => id.name.as_str(),
JSXAttributeName::NamespacedName(nn) => nn.name.name.as_str(),
};
if !DANGEROUS_PROPS.contains(&name) {
return;
}
let Some(JSXAttributeValue::ExpressionContainer(container)) = &attr.value else {
return;
};
let Some(expr) = container.expression.as_expression() else {
return;
};
match expr {
Expression::StringLiteral(s) => {
let val = s.value.as_str().trim().to_lowercase();
if val.starts_with("javascript:") {
let (line, col) = offset_to_line_col(self.source_text, s.span.start);
self.issues.push(Issue {
rule: "no_unsafe_href".into(),
message: format!(
"`{name}` prop contains `javascript:` URL — this enables XSS"
),
file: self.file_path.to_path_buf(),
line,
column: col,
severity: Severity::High,
source: IssueSource::ReactPerfAnalyzer,
category: IssueCategory::Security,
});
}
}
Expression::TemplateLiteral(tpl) => {
let raw_start = tpl
.quasis
.first()
.map(|q| q.value.raw.as_str().trim().to_lowercase())
.unwrap_or_default();
if raw_start.starts_with("javascript:") {
let (line, col) = offset_to_line_col(self.source_text, tpl.span.start);
self.issues.push(Issue {
rule: "no_unsafe_href".into(),
message: format!(
"`{name}` template literal starts with `javascript:` — XSS risk"
),
file: self.file_path.to_path_buf(),
line,
column: col,
severity: Severity::Critical,
source: IssueSource::ReactPerfAnalyzer,
category: IssueCategory::Security,
});
}
}
Expression::Identifier(_)
| Expression::StaticMemberExpression(_)
| Expression::CallExpression(_) => {
let (line, col) = offset_to_line_col(self.source_text, expr.span().start);
self.issues.push(Issue {
rule: "no_unsafe_href".into(),
message: format!(
"`{name}` prop is set from a dynamic expression — \
validate the URL to prevent open redirect or XSS"
),
file: self.file_path.to_path_buf(),
line,
column: col,
severity: Severity::Medium,
source: IssueSource::ReactPerfAnalyzer,
category: IssueCategory::Security,
});
}
_ => {}
}
}
}