過濾器沒有出錯,它只是把連結全部吃掉了

過濾器沒有出錯,它只是把連結全部吃掉了

7-Eleven 的包裹代收名單貼在大廳玻璃上,上面列著合作的快遞公司。郵局不在上面。郵局的包裹每次送到都被退回,沒有通知、沒有錯誤紀錄,收件人只知道什麼都沒收到。把外部 HTML 寫進 WordPress 前做白名單清洗,漏列一個標籤就是這種結構。先講結論:白名單要把 <a> 和它的屬性一起列進去,而且寫入後要比對連結數,靜默過濾才抓得到。

情境

HTML 從外部來,可能是匯入的稿件、別的系統的 API、編輯器貼上的內容,或是模型產出的文字。寫進 WordPress 之前,先過一層 strip_tags 型的白名單清洗,再透過 WP REST API 建文章。流程是同步的:取得 HTML → 白名單清洗 → POST /wp/v2/posts,中間沒有佇列。這個問題跟來源是什麼無關,只要寫入前有一層會靜默過濾、清單又沒人定期看,就會中。

連續三輪,外部連結:0

SEO 工具回報的數字是 0。不是 1,不是偶爾,是三輪都是 0。

來源 HTML 裡明明有連結,看得到 <a href>。但最後存進資料庫的文章一條連結都沒有,Yoast SEO 掃到的 outbound links 一直是空的。

往上追才看到那一步:寫入前的 HTML 允許清單上列著可以保留的標籤,<h4>、<p>、<blockquote>、<strong>、<em>。錨點標籤 <a> 不在裡面。

過濾器沒有報錯。它照設計,把不在清單上的東西靜默拿掉。

錯誤傳染鏈(時序)

來源 HTML           白名單清洗層          WP REST API          WordPress DB
     |                    |                    |                     |
     |── HTML(含 <a>)─>|                 |                     |
     |                    |── 白名單比對        |                     |
     |                    |   <a> 不在清單  |                     |
     |                    |── 靜默移除 <a>  |                     |
     |                    |── POST /wp/v2/posts >|                   |
     |                    |                    |── 寫入 ────────────>|
     |                    |                    |                     |── OK ✓
來源:含連結 ✓                                         DB 存入:無連結 ✗

清洗層是唯一的差異點:來源沒問題、寫入也成功,只有中間這一層在不回報任何錯誤的情況下改了內容。

為什麼沒有立刻看出來

這類 bug 難在它看起來完全正常。來源沒問題,寫入成功,只有資料庫裡那份 HTML 是被洗過的版本。沒有人主動比對「送出去的 HTML」跟「入庫的 HTML」,這個落差不會自己浮出來。

SEO 分數低,第一個直覺是來源本來就沒放連結。回頭看來源,有。到這裡很容易停下來,以為是來源偶爾漏掉。實際上連結一直都在,只是在最後一步被安靜地過濾掉。

順帶一提,WordPress 自己對沒有 unfiltered_html 權限的帳號也會跑一次 wp_kses_post,但那份清單是允許 <a href> 的。所以連結整批消失,通常不是 WordPress 吃的,是你自己加的那一層。

修法分兩層

第一層,允許清單補上 <a>,連同 href 屬性和允許的 URL 協定一起列。少了這步,連結永遠進不了資料庫。只放標籤不放屬性也不行,很多清洗工具會保留 <a> 但把 href 剝掉,結果是一堆點不動的字。

第二層,寫入後做比對:清洗前後各數一次連結,數字變少就記一筆警告。這一層的用意是讓「被過濾掉」這件事有聲音,下次清單又漏了什麼新標籤,第一次寫入就會被抓到,不用等三輪 SEO 報表。

Code 對照:修法前後

修法前(允許清單缺少錨點標籤)

# 寫入 WordPress 前的 HTML 清洗
ALLOWED_TAGS = [
    'h4', 'p', 'blockquote',
    'strong', 'em', 'code', 'pre',
    'ul', 'li', 'ol', 'hr'
    # <a> 不在清單,所有錨點標籤靜默移除
]
clean_html = strip_tags(raw_html, allowed_tags=ALLOWED_TAGS)

修法後(以 Python 的 nh3 為例:補上 <a>、限定協定,寫入後比對連結數)

import re
import nh3

ALLOWED_TAGS = {
    'h4', 'p', 'blockquote',
    'strong', 'em', 'code', 'pre',
    'ul', 'li', 'ol', 'hr',
    'a',                      # ← 補上
}
ALLOWED_ATTRS = {'a': {'href', 'title'}}   # rel 交給 link_rel 統一加

