diff --git a/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts b/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts index 775bc76266d..e167f30af0a 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts @@ -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; diff --git a/eslint.config.mjs b/eslint.config.mjs index df98b3af424..7f9bb2284f7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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" }], diff --git a/libs/eslint/platform/index.mjs b/libs/eslint/platform/index.mjs index c7ea3f1dd89..be78df43e3f 100644 --- a/libs/eslint/platform/index.mjs +++ b/libs/eslint/platform/index.mjs @@ -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, + }, +}; diff --git a/libs/eslint/platform/no-page-script-url-leakage.mjs b/libs/eslint/platform/no-page-script-url-leakage.mjs new file mode 100644 index 00000000000..fe13c496a62 --- /dev/null +++ b/libs/eslint/platform/no-page-script-url-leakage.mjs @@ -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", + }); + } + } + }, + }; + }, +}; diff --git a/libs/eslint/platform/no-page-script-url-leakage.spec.mjs b/libs/eslint/platform/no-page-script-url-leakage.spec.mjs new file mode 100644 index 00000000000..77e1c4d1697 --- /dev/null +++ b/libs/eslint/platform/no-page-script-url-leakage.spec.mjs @@ -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, + }, + ], + }, + ], +});