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

Update: consider env in no-implied-eval (fixes #12733) #12757

Merged
merged 11 commits into from Mar 21, 2020
180 changes: 79 additions & 101 deletions lib/rules/no-implied-eval.js
Expand Up @@ -5,6 +5,13 @@

"use strict";

//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

const astUtils = require("./utils/ast-utils");
const { getStaticValue } = require("eslint-utils");

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
Expand All @@ -28,141 +35,112 @@ module.exports = {
},

create(context) {
const CALLEE_RE = /^(setTimeout|setInterval|execScript)$/u;

/*
* Figures out if we should inspect a given binary expression. Is a stack
* of stacks, where the first element in each substack is a CallExpression.
*/
const impliedEvalAncestorsStack = [];

//--------------------------------------------------------------------------
// Helpers
//--------------------------------------------------------------------------
const EVAL_LIKE_FUNCS = Object.freeze(["setTimeout", "execScript", "setInterval"]);
const GLOBAL_CANDIDATES = Object.freeze(["global", "window"]);
aladdin-add marked this conversation as resolved.
Show resolved Hide resolved

/**
* Get the last element of an array, without modifying arr, like pop(), but non-destructive.
* @param {Array} arr What to inspect
* @returns {*} The last element of arr
* @private
* Checks whether a node is evaluated as a string or not.
* @param {ASTNode} node A node to check.
* @returns {boolean} True if the node is evaluated as a string.
*/
function last(arr) {
return arr ? arr[arr.length - 1] : null;
}

/**
* Checks if the given MemberExpression node is a potentially implied eval identifier on window.
* @param {ASTNode} node The MemberExpression node to check.
* @returns {boolean} Whether or not the given node is potentially an implied eval.
* @private
*/
function isImpliedEvalMemberExpression(node) {
const object = node.object,
property = node.property,
hasImpliedEvalName = CALLEE_RE.test(property.name) || CALLEE_RE.test(property.value);

return object.name === "window" && hasImpliedEvalName;
function isEvaluatedString(node) {
if (
(node.type === "Literal" && typeof node.value === "string") ||
node.type === "TemplateLiteral"
) {
return true;
}
if (node.type === "BinaryExpression" && node.operator === "+") {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder about some other cases that can result in string coercion like:

String(val);
val.toString();
JSON.stringify(val);

Is there a way to reliably enforce these cases? We should be able to check if they're the globals for String and JSON, but I don't know if X.prototype.toString() can be reliably checked (though it might be a fair assumption to assume it returns a string, given the name).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kaicataldo
I'll try but I'm not sure to do that. Can I work on that in another PR?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be fine to do that, since this does already improve the rule. Let's see what others have to say.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd also agree that this could be a nice future improvement.

Maybe we could also use getstaticvalue to catch code like:

var s = "alert('Hi!');";
setTimeout(s, 100);

And perhaps add self to the list of global objects.

return isEvaluatedString(node.left) || isEvaluatedString(node.right);
}
return false;
}

/**
* Determines if a node represents a call to a potentially implied eval.
*
* This checks the callee name and that there's an argument, but not the type of the argument.
* @param {ASTNode} node The CallExpression to check.
* @returns {boolean} True if the node matches, false if not.
* @private
* Checks whether a node is an Identifier node named one of the specified names.
* @param {ASTNode} node A node to check.
* @param {string[]} specifiers Array of specified name.
* @returns {boolean} True if the node is a Identifier node which has specified name.
*/
function isImpliedEvalCallExpression(node) {
const isMemberExpression = (node.callee.type === "MemberExpression"),
isIdentifier = (node.callee.type === "Identifier"),
isImpliedEvalCallee =
(isIdentifier && CALLEE_RE.test(node.callee.name)) ||
(isMemberExpression && isImpliedEvalMemberExpression(node.callee));

return isImpliedEvalCallee && node.arguments.length;
function isSpecifiedIdentifier(node, specifiers) {
return node.type === "Identifier" && specifiers.includes(node.name);
}

/**
* Checks that the parent is a direct descendent of an potential implied eval CallExpression, and if the parent is a CallExpression, that we're the first argument.
* @param {ASTNode} node The node to inspect the parent of.
* @returns {boolean} Was the parent a direct descendent, and is the child therefore potentially part of a dangerous argument?
* @private
* Checks a given node is a MemberExpression node which has the specified name's
* property.
* @param {ASTNode} node A node to check.
* @param {string[]} specifiers Array of specified name.
* @returns {boolean} `true` if the node is a MemberExpression node which has
* the specified name's property
*/
function hasImpliedEvalParent(node) {

// make sure our parent is marked
return node.parent === last(last(impliedEvalAncestorsStack)) &&

// if our parent is a CallExpression, make sure we're the first argument
(node.parent.type !== "CallExpression" || node === node.parent.arguments[0]);
function isSpecifiedMember(node, specifiers) {
return node.type === "MemberExpression" && specifiers.includes(astUtils.getStaticPropertyName(node));
}

/**
* Checks if our parent is marked as part of an implied eval argument. If
* so, collapses the top of impliedEvalAncestorsStack and reports on the
* original CallExpression.
* @param {ASTNode} node The CallExpression to check.
* @returns {boolean} True if the node matches, false if not.
* @private
* Reports if the `CallExpression` node has evaluated argument.
* @param {ASTNode} node A CallExpression to check.
* @returns {void}
*/
function checkString(node) {
if (hasImpliedEvalParent(node)) {
function reportImpliedEvalCallExpression(node) {
const [firstArgument] = node.arguments;

// remove the entire substack, to avoid duplicate reports
const substack = impliedEvalAncestorsStack.pop();
const staticValue = firstArgument && getStaticValue(firstArgument, context.getScope());
const isStaticString = staticValue && typeof staticValue.value === "string";
const isString = isStaticString || isEvaluatedString(firstArgument);
yeonjuan marked this conversation as resolved.
Show resolved Hide resolved

if (firstArgument && isString) {
context.report({
node: substack[0],
node,
messageId: "impliedEval"
});
}
}

//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Reports calls of `implied eval` via the global references.
* @param {Variable} globalVar A global variable to check.
* @returns {void}
*/
function reportImpliedEvalViaGlobal(globalVar) {
const { references, name } = globalVar;

return {
CallExpression(node) {
if (isImpliedEvalCallExpression(node)) {
references.forEach(ref => {
const identifier = ref.identifier;
let node = identifier.parent;

// call expressions create a new substack
impliedEvalAncestorsStack.push([node]);
while (isSpecifiedMember(node, [name])) {
node = node.parent;
}
},

"CallExpression:exit"(node) {
if (node === last(last(impliedEvalAncestorsStack))) {

/*
* Destroys the entire sub-stack, rather than just using
* last(impliedEvalAncestorsStack).pop(), as a CallExpression is
* always the bottom of a impliedEvalAncestorsStack substack.
*/
impliedEvalAncestorsStack.pop();
}
},
if (isSpecifiedMember(node, EVAL_LIKE_FUNCS)) {
const parent = node.parent;

BinaryExpression(node) {
if (node.operator === "+" && hasImpliedEvalParent(node)) {
last(impliedEvalAncestorsStack).push(node);
if (parent.type === "CallExpression" && parent.callee === node) {
reportImpliedEvalCallExpression(parent);
}
}
},
});
}

"BinaryExpression:exit"(node) {
if (node === last(last(impliedEvalAncestorsStack))) {
last(impliedEvalAncestorsStack).pop();
}
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------

Literal(node) {
if (typeof node.value === "string") {
checkString(node);
return {
CallExpression(node) {
if (isSpecifiedIdentifier(node.callee, EVAL_LIKE_FUNCS)) {
reportImpliedEvalCallExpression(node);
}
},
"Program:exit"() {
const globalScope = context.getScope();

TemplateLiteral(node) {
checkString(node);
GLOBAL_CANDIDATES
.map(candidate => astUtils.getVariableByName(globalScope, candidate))
.filter(globalVar => !!globalVar && globalVar.defs.length === 0)
.forEach(reportImpliedEvalViaGlobal);
}
};

Expand Down