clean_html = nh3.clean(
    raw_html,
    tags=ALLOWED_TAGS,
    attributes=ALLOWED_ATTRS,
    url_schemes={'http', 'https', 'mailto'},
    link_rel='noopener noreferrer',
)

# 寫入後比對:被過濾掉的連結要有聲音
def count_links(html):
    return len(re.findall(r'<a\s[^>]*href=', html, re.I))

if count_links(clean_html) < count_links(raw_html):
    log.warning('sanitizer dropped links: %d -> %d',
                count_links(raw_html), count_links(clean_html))

該被隔離的側效應類型

  • SEO 指標污染:Yoast 外連計數長期是 0,評分一直被扣,看的人會以為是內容問題,而不是寫入流程的結構問題。
  • 稽核工具誤判:任何在清洗之後才跑的檢查(連結掃描、無障礙檢查)都會把沒有連結當成正確狀態,失去偵錯價值。
  • 上游的假驗證:如果品質檢查做在清洗之前,結果就不代表最終入庫的內容,驗證跟上線看的是兩個版本。
  • 排查方向錯誤:連結消失被歸咎於來源不穩,時間花在追來源,真正的根因(清洗層)反而沒人查。
  • 靜默版本漂移:來源開始輸出新的 HTML 結構,白名單沒同步更新,新標籤一樣被吃掉,也不會有任何通知。
  • A/B 測試污染:如果分組靠錨點(追蹤連結、UTM 參數),清洗層會讓實驗資料整批失效,卻不觸發任何告警。
  • 讀者體驗靜默降級:讀者點不到應該有的參考連結,也看不到錯誤,只覺得文章「沒有連結」,無從回報。
  • 站內連結風險:延伸閱讀區塊如果也走同一層清洗,站內連結也會被拿掉,進一步傷到頁面的連結結構。

判斷標準:如果一段處理邏輯可以靜默改掉輸出、卻不影響整體回報的成功狀態,它就是需要獨立稽核的邊界。

一個漏掉的標籤等於一道禁令

允許清單的邏輯是白名單:沒列到的一律不通過。這個設計本身沒問題,它是用來擋任意 HTML 注入的。但白名單有個隱性的維護成本:每多一種合法用途,就得有人主動把對應的標籤加進去,不然那個用途就永遠被靜默封鎖。

漏掉 <a>,等於對所有連結下了一道沒有公告的禁令。不是刻意的,只是沒想到。

所以現在只要碰到「輸出看起來正確、結果卻不對」,我會先找中間有沒有一層會靜默改內容的過濾步驟。它不會主動告訴你它動了什麼。

— 邱柏宇


The Filter Didn’t Fail — It Just Ate Every Link

There’s a parcel pickup list posted on the lobby door of many older apartment buildings: a list of approved courier companies. If your package arrives with a carrier not on that list, it gets turned away. No error. No notification. You just never receive it. Sanitizing external HTML with an allowlist before writing it into WordPress has exactly this shape when one tag is missing. Short version: list <a> together with its attributes, and compare link counts after the write so silent filtering gets caught.

The Setup

The HTML comes from outside: imported drafts, another system’s API, content pasted into an editor, or model output. Before it reaches WordPress it passes through a strip_tags-style allowlist, then gets posted through the WP REST API. The flow is synchronous: get HTML → allowlist sanitization → POST /wp/v2/posts, no queue. The source doesn’t matter; any write path with a silent filtering step and an unaudited allowlist will reproduce this.

Three Rounds, Zero Outbound Links

The SEO tool returned the same number three times in a row: 0 outbound links. Not occasionally. Every single run.

The source HTML clearly had anchor tags in it. But by the time the article reached the database, every link was gone, and Yoast’s outbound links field stayed empty.

Tracing back revealed the step: before the write, the HTML passed through an allowlist of permitted tags, <h4>, <p>, <blockquote>, <strong>, <em>. The anchor tag <a> was not on it.

The filter threw no errors. It did exactly what it was designed to do: silently remove anything not on the list.

Error Propagation Sequence

Source HTML        Sanitizer Layer       WP REST API          WordPress DB
     |                    |                    |                     |
     |── HTML (with <a>)>|                 |                     |
     |                    |── allowlist check  |                     |
     |                    |   <a> not listed |                     |
     |                    |── silently removed |                     |
     |                    |── POST /wp/v2/posts >|                   |
     |                    |                    |── write ───────────>|
     |                    |                    |                     |── OK ✓
Source: links ✓                                        DB stored: no links ✗

The sanitizer is the only divergence point: the source is fine and the write succeeds; only the middle layer altered the content without reporting anything.

