UNPKG

eslint-plugin-no-unsanitized

Version:
582 lines (541 loc) 22.6 kB
/** * @file ESLint helpers for checking sanitization * @author Frederik Braun et al. * @copyright 2015-2017 Mozilla Corporation. All rights reserved. */ "use strict"; // names of escaping functions that we acknowledge const VALID_ESCAPERS = ["Sanitizer.escapeHTML", "escapeHTML"]; const VALID_UNWRAPPERS = ["Sanitizer.unwrapSafeHTML", "unwrapSafeHTML"]; /** * Gets the scope for a node taking account of where the scope function * is available (supports node versions earlier than 8.37.0). * * @param {object} context * The context passed from ESLint. * @param {object} node * The node to get the scope for. * @returns {Function} * The getScope function object. */ function getScope(context, node) { return context.sourceCode?.getScope ? context.sourceCode.getScope(node) : context.getScope(node); } /** * Change this to class RuleHelper when <4.2.6 is no longer an issue * * @class * @param {object} context ESLint configuration context * @param {object} defaultRuleChecks Default rules to merge with * this.context */ function RuleHelper(context, defaultRuleChecks) { this.context = context; this.ruleChecks = this.combineRuleChecks(defaultRuleChecks); } RuleHelper.prototype = { /** * Returns true if the expression contains allowed syntax, otherwise false. * * The function will be called recursively for Template Strings with interpolation * (e.g. `Hello ${name}`), Binary Expressions (e.g. |foo+bar|), and more. * * @param {object} expression Checks whether this node is an allowed expression. * @param {object} escapeObject contains keys "methods" and "taggedTemplates" which are arrays of strings * of matching escaping function names. * @param {object} details Additional linter violation state information, in case this function was called * recursively. * @returns {boolean} Returns whether the expression is allowed. */ allowedExpression(expression, escapeObject, details) { if (!escapeObject) { escapeObject = {}; } /* expression = { right-hand side of innerHTML or 2nd param to insertAdjacentHTML */ let allowed; /* check the stringish-part, which is either the right-hand-side of an inner/outerHTML assignment or the 2nd parameter to insertAdjacentTML */ switch (expression.type) { case "Literal": /* surely, someone could have an evil literal in there, but that"s malice we can just check for unsafe coding practice, not outright malice example literal "<script>eval(location.hash.slice(1)</script>" (it"s the task of the tagger-function to be the gateway here.) */ allowed = true; break; case "TemplateElement": // Raw text from a template allowed = true; break; case "TemplateLiteral": // check only the ${..} expressions allowed = this.allowedExpression( expression.expressions, escapeObject, details ); break; case "TaggedTemplateExpression": allowed = this.isAllowedCallExpression( expression.tag, escapeObject.taggedTemplates || VALID_ESCAPERS ); break; case "CallExpression": allowed = this.isAllowedCallExpression( expression.callee, escapeObject.methods || VALID_UNWRAPPERS ); break; case "BinaryExpression": allowed = this.allowedExpression( expression.left, escapeObject, details ) && this.allowedExpression( expression.right, escapeObject, details ); break; case "TSAsExpression": // TSAsExpressions contain the raw javascript value in 'expression' allowed = this.allowedExpression( expression.expression, escapeObject, details ); break; case "TypeCastExpression": allowed = this.allowedExpression( expression.expression, escapeObject, details ); break; case "Identifier": allowed = this.isAllowedIdentifier( expression, escapeObject, details ); break; default: // everything that doesn't match is considered unsafe: allowed = false; break; } if (Array.isArray(expression)) { allowed = expression.every((e) => this.allowedExpression(e, escapeObject, details) ); } return allowed; }, /** * Check if an identifier is allowed * - only if variableTracing is enabled in the first place. * - find its declarations and see if it's const or let * - if so, allow if the declaring statement is an allowed expression * - ensure that following assignments to that identifier are also allowed * * @param {object} expression Identifier expression * @param {object} escapeObject contains keys "methods" and "taggedTemplates" which are arrays of strings * of matching escaping function names. * @param {object} details Additional linter violation state information, in case this function was called * recursively. * @returns {boolean} Returns whether the Identifier is deemed safe. */ isAllowedIdentifier(expression, escapeObject, details) { // respect the custom config property `variableTracing`: if (!this.ruleChecks["variableTracing"]) { return false; } // Initialize the checking stack if it doesn't exist if (!details.checkingIdentifiers) { details.checkingIdentifiers = new Set(); } // Prevent infinite recursion by checking if we're already analyzing this identifier if (details.checkingIdentifiers.has(expression.name)) { return false; } // Add this identifier to the checking stack details.checkingIdentifiers.add(expression.name); // find declared variables and see which are literals const scope = getScope(this.context, expression); const variableInfo = scope.variableScope.set.get(expression.name); let allowed = false; // If we can't get info on the variable, we just can't allow it if ( !variableInfo || !variableInfo.defs || variableInfo.defs.length == 0 || !variableInfo.references || variableInfo.references.length == 0 ) { // FIXME Fix/Adjust towards a helpful message here and update tests accordingly. // details.message = `Variable ${expression.name} considered unsafe: variable initialization not found`; return false; } // look if the var was defined as allowable let definedAsAllowed = false; for (const def of variableInfo.defs) { if (def.node.type !== "VariableDeclarator") { // identifier wasn't declared as a variable // e.g., it shows up as a parameter to an // ArrowFunctionExpression, FunctionDeclaration or FunctionExpression const { line, column } = def.node.loc.start; if ( def.node.type === "FunctionDeclaration" || def.node.type == "ArrowFunctionExpression" || def.node.type === "FunctionExpression" ) { details.message = `Variable '${expression.name}' declared as function parameter, which is considered unsafe. '${def.node.type}' at ${line}:${column}`; } else { details.message = `Variable '${expression.name}' initialized with unknown declaration '${def.node.type}' at ${line}:${column}`; } definedAsAllowed = false; break; } if (def.kind !== "let" && def.kind !== "const") { // We do not allow for identifiers declared with "var", as they can be overridden in a // way that is hard for us to follow (e.g., assignments to globalThis[theirNameAsString]). definedAsAllowed = false; break; } // the `init` property carries the right-hand side of the variable definition: const varInitAs = def.node.init; // When the variable is only declared but not initialized, `init` is `null`. if ( varInitAs && !this.allowedExpression(varInitAs, escapeObject, details) ) { // if one variable definition is considered unsafe, all are. // NB: order of definition is unclear. See issue #168. if (!details.message) { const { line, column } = varInitAs.loc.start; details.message = `Variable '${expression.name}' initialized with unsafe value at ${line}:${column}`; } definedAsAllowed = false; break; } // keep iterating through other definitions. definedAsAllowed = true; } if (definedAsAllowed) { // the variable was declared as a safe value (e.g., literal) // now inspect writing references to that variable let allWritingRefsAllowed = false; // With no write variable references, if it was defined as allowed // then we should consider it safe. if ( variableInfo.references.filter((ref) => ref.isWrite()) .length === 0 ) { allWritingRefsAllowed = true; } for (const ref of variableInfo.references) { // only look into writing references if (ref.isWrite()) { const writeExpr = ref.writeExpr; // if one is unsafe we'll consider all unsafe. // this is because code occurring doesn't guarantee it being executed // due to dynamic behavior if-conditions and such if ( !this.allowedExpression( writeExpr, escapeObject, details ) ) { if (!details.message) { const { line, column } = writeExpr.loc.start; details.message = `Variable '${expression.name}' reassigned with unsafe value at ${line}:${column}`; } allWritingRefsAllowed = false; break; } allWritingRefsAllowed = true; } } // allow this variable, because all writing references to it were allowed. allowed = allWritingRefsAllowed; } // Remove this identifier from the checking stack before returning details.checkingIdentifiers.delete(expression.name); return allowed; }, /** * Check if a callee is in the list allowed sanitizers * * @param {object} callee Function that is being called expression.tag * or expression.callee * @param {Array} allowedSanitizers List of valid wrapping expressions * @returns {boolean} Returns whether call to the callee is allowed */ isAllowedCallExpression(callee, allowedSanitizers) { const funcName = this.getCodeName(callee); let allowed = false; if (funcName && allowedSanitizers.indexOf(funcName) !== -1) { allowed = true; } return allowed; }, /** * Captures safely any new node types that have been missed and throw when we don't support them * this normalizes the passed in identifier type to return the same shape * * @param {object} node A callable expression to be simplified * @returns {object} Method and (if applicable) object name */ normalizeMethodCall(node) { let methodName; let objectName; switch (node.type) { case "Identifier": methodName = node.name; break; case "MemberExpression": methodName = node.property.name; objectName = node.object.name || (this.context.sourceCode?.getText ? this.context.sourceCode.getText(node.object) : this.context.getSource(node.object)); break; case "ConditionalExpression": case "CallExpression": case "ArrowFunctionExpression": methodName = ""; break; case "AssignmentExpression": methodName = this.normalizeMethodCall(node.right); break; case "Import": methodName = "import"; break; default: this.reportUnsupported( node, "Unexpected callable", `unexpected ${node.type} in normalizeMethodCall` ); } return { objectName, methodName, }; }, /** * Returns functionName or objectName.methodName of an expression * * @param {object} node A callable expression * @returns {string} A nice name to expression call */ getCodeName(node) { const normalizedMethodCall = this.normalizeMethodCall(node); let codeName = normalizedMethodCall.methodName; if (normalizedMethodCall.objectName) { codeName = `${normalizedMethodCall.objectName}.${codeName}`; } return codeName; }, /** * Checks to see if a method or function should be called * If objectMatches isn't present or blank array the code should not be checked * If we do have object filters and the call is a function then it should not be checked * * Checks if there are objectMatches we need to apply * * @param {object} node Call expression node * @param {object} objectMatches Strings that are checked as regex to * match an object name * @returns {boolean} Returns whether to run checks expression */ shouldCheckMethodCall(node, objectMatches) { const normalizedMethodCall = this.normalizeMethodCall(node.callee); let matched = false; // Allow methods named "import": if ( normalizedMethodCall.methodName === "import" && node.callee && node.callee.type === "MemberExpression" ) { return false; } // If objectMatches isn't present we should match all if (!objectMatches) { return true; } // if blank array the code should not be checked, this is a quick way to disable rules // TODO should we make this match all instead and let the $ruleCheck be false instead? if (objectMatches.length === 0) { return false; } // If we do have object filters and the call is a function then it should not be checked if ( "objectName" in normalizedMethodCall && normalizedMethodCall.objectName ) { for (const objectMatch of objectMatches) { const match = new RegExp(objectMatch, "gi"); if (normalizedMethodCall.objectName.match(match)) { matched = true; break; } } } // if we don't have a objectName return false as bare function call // if we didn't match also return false return matched; }, /** * Algorithm used to decide on merging ruleChecks with this.context * * @param {object} defaultRuleChecks Object containing default rules * @returns {object} The merged ruleChecks */ combineRuleChecks(defaultRuleChecks) { const parentRuleChecks = this.context.options[0] || {}; let childRuleChecks = Object.assign({}, this.context.options[1]); const ruleCheckOutput = {}; if ( !("defaultDisable" in parentRuleChecks) || !parentRuleChecks.defaultDisable ) { childRuleChecks = Object.assign( {}, defaultRuleChecks, childRuleChecks ); } // default to variable back tracing enabled. ruleCheckOutput["variableTracing"] = true; if ("variableTracing" in parentRuleChecks) { ruleCheckOutput["variableTracing"] = !!parentRuleChecks["variableTracing"]; } // If we have defined child rules lets ignore default rules Object.keys(childRuleChecks).forEach((ruleCheckKey) => { // However if they have missing keys merge with default const ruleCheck = Object.assign( "defaultDisable" in parentRuleChecks ? {} : { escape: { taggedTemplates: [ "Sanitizer.escapeHTML", "escapeHTML", ], methods: [ "Sanitizer.unwrapSafeHTML", "unwrapSafeHTML", ], }, }, defaultRuleChecks[ruleCheckKey], parentRuleChecks, childRuleChecks[ruleCheckKey] ); ruleCheckOutput[ruleCheckKey] = ruleCheck; }); return ruleCheckOutput; }, /** * Runs the checks against a CallExpression * * @param {object} node Call expression node * @returns {undefined} Does not return */ checkMethod(node) { const normalizeMethodCall = this.normalizeMethodCall(node.callee); const methodName = normalizeMethodCall.methodName; if (Object.prototype.hasOwnProperty.call(this.ruleChecks, methodName)) { const ruleCheck = this.ruleChecks[methodName]; if (!Array.isArray(ruleCheck.properties)) { this.context.report( node, `Method check requires properties array in eslint rule ${methodName}` ); return; } ruleCheck.properties.forEach((propertyId) => { const argument = node.arguments[propertyId]; if (!argument) { // We bail out if arguments is supplied as a SpreadElement like `...args` // It would be better if we tried a bit harder. That's #214 return; } const details = {}; if ( this.shouldCheckMethodCall(node, ruleCheck.objectMatches) && !this.allowedExpression(argument, ruleCheck.escape, details) ) { // Include the additional details if available (e.g. name of a disallowed variable // and the position of the expression that made it disallowed). if (details.message) { this.context.report( node, `Unsafe call to ${this.getCodeName(node.callee)} for argument ${propertyId} (${details.message})` ); return; } this.context.report( node, `Unsafe call to ${this.getCodeName(node.callee)} for argument ${propertyId}` ); } }); } }, /** * Runs the checks against an assignment expression * * @param {object} node Assignment expression node * @returns {undefined} Does not return */ checkProperty(node) { if ( Object.prototype.hasOwnProperty.call( this.ruleChecks, node.left.property.name ) ) { const ruleCheck = this.ruleChecks[node.left.property.name]; const details = {}; if ( !this.allowedExpression(node.right, ruleCheck.escape, details) ) { // Include the additional details if available (e.g. name of a disallowed variable // and the position of the expression that made it disallowed). if (details.message) { this.context.report( node, `Unsafe assignment to ${node.left.property.name} (${details.message})` ); return; } this.context.report( node, `Unsafe assignment to ${node.left.property.name}` ); } } }, reportUnsupported(node, reason, errorTitle) { const bugPath = `https://github.com/mozilla/eslint-plugin-no-unsanitized/issues/new?title=${encodeURIComponent(errorTitle)}`; this.context.report( node, `Error in no-unsanitized: ${reason}. Please report a minimal code snippet to the developers at ${bugPath}` ); }, }; module.exports = RuleHelper;