使用 HTML Sanitizer 來過濾不安全的 HTML 內容

程式技術

只要網站有能讓使用者輸入文字的地方,例如常見的所見即所得(WYSIWYG)編輯器,就需要設定一個機制來過濾掉不安全的 HTML 內容以避免 XSS(Cross-site scripting)攻擊。

因為我的部落格一開始是以面向所有人的論壇為目標進行開發的,所以也有實裝 XSS 過濾的機制。原本我使用的是 HTML Purifier 這個套件,下面是根據我的需求所寫的過濾設定:

public static function purifierHtml(string $html): string
{
    $config = HTMLPurifier_Config::createDefault();

    $config->set('Core.Encoding', 'utf-8');
    // 設置配置的名稱
    $config->set('HTML.DefinitionID', 'content');
    // 建立過濾規則的快取
    $config->set('Cache.SerializerPath', '/tmp/cache');

    // 預設幫外部連結補上 target="_blank" 與 rel="noreferrer noopener"
    $config->set('HTML.TargetBlank', true);
    // 預設幫外部連結補上 rel="nofollow"
    $config->set('HTML.Nofollow', true);

    // 清除過濾規則的快取,只在開發環境下使用
    if (! app()->isProduction()) {
        $config->set('Cache.DefinitionImpl', null);
    }

    $def = $config->maybeGetRawHTMLDefinition();

    if (! is_null($def)) {
        // 影片嵌入元素
        $def->addElement(
            'oembed', /* 標籤名稱 */
            'Block', /* 元素本身的類型,可以選擇 Inline 或是 Block */
            'Flow', /* 子元素的類型,Flow 代表子元素可以是 Inline、Block 或者是單純的字串 */
            'Common', /* 可用的屬性集合,例如 style、class、id 或者是 title */
            ['url' => 'URI'] /* 允許哪些額外的屬性 */
        );
        // 圖片元素
        $def->addElement('figure', 'Block', 'Flow', 'Common');
        // 圖片底下的說明文字元素
        $def->addElement('figcaption', 'Block', 'Flow', 'Common');
    }

    $purifier = new HTMLPurifier($config);

    return $purifier->purify($html);
}

某天我看到 Laravel News 文章介紹,Symfony 也有一個用來處理 XSS 過濾的套件叫做 HTML Sanitizer,而且寫法採用流暢介面(Fluent Interface),所以語法看起來相當直觀。在看了文件之後,我決定使用看看,嘗試將 HTML Purifier 換成了 HTML Sanitizer。

HTML Sanitizer 的使用方式

HTML Sanitizer 的過濾規則是採用 WICG(Web Incubator Community Group) 的 Sanitizer API。這個 API 的用意是在前端中過濾不安全的 HTML,但目前還處在實驗階段,大多數的瀏覽器都不支援。

const exampleElement = document.getElementById("example");

const unsanitizedString = "...";

// 基本用法,過濾掉 script 與 onclick 等不安全的 HTML
// 這會比使用 innerHTML 與 outerHTML 等方式更為安全
exampleElement.setHTML(unsanitizedString);

// 連本來也允許的元素也過濾掉
const sanitizerConfig = new Sanitizer({
    elements: ["div", "p", "script"],
});

exampleElement.setHTML(unsanitizedString, { sanitizer: sanitizerConfig });

這個 API 可以在瀏覽器的設定中開啟。

首先使用 Composer 安裝 HTML Sanitizer。

composer require symfony/html-sanitizer

你可以使用 allowStaticElements() 設定最基本的過濾規則。

use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;

$config = new HtmlSanitizerConfig->allowStaticElements();

$htmlSanitizer = new HtmlSanitizer($config);

// 不安全的 HTML(可能由 WYSIWYG 編輯器產生出來的內容)
$unsafePostContents = "...";

// 使用 Sanitizer 產生出安全的 HTML
$safePostContents = $htmlSanitizer->sanitize($unsafePostContents);

基礎規則除了 allowStaticElements(),你也可以使用 allowSafeElements()。這個基礎規則會連 CSS(CSS Injection)與點擊劫持(Click-Jacking)都過濾掉。

$config = new HtmlSanitizerConfig->allowSafeElements();

基礎規則可能會過濾掉你想要的屬性。你可以使用 allowAttribute() 來避免你想要的屬性被過濾掉。

$config = new HtmlSanitizerConfig
    ->allowSafeElements()
    // 在 p 元素上允素 style 屬性
    ->allowAttribute(attribute: 'style', allowedElements: ['p']);

你也可以強制特定的元素上必須具備特定的屬性。

$config = new HtmlSanitizerConfig
    ->allowSafeElements()
    // 強制 a 元素上必須要有 target="_blank" 屬性
    ->forceAttribute(element: 'a', attribute: 'target', value: '_blank');

如果你想要的元素會被過濾掉,你可以使用 allowElement() 避免它被過濾掉。

$config = new HtmlSanitizerConfig
    ->allowSafeElements()
    // 允許 oembed 元素,並允許 url 與 class 屬性
    ->allowElement(element: 'oembed', allowedAttributes: ['url', 'class']);

HTML Sanitizer 預設有 20,000 字元的上限,用來避免 DoS 攻擊。如果你不想要這個限制的話,可以使用 withMaxInputLength() 將其關掉。

$config = new HtmlSanitizerConfig
    ->allowSafeElements()
    // 不限制字元數量上限
    ->withMaxInputLength(maxInputLength: -1);

字元長度的計算使用 strlen()

這裡選擇關掉上限的原因是我已經使用 Laravel Validation 來限制字元長度的最大值了。

改用 HTML Sanitizer 後的過濾設定如下:

public static function sanitizeHtml(string $html): string
{
    $htmlSanitizer = new HtmlSanitizer(
        new HtmlSanitizerConfig()
            ->allowSafeElements()
            ->allowAttribute(attribute: 'data-language', allowedElements: 'pre')
            ->allowAttribute(attribute: 'class', allowedElements: ['span', 'code', 'figure'])
            ->allowAttribute(attribute: 'style', allowedElements: ['p', 'figure'])
            ->forceAttribute(element: 'a', attribute: 'rel', value: 'noopener noreferrer')
            ->forceAttribute(element: 'a', attribute: 'target', value: '_blank')
            ->allowElement(element: 'oembed', allowedAttributes: ['url', 'class'])
            ->withMaxInputLength(maxInputLength: -1)
    );

    return $htmlSanitizer->sanitize($html);
}

看起來比原本 HTML Purifier 精簡非常多,超讚。

參考資料

Allen
written by
Allen

持續努力中的後端打工仔,在下班後喜歡研究各種不同的技術。稍微擅長 PHP,並偶爾涉獵前端開發。個性就像動態語言般隨興,但渴望做事能像囉嗦的靜態語言那樣嚴謹。

則留言
顯示更多留言
新增留言
訪客 2026 年 07 月 12 日