diff --git a/.gitignore b/.gitignore
index eb79dd5..32bc532 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
node_modules
.idea
+*.swp
diff --git a/README.md b/README.md
index 6ad4813..867ec37 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
[![Build Status](https://travis-ci.org/mozilla/eslint-plugin-no-unsanitized.svg?branch=master)](https://travis-ci.org/mozilla/eslint-plugin-no-unsanitized)
-# Disallow unsanitized DOM access (no-unsanitized)
+# Disallow unsanitized code (no-unsanitized)
These rules disallow unsafe coding practices that may result into security vulnerabilities. We will disallow assignments to innerHTML as well as calls to insertAdjacentHTML without the use of a pre-defined escaping function. The escaping functions must be called with a template string. The function names are hardcoded as `Sanitizer.escapeHTML` and `escapeHTML`.
@@ -31,6 +31,7 @@ This rule is being used within Mozilla to maintain and improve the security of o
In your eslint.json file enable this rule with the following:
+
```
{
"plugins": ["no-unsanitized"],
@@ -40,3 +41,39 @@ In your eslint.json file enable this rule with the following:
}
}
```
+
+## Advanced configuration
+
+```js
+{
+ "plugins": ["no-unsanitized"],
+ "env": {
+ "no-unsanitized/method": [
+ "error",
+ {
+ disableDefault: true,
+ escape: {
+ taggedTemplates: ["safeHTML"]
+ }
+ },
+ {
+ html: {
+ properties: [0]
+ }
+ }
+ ],
+ "no-unsanitized/method": [
+ "error",
+ {
+ },
+ {
+ innerHTML: {
+ objectMatches: ["document.*"]
+ }
+ }
+ ]
+ }
+}
+```
+
+[To see all available options vitit](./SCHEMA.md)
diff --git a/SCHEMA.md b/SCHEMA.md
new file mode 100644
index 0000000..17ea1a0
--- /dev/null
+++ b/SCHEMA.md
@@ -0,0 +1,43 @@
+## Schema
+
+```js
+{
+ "plugins": ["no-unsanitized"],
+ "env": {
+ // $ruleName is either "method" or "property"
+ "no-unsanitized/$ruleName": ["errorlevel",
+ // This object is optional would use default config for all default ruleChecks
+ {
+ // optional, omitting or length 0 would be considered permitting all objectNames
+ objectMatches: ["$stringRegex"],
+
+ // optional, removes default $ruleCheckName's provided by this rule
+ disableDefault: $boolean,
+
+ // optional, if omitted would disallow use of matched "$ruleCheckName" prop/method
+ escape: {
+ // optional: Tagged template permitted as arguments/assignments
+ taggedTemplates: ["$taggedTemplateFunctionName"],
+
+ // optional: method permitted as arguments/assignments
+ methods: ["$MethodName"]
+ }
+ },
+ // optional would use default ruleChecks, will merge each $ruleCheckName with default rule setup so user doesn't have to specify properties if already defined
+ {
+ "$ruleCheckName": {
+ // optional, same as above and overrides parent even if blank array
+ objectMatches: ["$stringRegex"],
+
+ // optional, same as above, overrides parent even if blank object
+ escape: {...}
+
+ // indices to check for arguments passed to a method call. Only applies to $ruleName="method"
+ properties: [...]
+ }
+ ...
+ }
+ ]
+ }
+}
+```
diff --git a/lib/ruleHelper.js b/lib/ruleHelper.js
index ac51d94..32dd52c 100644
--- a/lib/ruleHelper.js
+++ b/lib/ruleHelper.js
@@ -24,9 +24,14 @@ RuleHelper.prototype = {
* Using the Esprima Demo, for example: http://esprima.org/demo/parse.html
*
* @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.
* @returns {boolean}
*/
- allowedExpression(expression) {
+ allowedExpression(expression, escapeObject) {
+ if (!escapeObject) {
+ escapeObject = {};
+ }
/*
expression = { right-hand side of innerHTML or 2nd param to insertAdjacentHTML
*/
@@ -49,30 +54,18 @@ RuleHelper.prototype = {
// check for ${..} expressions
for (let e = 0; e < expression.expressions.length; e++) {
const templateExpression = expression.expressions[e];
- if (!this.allowedExpression(templateExpression)) {
+ if (!this.allowedExpression(templateExpression, escapeObject)) {
allowed = false;
break;
}
}
} else if (expression.type === "TaggedTemplateExpression") {
- //TODO avoid code-duplication with CallExpression-case below.
- const functionName = this.context.getSource(expression.tag);
- if (VALID_ESCAPERS.indexOf(functionName) !== -1) {
- allowed = true;
- } else {
- allowed = false;
- }
- } else if ((expression.type === "CallExpression") &&
- (expression.callee.property || expression.callee.name)) {
- const funcName = expression.callee.name || expression.callee.property.name;
- if (funcName && VALID_UNWRAPPERS.indexOf(funcName) !== -1) {
- allowed = true;
- } else {
- allowed = false;
- }
+ allowed = this.isAllowedCallExpression(expression.tag, escapeObject.taggedTemplates || VALID_ESCAPERS);
+ } else if (expression.type === "CallExpression") {
+ allowed = this.isAllowedCallExpression(expression.callee, escapeObject.methods || VALID_UNWRAPPERS);
} else if (expression.type === "BinaryExpression") {
- allowed = ((this.allowedExpression(expression.left))
- && (this.allowedExpression(expression.right)));
+ allowed = ((this.allowedExpression(expression.left, escapeObject))
+ && (this.allowedExpression(expression.right, escapeObject)));
} else {
// everything that doesn't match is unsafe:
allowed = false;
@@ -80,6 +73,170 @@ RuleHelper.prototype = {
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}
+ */
+ 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
+ */
+ 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;
+ break;
+ case "ArrowFunctionExpression":
+ methodName = "";
+ break;
+ default:
+ this.reportUnsupported(node, "Unexpected callable", `unexpected ${node.type} in normalizeMethodName`);
+ }
+ return {
+ objectName,
+ methodName
+ };
+ },
+
+ /**
+ * Returns functionName or objectName.methodName of an expression
+ * @param {Object} node
+ * @returns {String} 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} if to run checks expression
+ */
+ shouldCheckMethodCall(node, objectMatches) {
+ const normalizedMethodCall = this.normalizeMethodCall(node.callee);
+ let matched = 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) {
+ 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
+ * @returns {Object} 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);
+ }
+
+ // 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({},
+ defaultRuleChecks[ruleCheckKey],
+ parentRuleChecks,
+ childRuleChecks[ruleCheckKey]);
+ ruleCheckOutput[ruleCheckKey] = ruleCheck;
+ });
+ return ruleCheckOutput;
+ },
+
+ /**
+ * Runs the checks against a CallExpression
+ * @param {Object} node call expression node
+ * @param {Object} defaultRuleChecks default rules to merge with this.context
+ */
+ checkMethod(node, defaultRuleChecks) {
+ const ruleChecks = this.combineRuleChecks(defaultRuleChecks);
+ const normalizeMethodCall = this.normalizeMethodCall(node.callee);
+ const methodName = normalizeMethodCall.methodName;
+
+ if (ruleChecks.hasOwnProperty(methodName)) {
+ const ruleCheck = 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 (this.shouldCheckMethodCall(node, ruleCheck.objectMatches)
+ && !this.allowedExpression(argument, ruleCheck.escape)) {
+ 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
+ * @param {Object} defaultRuleChecks default rules to merge with this.context
+ */
+ checkProperty(node, defaultRuleChecks) {
+ const ruleChecks = this.combineRuleChecks(defaultRuleChecks);
+
+ if (ruleChecks.hasOwnProperty(node.left.property.name)) {
+ const ruleCheck = ruleChecks[node.left.property.name];
+ if (!this.allowedExpression(node.right, ruleCheck.escape)) {
+ 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}`);
diff --git a/lib/rules/method.js b/lib/rules/method.js
index ae3885c..92ef113 100644
--- a/lib/rules/method.js
+++ b/lib/rules/method.js
@@ -12,6 +12,31 @@ const RuleHelper = require("../ruleHelper");
// Rule Definition
//------------------------------------------------------------------------------
+
+const defaultRuleChecks = {
+
+ // check second parameter to .insertAdjacentHTML()
+ insertAdjacentHTML: {
+ properties: [1]
+ },
+
+ // check first parameter to .write(), as long as the preceeding object matches the regex "document"
+ write: {
+ objectMatches: [
+ "document"
+ ],
+ properties: [0]
+ },
+
+ // check first parameter to .writeLn(), as long as the preceeding object matches the regex "document"
+ writeln: {
+ objectMatches: [
+ "document"
+ ],
+ properties: [0]
+ }
+};
+
module.exports = {
meta: {
docs: {
@@ -34,20 +59,8 @@ module.exports = {
switch(node.callee.type) {
case "Identifier":
case "MemberExpression":
- if ("property" in node.callee && node.arguments.length > 0) {
- if (node.callee.property.name === "insertAdjacentHTML") {
- if (!ruleHelper.allowedExpression(node.arguments[1])) {
- context.report(node, "Unsafe call to insertAdjacentHTML");
- }
- } else if (context.getSource(node.callee) === "document.write") {
- if (!ruleHelper.allowedExpression(node.arguments[0])) {
- context.report(node, "Unsafe call to document.write");
- }
- } else if (context.getSource(node.callee) === "document.writeln") {
- if (!ruleHelper.allowedExpression(node.arguments[0])) {
- context.report(node, "Unsafe call to document.writeln");
- }
- }
+ if (node.arguments.length > 0) {
+ ruleHelper.checkMethod(node, defaultRuleChecks);
}
break;
@@ -58,6 +71,8 @@ module.exports = {
break;
case "Super":
break;
+
+ // If we don't cater for this expression throw an error
default:
context.reportUnsupported(node, "Unexpected Callee", "Unsupported Callee for CallExpression");
}
diff --git a/lib/rules/property.js b/lib/rules/property.js
index 4b1ab53..da4cc0f 100644
--- a/lib/rules/property.js
+++ b/lib/rules/property.js
@@ -12,6 +12,18 @@ const RuleHelper = require("../ruleHelper");
// Rule Definition
//------------------------------------------------------------------------------
+
+const defaultRuleChecks = {
+
+ // Check unsafe assignment to innerHTML
+ innerHTML: {
+ },
+
+ // Check unsafe assignment to outerHTML
+ outerHTML: {
+ }
+};
+
module.exports = {
meta: {
docs: {
@@ -47,15 +59,10 @@ module.exports = {
if (CHECK_REQUIRED_OPERATORS.indexOf(node.operator) === -1) {
ruleHelper.reportUnsupported(node, "Unexpected Assignment", "Unsupported Operator for AssignmentExpression");
}
- if ((node.left.property.name === "innerHTML") ||
- (node.left.property.name === "outerHTML")) {
- if (!ruleHelper.allowedExpression(node.right)) {
- context.report(node, `Unsafe assignment to ${node.left.property.name}`);
- }
- }
+ ruleHelper.checkProperty(node, defaultRuleChecks);
}
}
- },
+ }
};
}
};
diff --git a/package.json b/package.json
index 61e0a5f..db94a3e 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,6 @@
},
"scripts":{
"test": "./node_modules/.bin/mocha tests/rules/",
- "lint": "./node_modules/.bin/eslint index.js lib/**/*.js tests/**/*.js"
+ "lint": "./node_modules/.bin/eslint index.js lib/*.js lib/**/*.js tests/**/*.js"
}
}
diff --git a/tests/rules/method.js b/tests/rules/method.js
index e1b4254..9654d63 100644
--- a/tests/rules/method.js
+++ b/tests/rules/method.js
@@ -24,12 +24,6 @@ eslintTester.run("method", rule, {
// XXX this does not find z['innerHTML'] and the like.
valid: [
- // testing unwrapSafeHTML spread
- {
- code: "this.imeList.innerHTML = Sanitizer.unwrapSafeHTML(...listHtml);",
- parserOptions: { ecmaVersion: 6 }
- },
-
// tests for insertAdjacentHTML calls
{
code: "n.insertAdjacentHTML('afterend', 'meh');",
@@ -57,6 +51,51 @@ eslintTester.run("method", rule, {
code: "document.writeln(Sanitizer.escapeHTML`${evil}`);",
parserOptions: { ecmaVersion: 6 }
},
+ {
+ code: "otherNodeWeDontCheckFor.writeln(evil);",
+ parserOptions: { ecmaVersion: 6 }
+ },
+
+ // Native method (Check customize code doesn't include these)
+ {
+ code: "document.toString(evil);"
+ },
+
+ {
+ code: "document.write(escaper(x))",
+ options: [
+ {
+ escape: {
+ methods: ["escaper"]
+ }
+ }
+ ]
+ },
+
+ // Checking write can be overriden
+ {
+ code: "document.write(evilest)",
+ options: [
+ {
+ objectMatches: ["document", "documentFun"]
+ },
+ {
+ write: {
+ objectMatches: ["thing"]
+ }
+ }
+ ]
+ },
+
+ // Checking disableDefault can remove the default rules
+ {
+ code: "document.write(evil)",
+ options: [
+ {
+ defaultDisable: true
+ }
+ ]
+ }
],
// Examples of code that should trigger the rule
@@ -72,7 +111,7 @@ eslintTester.run("method", rule, {
code: "node.insertAdjacentHTML('beforebegin', htmlString);",
errors: [
{
- message: "Unsafe call to insertAdjacentHTML",
+ message: "Unsafe call to node.insertAdjacentHTML for argument 1",
type: "CallExpression"
}
]
@@ -81,7 +120,7 @@ eslintTester.run("method", rule, {
code: "node.insertAdjacentHTML('beforebegin', template.getHTML());",
errors: [
{
- message: "Unsafe call to insertAdjacentHTML",
+ message: "Unsafe call to node.insertAdjacentHTML for argument 1",
type: "CallExpression"
}
]
@@ -92,7 +131,25 @@ eslintTester.run("method", rule, {
code: "document.write(''+ htmlInput + '');",
errors: [
{
- message: "Unsafe call to document.write",
+ message: "Unsafe call to document.write for argument 0",
+ type: "CallExpression"
+ }
+ ]
+ },
+ {
+ code: "documentish.write(''+ htmlInput + '');",
+ errors: [
+ {
+ message: "Unsafe call to documentish.write for argument 0",
+ type: "CallExpression"
+ }
+ ]
+ },
+ {
+ code: "documentIframe.write(''+ htmlInput + '');",
+ errors: [
+ {
+ message: "Unsafe call to documentIframe.write for argument 0",
type: "CallExpression"
}
]
@@ -101,11 +158,56 @@ eslintTester.run("method", rule, {
code: "document.writeln(evil);",
errors: [
{
- message: "Unsafe call to document.writeln",
+ message: "Unsafe call to document.writeln for argument 0",
type: "CallExpression"
}
]
},
+ // Broken config
+ {
+ code: "b.boop(pie)",
+ options: [
+ {
+ },
+ {
+ boop: {
+ }
+ }
+ ],
+ errors: [
+ {
+ message: "Method check requires properties array in eslint rule boop",
+ type: "CallExpression"
+ }
+ ]
+ },
+
+ // Checking disableDefault can remove the default rules but also add more
+ {
+ code: "document.write(evil); b.thing(x); b.other(me);",
+ options: [
+ {
+ defaultDisable: true
+ },
+ {
+ thing: {
+ },
+ other: {
+ properties: [0]
+ }
+ }
+ ],
+ errors: [
+ {
+ message: "Method check requires properties array in eslint rule thing",
+ type: "CallExpression"
+ },
+ {
+ message: "Unsafe call to b.other for argument 0",
+ type: "CallExpression"
+ }
+ ]
+ }
]
});
diff --git a/tests/rules/property.js b/tests/rules/property.js
index 927785e..b305e79 100644
--- a/tests/rules/property.js
+++ b/tests/rules/property.js
@@ -111,7 +111,19 @@ eslintTester.run("property", rule, {
{
code: "w.innerHTML = `${'lulz'+'meh'}`;",
parserOptions: { ecmaVersion: 6 }
- }
+ },
+
+ // testing unwrapSafeHTML spread
+ {
+ code: "this.imeList.innerHTML = Sanitizer.unwrapSafeHTML(...listHtml);",
+ parserOptions: { ecmaVersion: 6 }
+ },
+
+
+ // Native method (Check customize code doesn't include these)
+ {
+ code: "document.toString = evil;"
+ },
],
// Examples of code that should trigger the rule
@@ -261,6 +273,19 @@ eslintTester.run("property", rule, {
parserOptions: { ecmaVersion: 6 }
},
+ // testing unwrapSafeHTML spread sanitizer typo
+ {
+ code: "this.imeList.innerHTML = Sanitizer.unrapSafeHTML(...listHtml);",
+ errors: [
+ {
+ message: "Unsafe assignment to innerHTML",
+ type: "AssignmentExpression"
+ }
+ ],
+ parserOptions: { ecmaVersion: 6 }
+ },
+
+
// the previous override for manual review and legacy code is now invalid
{
code: "g.innerHTML = potentiallyUnsafe; // a=legacy, bug 1155131",