Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(linter): implement no-extend-native rule #5867

Merged
merged 17 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/oxc_linter/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ mod eslint {
pub mod no_eq_null;
pub mod no_eval;
pub mod no_ex_assign;
pub mod no_extend_native;
pub mod no_extra_boolean_cast;
pub mod no_fallthrough;
pub mod no_func_assign;
Expand Down Expand Up @@ -531,6 +532,7 @@ oxc_macros::declare_all_lint_rules! {
eslint::no_eq_null,
eslint::no_eval,
eslint::no_ex_assign,
eslint::no_extend_native,
eslint::no_extra_boolean_cast,
eslint::no_fallthrough,
eslint::no_func_assign,
Expand Down
331 changes: 331 additions & 0 deletions crates/oxc_linter/src/rules/eslint/no_extend_native.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
use oxc_ast::ast::{CallExpression, ChainElement, Expression};
use oxc_ast::{ast::MemberExpression, AstKind};
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::cmp::ContentEq;
use oxc_span::{CompactStr, GetSpan};

use crate::{context::LintContext, rule::Rule, AstNode};

#[derive(Debug, Default, Clone)]
pub struct NoExtendNative(Box<NoExtendNativeConfig>);

#[derive(Debug, Default, Clone)]
pub struct NoExtendNativeConfig {
/// A list of objects which are allowed to be exceptions to the rule.
exceptions: Vec<CompactStr>,
}

impl std::ops::Deref for NoExtendNative {
type Target = NoExtendNativeConfig;

fn deref(&self) -> &Self::Target {
&self.0
}
}

declare_oxc_lint!(
/// ### What it does
///
/// Prevents extending native global objects such as `Object`, `String`, or `Array` with new
/// properties.
///
/// ### Why is this bad?
///
/// Extending native objects can cause unexpected behavior and conflicts with other code.
///
/// For example:
/// ```js
/// // Adding a new property, which might seem okay
/// Object.prototype.extra = 55;
///
/// // Defining a user object
/// const users = {
/// "1": "user1",
/// "2": "user2",
/// };
///
/// for (const id in users) {
/// // This will print "extra" as well as "1" and "2":
/// console.log(id);
/// }
/// ```
///
/// ### Examples
///
/// Examples of **incorrect** code for this rule:
/// ```js
/// Object.prototype.p = 0
/// Object.defineProperty(Array.prototype, 'p', {value: 0})
/// ```
///
/// Examples of **correct** code for this rule:
/// ```js
/// x.prototype.p = 0
/// Object.defineProperty(x.prototype, 'p', {value: 0})
/// ```
NoExtendNative,
suspicious,
);

impl Rule for NoExtendNative {
fn from_configuration(value: serde_json::Value) -> Self {
let obj = value.get(0);

Self(Box::new(NoExtendNativeConfig {
exceptions: obj
.and_then(|v| v.get("exceptions"))
.and_then(serde_json::Value::as_array)
.unwrap_or(&vec![])
.iter()
.filter_map(serde_json::Value::as_str)
.map(CompactStr::from)
.collect(),
}))
}

fn run_once(&self, ctx: &LintContext) {
let symbols = ctx.symbols();
for reference_id_list in ctx.scopes().root_unresolved_references_ids() {
for reference_id in reference_id_list {
let reference = symbols.get_reference(reference_id);
let name = ctx.semantic().reference_name(reference);
// If the referenced name does not appear to be a global object, skip it.
if !ctx.env_contains_var(name) {
continue;
}
// If the referenced name is explicitly allowed, skip it.
let compact_name = CompactStr::from(name);
if self.exceptions.contains(&compact_name) {
continue;
}
// If the first letter is capital, like `Object`, we will assume it is a native object
let Some(first_char) = name.chars().next() else {
continue;
};
if first_char.is_lowercase() {
continue;
}
let node = ctx.nodes().get_node(reference.node_id());
// If this is not `*.prototype` access, skip it.
let Some(prop_access) = get_prototype_property_accessed(ctx, node) else {
continue;
};
// Check if being used like `String.prototype.xyz = 0`
if let Some(prop_assign) = get_property_assignment(ctx, prop_access) {
ctx.diagnostic(
OxcDiagnostic::error(format!(
"{name} prototype is read-only, properties should not be added."
))
.with_label(prop_assign.span()),
);
}
// Check if being used like `Object.defineProperty(String.prototype, 'xyz', 0)`
else if let Some(define_property_call) =
get_define_property_call(ctx, prop_access)
{
ctx.diagnostic(
OxcDiagnostic::error(format!(
"{name} prototype is read-only, properties should not be added."
))
.with_label(define_property_call.span()),
);
}
}
}
}
}

/// If this usage of `*.prototype` is a `Object.defineProperty` or `Object.defineProperties` call,
/// then this function returns the `CallExpression` node.
fn get_define_property_call<'a>(
ctx: &'a LintContext,
node: &AstNode<'a>,
) -> Option<&'a AstNode<'a>> {
let mut ancestor = ctx.nodes().parent_node(node.id())?;
camchenry marked this conversation as resolved.
Show resolved Hide resolved
loop {
if let AstKind::CallExpression(call_expr) = ancestor.kind() {
if !is_define_property_call(call_expr) {
return None;
}
return Some(ancestor);
} else if let AstKind::ChainExpression(_) | AstKind::Argument(_) = ancestor.kind() {
ancestor = ctx.nodes().parent_node(ancestor.id())?;
} else {
return None;
}
}
}

/// Checks if a given `CallExpression` is a call to `Object.defineProperty` or `Object.defineProperties`.
fn is_define_property_call(call_expr: &CallExpression) -> bool {
let callee = call_expr.callee.without_parentheses();

let member_expression = if let Expression::ChainExpression(chain_expr) = callee {
chain_expr.expression.as_member_expression()
} else {
callee.as_member_expression()
};
match member_expression {
Some(me) => {
let prop_name = me.static_property_name();
me.object()
.get_identifier_reference()
.is_some_and(|ident_ref| ident_ref.name == "Object")
&& (prop_name == Some("defineProperty") || prop_name == Some("defineProperties"))
}
_ => false,
}
}

/// Get an assignment to the property of the given node.
/// Example: `*.prop = 0` where `*.prop` is the given node.
fn get_property_assignment<'a>(
ctx: &'a LintContext,
node: &AstNode<'a>,
) -> Option<&'a AstNode<'a>> {
let mut parent = ctx.nodes().parent_node(node.id())?;
camchenry marked this conversation as resolved.
Show resolved Hide resolved
loop {
if let AstKind::AssignmentExpression(_) = parent.kind() {
return Some(parent);
} else if let AstKind::AssignmentTarget(_) | AstKind::SimpleAssignmentTarget(_) =
parent.kind()
{
parent = ctx.nodes().parent_node(parent.id())?;
} else if let AstKind::MemberExpression(member_expr) = parent.kind() {
if let MemberExpression::ComputedMemberExpression(computed) = member_expr {
if let AstKind::MemberExpression(node_expr) = node.kind() {
// Ignore computed member expressions like `obj[Object.prototype] = 0` (i.e., the
// given node is the `expression` of the computed member expression)
if computed
.expression
.as_member_expression()
.is_some_and(|expression| expression.content_eq(node_expr))
{
return None;
}
return None;
}
}
parent = ctx.nodes().parent_node(parent.id())?;
} else {
return None;
}
}
}

