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
Add warning about use of unsafe innerHTML #1140
Changes from all commits
e13aac1
2a5e600
1075cfb
66e94f7
cece6c3
8306bd7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,15 @@ | ||
import path from 'path'; | ||
|
||
import { ONLY_PREFS_IN_DEFAULTS } from 'messages/javascript'; | ||
import { getRootExpression } from 'utils'; | ||
|
||
|
||
export default { | ||
create(context) { | ||
var filename = context.getFilename(); | ||
var relPath = path.relative(process.cwd(), context.getFilename()); | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This might need a bit more work and testing. I'd love to forward the actual package path to the rules eventually but for now this seems to work just fine. (also this rule will be removed anyway but it's a good example) |
||
// This rule only applies to files in defaults/preferences | ||
if (filename.indexOf('defaults/preferences/') === 0) { | ||
if (path.dirname(relPath).startsWith('defaults/preferences')) { | ||
return { | ||
CallExpression: function(node) { | ||
var root = getRootExpression(node); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -30,54 +30,70 @@ export default class JavaScriptScanner { | |
_messages=messages, | ||
}={}) { | ||
return new Promise((resolve) => { | ||
// ESLint is synchronous and doesn't accept streams, so we need to | ||
// pass it the entire source file as a string. | ||
var eslint = _ESLint.linter; | ||
|
||
for (const name in _rules) { | ||
this._rulesProcessed++; | ||
eslint.defineRule(name, _rules[name]); | ||
} | ||
|
||
var report = eslint.verify(this.code, { | ||
var cli = new _ESLint.CLIEngine({ | ||
env: { es6: true }, | ||
parserOptions: { ecmaVersion: 2017 }, | ||
baseConfig: { | ||
parserOptions: { ecmaVersion: 2017 }, | ||
settings: { | ||
addonMetadata: this.options.addonMetadata, | ||
}, | ||
}, | ||
ignore: false, | ||
rules: _ruleMapping, | ||
settings: { | ||
addonMetadata: this.options.addonMetadata, | ||
}, | ||
}, { | ||
plugins: ['no-unsafe-innerhtml'], | ||
allowInlineConfig: false, | ||
filename: this.filename, | ||
// Avoid loading the addons-linter .eslintrc file | ||
useEslintrc: false, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Presumably this change also means that eslintrc's nested in a webext can't override anything. It would be good to add an explicit test for something like that. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added #1141 for this, could be a rabbit-hole so I'd like to do that as a separate task. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok, that's fine we should try and avoid a release until that's checked - a quick local check would suffice though. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I checked locally and couldn't find an obvious way to overwrite default checks (checked with innerHTML) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That doesn't work without that option too though as far as I see. |
||
}); | ||
|
||
for (const message of report) { | ||
// Fatal error messages (like SyntaxErrors) are a bit different, we | ||
// need to handle them specially. | ||
if (message.fatal === true) { | ||
message.message = _messages.JS_SYNTAX_ERROR.code; | ||
} | ||
for (const name in _rules) { | ||
this._rulesProcessed++; | ||
_ESLint.linter.defineRule(name, _rules[name]); | ||
} | ||
|
||
if (typeof message.message === 'undefined') { | ||
throw new Error(singleLineString`JS rules must pass a valid message as | ||
the second argument to context.report()`); | ||
} | ||
// ESLint is synchronous and doesn't accept streams, so we need to | ||
// pass it the entire source file as a string. | ||
var report = cli.executeOnText(this.code, this.filename, true); | ||
|
||
// Fallback to looking up the message object by the message | ||
var messageObj = _messages[message.message]; | ||
var code = message.message; | ||
|
||
this.linterMessages.push({ | ||
code: code, | ||
column: message.column, | ||
description: messageObj.description, | ||
file: this.filename, | ||
line: message.line, | ||
message: messageObj.message, | ||
sourceCode: message.source, | ||
type: ESLINT_TYPES[message.severity], | ||
}); | ||
for (const result of report.results) { | ||
for (const message of result.messages) { | ||
// Fatal error messages (like SyntaxErrors) are a bit different, we | ||
// need to handle them specially. | ||
if (message.fatal === true) { | ||
message.message = _messages.JS_SYNTAX_ERROR.code; | ||
} | ||
|
||
if (typeof message.message === 'undefined') { | ||
throw new Error( | ||
singleLineString`JS rules must pass a valid message as | ||
the second argument to context.report()`); | ||
} | ||
|
||
// Fallback to looking up the message object by the message | ||
var code = message.message; | ||
|
||
// Support 3rd party eslint rules that don't have our internal | ||
// message structure. | ||
if (_messages.hasOwnProperty(code)) { | ||
var shortDescription = _messages[code].message; | ||
var description = _messages[code].description; | ||
} else { | ||
var shortDescription = code; | ||
var description = null; | ||
} | ||
|
||
this.linterMessages.push({ | ||
code: code, | ||
column: message.column, | ||
description: description, | ||
file: this.filename, | ||
line: message.line, | ||
message: shortDescription, | ||
sourceCode: message.source, | ||
type: ESLINT_TYPES[message.severity], | ||
}); | ||
} | ||
} | ||
|
||
resolve(this.linterMessages); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,173 @@ | ||
import { VALIDATION_WARNING } from 'const'; | ||
import JavaScriptScanner from 'scanners/javascript'; | ||
|
||
// These rules were mostly copied and adapted from | ||
// https://github.com/mozfreddyb/eslint-plugin-no-unsafe-innerhtml/ | ||
// Please make sure to keep them up-to-date and report upstream errors. | ||
// Some notes are not included since we have our own rules | ||
// marking them as invalid (e.g document.write) | ||
|
||
|
||
describe('no_unsafe_innerhtml', () => { | ||
var validCodes = [ | ||
// innerHTML equals | ||
'a.innerHTML = \'\';', | ||
'c.innerHTML = ``;', | ||
'g.innerHTML = Sanitizer.escapeHTML``;', | ||
'h.innerHTML = Sanitizer.escapeHTML`foo`;', | ||
'i.innerHTML = Sanitizer.escapeHTML`foo${bar}baz`;', | ||
|
||
// tests for innerHTML update (+= operator) | ||
'a.innerHTML += \'\';', | ||
'b.innerHTML += "";', | ||
'c.innerHTML += ``;', | ||
'g.innerHTML += Sanitizer.escapeHTML``;', | ||
'h.innerHTML += Sanitizer.escapeHTML`foo`;', | ||
'i.innerHTML += Sanitizer.escapeHTML`foo${bar}baz`;', | ||
'i.innerHTML += Sanitizer.unwrapSafeHTML(htmlSnippet)', | ||
'i.outerHTML += Sanitizer.unwrapSafeHTML(htmlSnippet)', | ||
|
||
// testing unwrapSafeHTML spread | ||
'this.imeList.innerHTML = Sanitizer.unwrapSafeHTML(...listHtml);', | ||
|
||
// tests for insertAdjacentHTML calls | ||
'n.insertAdjacentHTML("afterend", "meh");', | ||
'n.insertAdjacentHTML("afterend", `<br>`);', | ||
'n.insertAdjacentHTML("afterend", Sanitizer.escapeHTML`${title}`);', | ||
|
||
// override for manual review and legacy code | ||
'g.innerHTML = potentiallyUnsafe; // a=legacy, bug 1155131', | ||
|
||
// (binary) expressions | ||
'x.innerHTML = `foo`+`bar`;', | ||
'y.innerHTML = "<span>" + 5 + "</span>";', | ||
|
||
// document.write/writeln | ||
'document.writeln(Sanitizer.escapeHTML`<em>${evil}</em>`);', | ||
|
||
// template string expression tests | ||
'u.innerHTML = `<span>${"lulz"}</span>`;', | ||
'v.innerHTML = `<span>${"lulz"}</span>${55}`;', | ||
'w.innerHTML = `<span>${"lulz"+"meh"}</span>`;', | ||
]; | ||
|
||
|
||
for (const code of validCodes) { | ||
it(`should allow the use of innerHTML: ${code}`, () => { | ||
var jsScanner = new JavaScriptScanner(code, 'badcode.js'); | ||
|
||
return jsScanner.scan() | ||
.then((validationMessages) => { | ||
assert.equal(validationMessages.length, 0); | ||
}); | ||
}); | ||
} | ||
|
||
|
||
var invalidCodes = [ | ||
// innerHTML examples | ||
{ | ||
code: 'm.innerHTML = htmlString;', | ||
message: ['Unsafe assignment to innerHTML'], | ||
}, | ||
{ | ||
code: 'a.innerHTML += htmlString;', | ||
message: ['Unsafe assignment to innerHTML'], | ||
}, | ||
{ | ||
code: 'a.innerHTML += template.toHtml();', | ||
message: ['Unsafe assignment to innerHTML'], | ||
}, | ||
{ | ||
code: 'm.outerHTML = htmlString;', | ||
message: ['Unsafe assignment to outerHTML'], | ||
}, | ||
{ | ||
code: 't.innerHTML = `<span>${name}</span>`;', | ||
message: ['Unsafe assignment to innerHTML'], | ||
}, | ||
{ | ||
code: 't.innerHTML = `<span>${"foobar"}</span>${evil}`;', | ||
message: ['Unsafe assignment to innerHTML'], | ||
}, | ||
|
||
// insertAdjacentHTML examples | ||
{ | ||
code: 'node.insertAdjacentHTML("beforebegin", htmlString);', | ||
message: ['Unsafe call to insertAdjacentHTML'], | ||
}, | ||
{ | ||
code: 'node.insertAdjacentHTML("beforebegin", template.getHTML());', | ||
message: ['Unsafe call to insertAdjacentHTML'], | ||
}, | ||
|
||
// (binary) expressions | ||
{ | ||
code: 'node.innerHTML = "<span>"+ htmlInput;', | ||
message: ['Unsafe assignment to innerHTML'], | ||
}, | ||
{ | ||
code: 'node.innerHTML = "<span>" + htmlInput + "</span>";', | ||
message: ['Unsafe assignment to innerHTML'], | ||
}, | ||
|
||
// document.write / writeln | ||
{ | ||
code: 'document.write("<span>" + htmlInput + "</span>");', | ||
message: [ | ||
'Use of document.write strongly discouraged.', | ||
'Unsafe call to document.write', | ||
], | ||
}, | ||
{ | ||
code: 'document.writeln(evil);', | ||
message: ['Unsafe call to document.writeln'], | ||
}, | ||
|
||
// bug https://bugzilla.mozilla.org/show_bug.cgi?id=1198200 | ||
{ | ||
code: 'title.innerHTML = _("WB_LT_TIPS_S_SEARCH", {value0:engine});', | ||
message: ['Unsafe assignment to innerHTML'], | ||
}, | ||
|
||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1192595 | ||
{ | ||
code: 'x.innerHTML = Sanitizer.escapeHTML(evil)', | ||
message: ['Unsafe assignment to innerHTML'], | ||
}, | ||
{ | ||
code: 'x.innerHTML = Sanitizer.escapeHTML(`evil`)', | ||
message: ['Unsafe assignment to innerHTML'], | ||
}, | ||
{ | ||
code: 'y.innerHTML = ((arrow_function)=>null)`some HTML`', | ||
message: ['Unsafe assignment to innerHTML'], | ||
}, | ||
{ | ||
code: 'y.innerHTML = ((arrow_function)=>null)`some HTML`', | ||
message: ['Unsafe assignment to innerHTML'], | ||
}, | ||
{ | ||
code: 'y.innerHTML = ((arrow_function)=>null)`some HTML`', | ||
message: ['Unsafe assignment to innerHTML'], | ||
}, | ||
]; | ||
|
||
for (const code of invalidCodes) { | ||
it(`should not allow the use of innerHTML examples ${code.code}`, () => { | ||
var jsScanner = new JavaScriptScanner(code.code, 'badcode.js'); | ||
|
||
return jsScanner.scan() | ||
.then((validationMessages) => { | ||
validationMessages = validationMessages.sort(); | ||
|
||
assert.equal(validationMessages.length, code.message.length); | ||
|
||
code.message.forEach((expectedMessage, idx) => { | ||
assert.equal(validationMessages[idx].message, expectedMessage); | ||
assert.equal(validationMessages[idx].type, VALIDATION_WARNING); | ||
}); | ||
}); | ||
}); | ||
} | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I could see this could potentially break with using the linter via web-ext.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I want to make this configurable and maybe forward the actual package path, not sure yet, see #1140 (comment) on that.
Generally, this is how eslint does things anyway so I don't expect there to be any major problems.