文档对象模型(DOM
)充当着 HTML
和 JavaScript
之间的接口,搭建起静态内容与动态交互之间的桥梁,对现代 Web
开发而言,DOM
的作用不可或缺。
然而,DOM
也有一个致命的陷阱 —— DOM
劫持。DOM
劫持是指当 HTML
元素与全局 JavaScript
变量或函数产生冲突时,可能会导致 Web
应用程序出现不可预期的行为,甚至产生潜在的安全漏洞。
今天就可大家一起来聊聊 DOM
劫持的问题。
DOM 劫持是怎么发生的?
每个 HTML
元素都可以有一个唯一的 id
或 name
属性,方便在 JavaScript
中引用特定的元素。例如,下面的 HTML
按钮具有一个值为 "myButton"
的 id
属性:
<button id="myButton">Click Me!</button>
我们可以在 JavaScript
代码中使用此 ID
来操作按钮,例如,当点击时改变其文本:
document.getElementById('myButton').onclick = function() {
this.textContent = 'Clicked!';
};
如果 HTML
元素的 id
或 name
属性与全局 JavaScript
变量或函数冲突会发生什么呢?
当浏览器加载 HTML
页面时,它会自动为 HTML DOM
中的每个 id
和 name
属性创建全局 JavaScript
变量。如果我们有一个名为 “myButton”
的 HTML
元素,浏览器会创建一个全局 JavaScript
变量 myButton
,引用该 HTML
元素。
现在,让我们考虑一个场景,其中我们声明了一个名为 myButton
的 JavaScript
函数:
function myButton() {
// some code
}
但我们还有一个 id
或 name
为 “myButton”
的 HTML
元素。当页面加载时,浏览器的自动进程会引用 HTML
元素并覆盖 JavaScript
函数 myButton
。
<button id="myButton">Click Me!</button>
console.log(myButton); // This will log the HTML button element, not the function
这个过程叫做 DOM
劫持,它可能会引发不可预测的行为和安全漏洞。如果攻击者能控制这些属性,他们可能有能力向网页注入恶意代码,从而引发包括跨站脚本(XSS
)在内的安全问题。
为了说明这一点,让我们考虑以下情景:
Enter your name: <input id="username" type="text">
<button onclick="greet()">Greet</button>
function greet() {
var username = document.getElementById('username').value;
alert(Hello ${username}
);
}
攻击者可以输入类似 <img id='alert' src=x onerror='alert(1)'>
的内容,创建一个 'id'
为 'alert'
的新 HTML
组件。该组件会破坏 JavaScript
中的正常 alert 功能。下次网站尝试使用此功能时,它将无法正常工作,甚至可能运行恶意代码。
我们想象现在有一个带有用户反馈功能的基本 Web
应用程序。用户输入自己的姓名和反馈消息,然后提交。页面显示反馈:
html:
<h2>Feedback Form</h2>
<form>
<label for="name">Name:</label><br>
<input type="text" id="name" name="name"><br>
<label for="feedback">Feedback:</label><br>
<textarea id="feedback" name="feedback"></textarea><br>
<input type="submit" value="Submit">
</form>
<div id="feedbackDisplay"></div>
JavaScript:
document.querySelector('form').onsubmit = function(event) {
event.preventDefault();let name = document.getElementById('name').value; let feedback = document.getElementById('feedback').value; let feedbackElement = document.getElementById('feedbackDisplay'); feedbackElement.innerHTML = `<p><b>${name}</b>: ${feedback}</p>`;
};
这段代码会获取用户的姓名和反馈,并将其显示在 FeedbackDisplay div
内的段落元素中。
攻击者可以通过在反馈表单中提交一段 HTML
来利用此代码。例如,如果他们在名称字段中输入以下代码并提交表单,则反馈显示区域就会被 Script
替换:
<script id="feedbackDisplay">window.location.href='http://conardli.top';</script>
当表单尝试显示下一条反馈时,就会执行脚本,将用户重定向到恶意网站。这是 DOM 劫持造成严重后果的一个例子 —— 攻击者可以控制用户的浏览器,从而窃取敏感数据或安装恶意软件。
缓解 DOM 劫持的安全编码实践
通过更深入地了解这些漏洞,我们可以继续采取一些最佳实践来减轻 DOM 劫持的风险。
正确定义变量和函数的作用域
DOM
劫持的最常见原因之一是滥用 JavaScript
中的全局作用域。
通过在特定的作用域范围内定义变量和函数,我们可以限制对该范围或任何嵌套范围的覆盖,并最大限度地减少潜在的冲突。
我们来用 JavaScript
的作用域规则并重构前面的示例来展示如何做到这一点:
(function() {
// All variables and functions are now in this function's scope
const form = document.querySelector('form');
const feedbackElement = document.getElementById('feedbackDisplay');form.onsubmit = function(event) { event.preventDefault(); const name = document.getElementById('name').value; const feedback = document.getElementById('feedback').value; // Sanitize user input name = DOMPurify.sanitize(name); feedback = DOMPurify.sanitize(feedback); const newFeedback = document.createElement('p'); newFeedback.textContent = `${name}: ${feedback}`; feedbackElement.appendChild(newFeedback); };
})();
首先我们使用了 DOMPurify
来对上述代码块中的用户输入进行清理。
在此版本的代码中,我们将所有内容都包含在立即调用函数表达式 (IIFE
) 中,这会创建一个新作用域。form
和 FeedbackElement
变量以及分配给 onsubmit
事件处理程序的函数不在全局作用域内,因此它们不能被劫持。
使用唯一标识符
确保网页上的每个元素都有唯一的 id 可以降低无意中覆盖重要函数或变量的风险。另外,避免使用通用名称或可能与全局 JavaScript
对象或函数冲突的名称。
避免全局命名空间污染
保持全局命名空间干净是编写安全 JavaScript
的一个重要方面。全局作用域中的变量和函数越多,DOM劫持的风险就越大。使用 JavaScript
的函数作用域或 ES6
的块作用域来保留变量和函数。这是使用后者的示例:
let form = document.querySelector('form');
let feedbackElement = document.getElementById('feedbackDisplay');form.onsubmit = function(event) { event.preventDefault(); let name = document.getElementById('name').value; let feedback = document.getElementById('feedback').value; // Sanitize user input name = DOMPurify.sanitize(name); feedback = DOMPurify.sanitize(feedback); let newFeedback = document.createElement('p'); newFeedback.textContent = `${name}: ${feedback}`; feedbackElement.appendChild(newFeedback); };
在这段代码中,我们使用块(由 {}
定义)来创建新作用域。所有变量和函数现在都限制在该块中,并且不在全局作用域内。
正确使用 JavaScript 特性
现代 JavaScript
提供了一些有助于最大限度地缓解 DOM 劫持的风险。特别是 ES6
中引入的 let
和 const
关键字提供了对声明变量的更多控制。
在以前,我们使用 var
关键字声明 JavaScript
变量。var
有一些怪癖,其中之一是就它没有块作用域,只有函数作用域和全局作用域。这意味着用 var
声明的变量可以在声明它的块之外访问和覆盖。
另一方面,let
和 const
都具有块作用域,这意味着它们只能在声明它们的块内访问。这一特性通常使它们成为变量声明的更好选择,因为它限制了覆盖变量的可能性。
我们还可以使用 const
来声明常量 — 分配它们后我们无法更改的值。它们可以防止重要的变量被意外覆盖。
const form = document.querySelector('form');
const feedbackElement = document.getElementById('feedbackDisplay');form.onsubmit = function(event) { event.preventDefault(); const name = document.getElementById('name').value; const feedback = document.getElementById('feedback').value; // Sanitize user input name = DOMPurify.sanitize(name); feedback = DOMPurify.sanitize(feedback); const newFeedback = document.createElement('p'); newFeedback.textContent = `${name}: ${feedback}`; feedbackElement.appendChild(newFeedback); };
在此代码中,我们将所有 var
的使用替换为 const
。我们将所有变量限制在声明它们的块中,并且常量不能被覆盖。
但是 ,使用 let
和 const
并不能完全消除 DOM
劫持的风险,但这种做法仍然是安全编码的一个关键方面。
使用 Devtools 发现潜在的 DOM 劫持风险
例如 Chrome
或 Firefox
中的浏览器开发者工具,也是探测 DOM
劫持漏洞的强大助手。
最简单的方法,我们直接打开 Devtools
。
然后在控制台输入 window
,这里面包含了网站全局作用域下所有的全局变量和函数。
然后我们检查下是否有任何看起来不合适的变量,尤其是那些与 HTML
元素 id
或 name
同名的变量。
通过 Elements
选项卡,编辑页面的 HTML
来操控 DOM
并测试潜在的漏洞。例如,添加一个 id
与全局变量或函数相匹配的元素,看看是否会被覆写。