mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
chore: create eslint rule to catch insecure page script injection (#17437)
* chore: create eslint rule to catch insecure page script injection * chore: ignore existing lints * review: tighten rule scope * review: add tests
This commit is contained in:
@@ -8,6 +8,9 @@
|
||||
}
|
||||
|
||||
const script = globalContext.document.createElement("script");
|
||||
// This script runs in world: MAIN, eliminating the risk associated with this lint error.
|
||||
// DOM injection is still needed for the iframe timing hack.
|
||||
// eslint-disable-next-line @bitwarden/platform/no-page-script-url-leakage
|
||||
script.src = chrome.runtime.getURL("content/fido2-page-script.js");
|
||||
script.async = false;
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ export default tseslint.config(
|
||||
|
||||
"@bitwarden/platform/required-using": "error",
|
||||
"@bitwarden/platform/no-enums": "error",
|
||||
"@bitwarden/platform/no-page-script-url-leakage": "error",
|
||||
"@bitwarden/components/require-theme-colors-in-svg": "error",
|
||||
|
||||
"@typescript-eslint/explicit-member-accessibility": ["error", { accessibility: "no-public" }],
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import requiredUsing from "./required-using.mjs";
|
||||
import noEnums from "./no-enums.mjs";
|
||||
import noPageScriptUrlLeakage from "./no-page-script-url-leakage.mjs";
|
||||
|
||||
export default { rules: { "required-using": requiredUsing, "no-enums": noEnums } };
|
||||
export default {
|
||||
rules: {
|
||||
"required-using": requiredUsing,
|
||||
"no-enums": noEnums,
|
||||
"no-page-script-url-leakage": noPageScriptUrlLeakage,
|
||||
},
|
||||
};
|
||||
|
||||
115
libs/eslint/platform/no-page-script-url-leakage.mjs
Normal file
115
libs/eslint/platform/no-page-script-url-leakage.mjs
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @fileoverview ESLint rule to prevent page script URL leakage vulnerabilities
|
||||
* @description This rule detects the specific security vulnerability where DOM script elements
|
||||
* receive extension URLs through chrome.runtime.getURL() or browser.runtime.getURL() calls.
|
||||
* This pattern exposes predictable extension URLs to web pages, enabling fingerprinting attacks.
|
||||
*/
|
||||
|
||||
export const errorMessage =
|
||||
"Script injection with extension URL exposes asset urls. Use secure page script registration instead.";
|
||||
|
||||
/**
|
||||
* Checks if a node is a call to chrome.runtime.getURL() or browser.runtime.getURL()
|
||||
* @param {Object} node - The AST node to check
|
||||
* @returns {boolean} True if the node is an extension URL call
|
||||
*/
|
||||
function isExtensionURLCall(node) {
|
||||
return (
|
||||
node &&
|
||||
node.type === "CallExpression" &&
|
||||
node.callee &&
|
||||
node.callee.type === "MemberExpression" &&
|
||||
node.callee.object &&
|
||||
node.callee.object.type === "MemberExpression" &&
|
||||
node.callee.object.object &&
|
||||
["chrome", "browser"].includes(node.callee.object.object.name) &&
|
||||
node.callee.object.property &&
|
||||
node.callee.object.property.name === "runtime" &&
|
||||
node.callee.property &&
|
||||
node.callee.property.name === "getURL"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a node is a call to createElement("script")
|
||||
* @param {Object} node - The AST node to check
|
||||
* @returns {boolean} True if the node creates a script element
|
||||
*/
|
||||
function isScriptCreation(node) {
|
||||
return (
|
||||
node &&
|
||||
node.type === "CallExpression" &&
|
||||
node.callee &&
|
||||
node.callee.type === "MemberExpression" &&
|
||||
node.callee.property &&
|
||||
node.callee.property.name === "createElement" &&
|
||||
node.arguments &&
|
||||
node.arguments.length === 1 &&
|
||||
node.arguments[0] &&
|
||||
node.arguments[0].type === "Literal" &&
|
||||
node.arguments[0].value === "script"
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description: "Prevent page script URL leakage through extension runtime.getURL calls",
|
||||
category: "Security",
|
||||
recommended: true,
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
pageScriptUrlLeakage: errorMessage,
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const scriptVariables = new Set();
|
||||
|
||||
return {
|
||||
// Track createElement("script") calls to identify script variables
|
||||
VariableDeclarator(node) {
|
||||
if (node.init && isScriptCreation(node.init) && node.id && node.id.name) {
|
||||
scriptVariables.add(node.id.name);
|
||||
}
|
||||
},
|
||||
|
||||
// Track assignments where script elements are created
|
||||
AssignmentExpression(node) {
|
||||
// Track script element creation: variable = document.createElement("script")
|
||||
if (
|
||||
node.operator === "=" &&
|
||||
node.left &&
|
||||
node.left.type === "Identifier" &&
|
||||
isScriptCreation(node.right)
|
||||
) {
|
||||
scriptVariables.add(node.left.name);
|
||||
}
|
||||
|
||||
// Check for script.src = extension URL pattern
|
||||
if (
|
||||
node.operator === "=" &&
|
||||
node.left &&
|
||||
node.left.type === "MemberExpression" &&
|
||||
node.left.property &&
|
||||
node.left.property.name === "src" &&
|
||||
isExtensionURLCall(node.right)
|
||||
) {
|
||||
// Only flag if this is a script element assignment
|
||||
if (
|
||||
node.left.object &&
|
||||
node.left.object.type === "Identifier" &&
|
||||
scriptVariables.has(node.left.object.name)
|
||||
) {
|
||||
context.report({
|
||||
node: node.right,
|
||||
messageId: "pageScriptUrlLeakage",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
151
libs/eslint/platform/no-page-script-url-leakage.spec.mjs
Normal file
151
libs/eslint/platform/no-page-script-url-leakage.spec.mjs
Normal file
@@ -0,0 +1,151 @@
|
||||
import { RuleTester } from "@typescript-eslint/rule-tester";
|
||||
|
||||
import rule, { errorMessage } from "./no-page-script-url-leakage.mjs";
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: [__dirname + "/../tsconfig.spec.json"],
|
||||
projectService: {
|
||||
allowDefaultProject: ["*.ts*"],
|
||||
},
|
||||
tsconfigRootDir: __dirname + "/..",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ruleTester.run("no-page-script-url-leakage", rule.default, {
|
||||
valid: [
|
||||
{
|
||||
name: "Non-script element with extension URL (iframe)",
|
||||
code: `
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.src = chrome.runtime.getURL("popup.html");
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "Non-script element with extension URL (img)",
|
||||
code: `
|
||||
const img = document.createElement("img");
|
||||
img.src = chrome.runtime.getURL("icon.png");
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "Script element with non-extension URL",
|
||||
code: `
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://example.com/script.js";
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "Extension URL call without DOM assignment",
|
||||
code: `
|
||||
const url = chrome.runtime.getURL("assets/icon.png");
|
||||
console.log(url);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "Browser runtime call without DOM assignment",
|
||||
code: `
|
||||
const url = browser.runtime.getURL("content/style.css");
|
||||
fetch(url);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "Script assignment with variable not from createElement",
|
||||
code: `
|
||||
const script = getSomeScriptElement();
|
||||
script.src = chrome.runtime.getURL("script.js");
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "Assignment to different property",
|
||||
code: `
|
||||
const script = document.createElement("script");
|
||||
script.type = "text/javascript";
|
||||
`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: "Script element with chrome.runtime.getURL - variable declaration",
|
||||
code: `
|
||||
const script = document.createElement("script");
|
||||
script.src = chrome.runtime.getURL("content/script.js");
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
message: errorMessage,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Script element with browser.runtime.getURL - variable declaration",
|
||||
code: `
|
||||
const script = document.createElement("script");
|
||||
script.src = browser.runtime.getURL("content/script.js");
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
message: errorMessage,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Script element with chrome.runtime.getURL - assignment expression",
|
||||
code: `
|
||||
let script;
|
||||
script = document.createElement("script");
|
||||
script.src = chrome.runtime.getURL("page-script.js");
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
message: errorMessage,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Script element with browser.runtime.getURL - assignment expression",
|
||||
code: `
|
||||
let element;
|
||||
element = document.createElement("script");
|
||||
element.src = browser.runtime.getURL("fido2-page-script.js");
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
message: errorMessage,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Multiple script elements with different variable names",
|
||||
code: `
|
||||
const scriptA = document.createElement("script");
|
||||
const scriptB = document.createElement("script");
|
||||
scriptA.src = chrome.runtime.getURL("script-a.js");
|
||||
scriptB.src = browser.runtime.getURL("script-b.js");
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
message: errorMessage,
|
||||
},
|
||||
{
|
||||
message: errorMessage,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Real-world pattern that prompted creation of this lint rule",
|
||||
code: `
|
||||
const script = globalThis.document.createElement("script");
|
||||
script.src = chrome.runtime.getURL("content/fido2-page-script.js");
|
||||
script.async = false;
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
message: errorMessage,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user