Skip to content

Commit ba93526

Browse files
authored
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
1 parent 98401cc commit ba93526

File tree

5 files changed

+278
-1
lines changed

5 files changed

+278
-1
lines changed

apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
}
99

1010
const script = globalContext.document.createElement("script");
11+
// This script runs in world: MAIN, eliminating the risk associated with this lint error.
12+
// DOM injection is still needed for the iframe timing hack.
13+
// eslint-disable-next-line @bitwarden/platform/no-page-script-url-leakage
1114
script.src = chrome.runtime.getURL("content/fido2-page-script.js");
1215
script.async = false;
1316

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export default tseslint.config(
8080

8181
"@bitwarden/platform/required-using": "error",
8282
"@bitwarden/platform/no-enums": "error",
83+
"@bitwarden/platform/no-page-script-url-leakage": "error",
8384
"@bitwarden/components/require-theme-colors-in-svg": "error",
8485

8586
"@typescript-eslint/explicit-member-accessibility": ["error", { accessibility: "no-public" }],

libs/eslint/platform/index.mjs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
import requiredUsing from "./required-using.mjs";
22
import noEnums from "./no-enums.mjs";
3+
import noPageScriptUrlLeakage from "./no-page-script-url-leakage.mjs";
34

4-
export default { rules: { "required-using": requiredUsing, "no-enums": noEnums } };
5+
export default {
6+
rules: {
7+
"required-using": requiredUsing,
8+
"no-enums": noEnums,
9+
"no-page-script-url-leakage": noPageScriptUrlLeakage,
10+
},
11+
};
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* @fileoverview ESLint rule to prevent page script URL leakage vulnerabilities
3+
* @description This rule detects the specific security vulnerability where DOM script elements
4+
* receive extension URLs through chrome.runtime.getURL() or browser.runtime.getURL() calls.
5+
* This pattern exposes predictable extension URLs to web pages, enabling fingerprinting attacks.
6+
*/
7+
8+
export const errorMessage =
9+
"Script injection with extension URL exposes asset urls. Use secure page script registration instead.";
10+
11+
/**
12+
* Checks if a node is a call to chrome.runtime.getURL() or browser.runtime.getURL()
13+
* @param {Object} node - The AST node to check
14+
* @returns {boolean} True if the node is an extension URL call
15+
*/
16+
function isExtensionURLCall(node) {
17+
return (
18+
node &&
19+
node.type === "CallExpression" &&
20+
node.callee &&
21+
node.callee.type === "MemberExpression" &&
22+
node.callee.object &&
23+
node.callee.object.type === "MemberExpression" &&
24+
node.callee.object.object &&
25+
["chrome", "browser"].includes(node.callee.object.object.name) &&
26+
node.callee.object.property &&
27+
node.callee.object.property.name === "runtime" &&
28+
node.callee.property &&
29+
node.callee.property.name === "getURL"
30+
);
31+
}
32+
33+
/**
34+
* Checks if a node is a call to createElement("script")
35+
* @param {Object} node - The AST node to check
36+
* @returns {boolean} True if the node creates a script element
37+
*/
38+
function isScriptCreation(node) {
39+
return (
40+
node &&
41+
node.type === "CallExpression" &&
42+
node.callee &&
43+
node.callee.type === "MemberExpression" &&
44+
node.callee.property &&
45+
node.callee.property.name === "createElement" &&
46+
node.arguments &&
47+
node.arguments.length === 1 &&
48+
node.arguments[0] &&
49+
node.arguments[0].type === "Literal" &&
50+
node.arguments[0].value === "script"
51+
);
52+
}
53+
54+
export default {
55+
meta: {
56+
type: "problem",
57+
docs: {
58+
description: "Prevent page script URL leakage through extension runtime.getURL calls",
59+
category: "Security",
60+
recommended: true,
61+
},
62+
schema: [],
63+
messages: {
64+
pageScriptUrlLeakage: errorMessage,
65+
},
66+
},
67+
68+
create(context) {
69+
const scriptVariables = new Set();
70+
71+
return {
72+
// Track createElement("script") calls to identify script variables
73+
VariableDeclarator(node) {
74+
if (node.init && isScriptCreation(node.init) && node.id && node.id.name) {
75+
scriptVariables.add(node.id.name);
76+
}
77+
},
78+
79+
// Track assignments where script elements are created
80+
AssignmentExpression(node) {
81+
// Track script element creation: variable = document.createElement("script")
82+
if (
83+
node.operator === "=" &&
84+
node.left &&
85+
node.left.type === "Identifier" &&
86+
isScriptCreation(node.right)
87+
) {
88+
scriptVariables.add(node.left.name);
89+
}
90+
91+
// Check for script.src = extension URL pattern
92+
if (
93+
node.operator === "=" &&
94+
node.left &&
95+
node.left.type === "MemberExpression" &&
96+
node.left.property &&
97+
node.left.property.name === "src" &&
98+
isExtensionURLCall(node.right)
99+
) {
100+
// Only flag if this is a script element assignment
101+
if (
102+
node.left.object &&
103+
node.left.object.type === "Identifier" &&
104+
scriptVariables.has(node.left.object.name)
105+
) {
106+
context.report({
107+
node: node.right,
108+
messageId: "pageScriptUrlLeakage",
109+
});
110+
}
111+
}
112+
},
113+
};
114+
},
115+
};
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { RuleTester } from "@typescript-eslint/rule-tester";
2+
3+
import rule, { errorMessage } from "./no-page-script-url-leakage.mjs";
4+
5+
const ruleTester = new RuleTester({
6+
languageOptions: {
7+
parserOptions: {
8+
project: [__dirname + "/../tsconfig.spec.json"],
9+
projectService: {
10+
allowDefaultProject: ["*.ts*"],
11+
},
12+
tsconfigRootDir: __dirname + "/..",
13+
},
14+
},
15+
});
16+
17+
ruleTester.run("no-page-script-url-leakage", rule.default, {
18+
valid: [
19+
{
20+
name: "Non-script element with extension URL (iframe)",
21+
code: `
22+
const iframe = document.createElement("iframe");
23+
iframe.src = chrome.runtime.getURL("popup.html");
24+
`,
25+
},
26+
{
27+
name: "Non-script element with extension URL (img)",
28+
code: `
29+
const img = document.createElement("img");
30+
img.src = chrome.runtime.getURL("icon.png");
31+
`,
32+
},
33+
{
34+
name: "Script element with non-extension URL",
35+
code: `
36+
const script = document.createElement("script");
37+
script.src = "https://example.com/script.js";
38+
`,
39+
},
40+
{
41+
name: "Extension URL call without DOM assignment",
42+
code: `
43+
const url = chrome.runtime.getURL("assets/icon.png");
44+
console.log(url);
45+
`,
46+
},
47+
{
48+
name: "Browser runtime call without DOM assignment",
49+
code: `
50+
const url = browser.runtime.getURL("content/style.css");
51+
fetch(url);
52+
`,
53+
},
54+
{
55+
name: "Script assignment with variable not from createElement",
56+
code: `
57+
const script = getSomeScriptElement();
58+
script.src = chrome.runtime.getURL("script.js");
59+
`,
60+
},
61+
{
62+
name: "Assignment to different property",
63+
code: `
64+
const script = document.createElement("script");
65+
script.type = "text/javascript";
66+
`,
67+
},
68+
],
69+
invalid: [
70+
{
71+
name: "Script element with chrome.runtime.getURL - variable declaration",
72+
code: `
73+
const script = document.createElement("script");
74+
script.src = chrome.runtime.getURL("content/script.js");
75+
`,
76+
errors: [
77+
{
78+
message: errorMessage,
79+
},
80+
],
81+
},
82+
{
83+
name: "Script element with browser.runtime.getURL - variable declaration",
84+
code: `
85+
const script = document.createElement("script");
86+
script.src = browser.runtime.getURL("content/script.js");
87+
`,
88+
errors: [
89+
{
90+
message: errorMessage,
91+
},
92+
],
93+
},
94+
{
95+
name: "Script element with chrome.runtime.getURL - assignment expression",
96+
code: `
97+
let script;
98+
script = document.createElement("script");
99+
script.src = chrome.runtime.getURL("page-script.js");
100+
`,
101+
errors: [
102+
{
103+
message: errorMessage,
104+
},
105+
],
106+
},
107+
{
108+
name: "Script element with browser.runtime.getURL - assignment expression",
109+
code: `
110+
let element;
111+
element = document.createElement("script");
112+
element.src = browser.runtime.getURL("fido2-page-script.js");
113+
`,
114+
errors: [
115+
{
116+
message: errorMessage,
117+
},
118+
],
119+
},
120+
{
121+
name: "Multiple script elements with different variable names",
122+
code: `
123+
const scriptA = document.createElement("script");
124+
const scriptB = document.createElement("script");
125+
scriptA.src = chrome.runtime.getURL("script-a.js");
126+
scriptB.src = browser.runtime.getURL("script-b.js");
127+
`,
128+
errors: [
129+
{
130+
message: errorMessage,
131+
},
132+
{
133+
message: errorMessage,
134+
},
135+
],
136+
},
137+
{
138+
name: "Real-world pattern that prompted creation of this lint rule",
139+
code: `
140+
const script = globalThis.document.createElement("script");
141+
script.src = chrome.runtime.getURL("content/fido2-page-script.js");
142+
script.async = false;
143+
`,
144+
errors: [
145+
{
146+
message: errorMessage,
147+
},
148+
],
149+
},
150+
],
151+
});

0 commit comments

Comments
 (0)