Why It Wasn’t Caught Immediately

This class of bug looks normal at every visible checkpoint. The source is fine, the write completes, and the only place the problem exists is the stored HTML. Nobody was comparing what was sent against what was persisted.

The first suspicion was that the source simply had no links. Check the source: they’re there. It’s easy to stop and blame an inconsistent source. The links were never missing upstream; they were removed at the last step, quietly.

A side note: WordPress itself runs wp_kses_post for accounts without the unfiltered_html capability, but that list allows <a href>. If links vanish wholesale, it is usually your own layer, not WordPress.

The Fix Has Two Parts

First: add <a> to the allowlist together with the href attribute and the allowed URL schemes. Without this, no link survives. Listing the tag without the attribute isn’t enough either; many sanitizers keep <a> but strip href, leaving unclickable text.

Second: compare after the write. Count links before and after sanitization and log a warning when the number drops. The point is to give filtering a voice, so the next time the allowlist misses a new tag, the first write catches it instead of three rounds of SEO reports.

Code Diff: Before and After

Before (anchor tag missing from allowlist)

# HTML sanitization before writing to WordPress
ALLOWED_TAGS = [
    'h4', 'p', 'blockquote',
    'strong', 'em', 'code', 'pre',
    'ul', 'li', 'ol', 'hr'
    # <a> not listed; all anchor tags silently removed
]
clean_html = strip_tags(raw_html, allowed_tags=ALLOWED_TAGS)

After (Python with nh3: add <a>, restrict schemes, compare link counts)

import re
import nh3

ALLOWED_TAGS = {
    'h4', 'p', 'blockquote',
    'strong', 'em', 'code', 'pre',
    'ul', 'li', 'ol', 'hr',
    'a',                      # ← added
}
ALLOWED_ATTRS = {'a': {'href', 'title'}}   # rel is added uniformly via link_rel

clean_html = nh3.clean(
    raw_html,
    tags=ALLOWED_TAGS,
    attributes=ALLOWED_ATTRS,
    url_schemes={'http', 'https', 'mailto'},
    link_rel='noopener noreferrer',
)

# Post-write check: dropped links should make noise
def count_links(html):
    return len(re.findall(r'<a\s[^>]*href=', html, re.I))

if count_links(clean_html) < count_links(raw_html):
    log.warning('sanitizer dropped links: %d -> %d',
                count_links(raw_html), count_links(clean_html))

Side Effects That Should Be Isolated

  • SEO metric pollution: Yoast’s outbound link count stays at 0 and keeps costing score. Observers blame the content, not the write path.
  • Auditing tool invalidation: Any check that runs after sanitization treats the stripped output as the correct baseline and loses its diagnostic value.
  • False upstream validation: If quality checks run before sanitization, they don’t reflect what is stored; validation and production are looking at different versions.
  • Misdirected debugging: Missing links get blamed on an unreliable source, so time goes into the source while the real cause, the sanitizer, goes uninvestigated.
  • Silent version drift: When the source starts emitting new HTML structures and the allowlist isn’t updated, the new tags get stripped too, with no alert.
  • A/B test contamination: If variants rely on anchors (tracking links, UTM parameters), the sanitizer silently invalidates the experiment data.
  • Silent reader experience degradation: Readers can’t click reference links that should be there, see no error, and have nothing concrete to report.
  • Internal link structure risk: If related-post sections go through the same sanitizer, internal links disappear too, further hurting the page’s link structure.

The heuristic: if a processing step can silently modify its output without affecting the success status it reports, it needs an independent audit boundary.

A Missing Tag Is an Undeclared Ban

An allowlist runs on a simple rule: anything not explicitly permitted is removed. The design is sound; it exists to prevent arbitrary HTML injection. But allowlists carry a maintenance obligation. Every new legitimate use needs someone to add the matching tag, or that use stays silently blocked forever.

Missing <a> placed an unannounced ban on every link. Not intentional. Just overlooked.

So now, whenever output looks right but results don’t, the first thing I look for is a filtering step somewhere in the middle. It won’t tell you what it removed.

— 邱柏宇

延伸閱讀

分類: 技術筆記,標籤: 、、,作者: Jett。這篇內容的永久連結。

關於「Jett」

邱柏宇,新北人,寫程式的工程師。踩到的坑習慣整理成筆記,從錯誤訊息一路追到根因才收工;在 JustBlog 寫開發除錯、工具與工作流程的實戰紀錄,下班喜歡哼歌、做點手作。 Jett Chiu is a New Taipei–based software engineer who writes up the bugs he runs into, tracing each error message down to its root cause.