/// Returns the ASTNode that represents a prototype property access, such as
/// `Object?.['prototype']`
fn get_prototype_property_accessed<'a>(
ctx: &'a LintContext,
node: &AstNode<'a>,
) -> Option<&'a AstNode<'a>> {
let AstKind::IdentifierReference(_) = node.kind() else {
return None;
};
let parent = ctx.nodes().parent_node(node.id())?;
let mut prototype_node = Some(parent);
let AstKind::MemberExpression(prop_access_expr) = parent.kind() else {
return None;
};
let prop_name = prop_access_expr.static_property_name()?;
if prop_name != "prototype" {
return None;
}
let grandparent_node = ctx.nodes().parent_node(parent.id())?;

if let AstKind::ChainExpression(_) = grandparent_node.kind() {
prototype_node = Some(grandparent_node);
if let Some(grandparent_parent) = ctx.nodes().parent_node(grandparent_node.id()) {
prototype_node = Some(grandparent_parent);
}
}

if is_computed_member_expression_matching(grandparent_node, prop_access_expr) {
prototype_node = Some(grandparent_node);
}

prototype_node
}

fn is_computed_member_expression_matching(
node: &AstNode,
prop_access_expr: &MemberExpression,
) -> bool {
match node.kind() {
AstKind::ChainExpression(chain_expr) => {
if let ChainElement::ComputedMemberExpression(computed) = &chain_expr.expression {
return computed
.object
.as_member_expression()
.is_some_and(|object| object.content_eq(prop_access_expr));
}
}
AstKind::MemberExpression(MemberExpression::ComputedMemberExpression(computed)) => {
return computed
.object
.as_member_expression()
.is_some_and(|object| object.content_eq(prop_access_expr));
}
_ => {}
}
false
}

#[test]
fn test() {
use crate::tester::Tester;

let pass = vec![
("x.prototype.p = 0", None),
("x.prototype['p'] = 0", None),
("Object.p = 0", None),
("Object.toString.bind = 0", None),
("Object['toString'].bind = 0", None),
("Object.defineProperty(x, 'p', {value: 0})", None),
("Object.defineProperty(x.prototype, 'p', {value: 0})", None),
("Object.defineProperties(x, {p: {value: 0}})", None),
("global.Object.prototype.toString = 0", None),
("this.Object.prototype.toString = 0", None),
("with(Object) { prototype.p = 0; }", None),
("o = Object; o.prototype.toString = 0", None),
("eval('Object.prototype.toString = 0')", None),
("parseFloat.prototype.x = 1", None),
("Object.prototype.g = 0", Some(serde_json::json!([{ "exceptions": ["Object"] }]))),
("obj[Object.prototype] = 0", None),
("Object.defineProperty()", None),
("Object.defineProperties()", None),
("function foo() { var Object = function() {}; Object.prototype.p = 0 }", None),
("{ let Object = function() {}; Object.prototype.p = 0 }", None), // { "ecmaVersion": 6 }
];

let fail = vec![
("Object.prototype.p = 0", None),
("BigInt.prototype.p = 0", None), // { "ecmaVersion": 2020 },
("WeakRef.prototype.p = 0", None), // { "ecmaVersion": 2021 },
("FinalizationRegistry.prototype.p = 0", None), // { "ecmaVersion": 2021 },
("AggregateError.prototype.p = 0", None), // { "ecmaVersion": 2021 },
("Function.prototype['p'] = 0", None),
("String['prototype'].p = 0", None),
("Number['prototype']['p'] = 0", None),
("Object.defineProperty(Array.prototype, 'p', {value: 0})", None),
("Object['defineProperty'](Array.prototype, 'p', {value: 0})", None),
("Object['defineProperty'](Array['prototype'], 'p', {value: 0})", None),
("Object.defineProperties(Array.prototype, {p: {value: 0}})", None),
("Object.defineProperties(Array.prototype, {p: {value: 0}, q: {value: 0}})", None),
("Number['prototype']['p'] = 0", Some(serde_json::json!([{ "exceptions": ["Object"] }]))),
("Object.prototype.p = 0; Object.prototype.q = 0", None),
("function foo() { Object.prototype.p = 0 }", None),
("(Object?.prototype).p = 0", None), // { "ecmaVersion": 2020 },
("(Object?.['prototype'])['p'] = 0", None),
("Object.defineProperty(Object?.prototype, 'p', { value: 0 })", None), // { "ecmaVersion": 2020 },
("Object?.defineProperty(Object.prototype, 'p', { value: 0 })", None), // { "ecmaVersion": 2020 },
("Object?.['defineProperty'](Object?.['prototype'], 'p', {value: 0})", None),
("(Object?.defineProperty)(Object.prototype, 'p', { value: 0 })", None), // { "ecmaVersion": 2020 },
("Array.prototype.p &&= 0", None), // { "ecmaVersion": 2021 },
("Array.prototype.p ||= 0", None), // { "ecmaVersion": 2021 },
("Array.prototype.p ??= 0", None), // { "ecmaVersion": 2021 }
];

Tester::new(NoExtendNative::NAME, pass, fail).test_and_snapshot();
}
Loading