Day 14 · W2 · 無障礙線 · 難度 ★★★☆☆
本系列由 AI 協作撰寫。 內容、技術判斷、程式碼由 light-design 數位顧問團隊與 Claude 共同產出,最終由作者驗證後 publish。完整協作模式與把關方式見 Day 01。
我的工具裡有一份清單,長這樣:
付款 · 結帳 · 下單 · 購買 · 刪除 · 取消訂閱 · 捐款 · 登出
pay · checkout · delete · unsubscribe · donate · logout
那不是給使用者看的,是給工具自己看的。它會去點頁面上的按鈕,而這些字出現在按鈕上的時候,它必須停手。
我一直覺得這件事我想得夠周到了。寫這篇的時候回去翻程式碼,才發現真正會把東西送出去的那條路,根本沒有經過這份清單。
一句話主軸:一層防禦失效,通常不是那一層判斷寫錯,是有一條路不經過它。
先講它為什麼會去按。
彈窗、對話框、焦點陷阱、輪播,不互動就檢查不到。更常見的是表單:很多聯絡表單掛在 modal 裡,你不點那顆「聯絡我們」,初始 DOM 裡根本沒有那個 <form>。
所以自動化無障礙檢查繞不開一件事:它必須在別人的正式站上做動作。 不是讀,是做。
一個只會讀的爬蟲,最壞情況是把對方的頻寬吃掉。一個會按的工具,最壞情況是幫對方下了一筆訂單。
第一版只比對按鈕上看得到的文字。那會漏掉一整類按鈕。
一顆只有垃圾桶圖示的刪除鍵,innerText 是空的。
所以比對範圍改成四個欄位的聯集:
const text = [el.innerText, el.getAttribute('aria-label'),
el.getAttribute('title'), el.value]
.filter(Boolean).join(' ').trim();
而且找按鈕有兩條路(照結構屬性找、照文字關鍵字找),兩條都要套同一份清單。只套一條等於沒套。
這裡有一件事對前端比較有用:在只有圖示的按鈕上,aria-label 是機器唯一讀得到的意圖。 它同時是螢幕報讀軟體的依據,也是任何自動化流量判斷「這顆能不能碰」的依據。aria-label 寫得爛,倒楣的不只是使用者。
清單看的是我主動去點的觸發器。
但探針還做另一件事:找到表單的送出鍵,在所有欄位都空的狀態下按下去,看頁面怎麼反應。有沒有指明哪個必填沒填、焦點有沒有跳到那一欄。碼號 GN2330300E 要驗的就是這兩件事,它的第三個字元是 2,AA(Day 13 講過碼號怎麼讀)。
空送為什麼安全? 因為表單有必填欄位,瀏覽器會擋下來,根本送不出去。
而「有沒有必填欄位」是這樣數的:
f.querySelectorAll('[required], [aria-required="true"]')
那兩個不等價。 我自建了三個表單,用真的瀏覽器跑一次:
沒送出 <input required>
實際送出了 <form novalidate> 加 <input required>
實際送出了 只有 <input aria-required="true">
後兩個的網址都變成了表單的 action。東西真的送出去了。
aria-required="true" 是講給輔助科技聽的,它不會啟動瀏覽器的原生驗證;novalidate 則是直接把驗證整個關掉。這兩種情況下,我那個「一定會被擋」的前提都不成立。
而 ARIA 加了、原生屬性沒補,本身就是一種無障礙缺陷。所以結論很難看:我最可能真的送出去的,剛好是無障礙寫得半吊子的那些表單。 那正是這支工具存在的理由。
嚴重度也要照實講:表單探測在每一次 --render 都會跑。--probe-modals 只管「要不要去點開彈窗」,不管送出。沒有旗標可以關掉它。
把兩條路擺在一起就清楚了:
我點的觸發器 → 經過「不准按」清單 → 遇到危險字就停手
表單的送出鍵 → 沒有經過任何清單 → 按下去
清單本身沒寫錯。垃圾桶圖示那類漏洞我補過,四欄位聯集就是為此才寫的。問題是有一條路根本走不到那裡。
會這樣,是因為我把送出當成「讀取」在設計。它看起來像在觀察頁面的反應,實際上是在對別人的伺服器發一個請求。
還有一個更難看的細節。上架前那次自查,我自己寫的摘要裡有一行小標:
Destructive form_probe gated behind --probe-modals
底下的內文是準的,寫的是 Modal-trigger clicks default off。小標宣稱的保證,比程式碼實際給的大。
而我後來要確認這件事的時候,讀的是小標。
在白紙上蓋一個「已檢查」的章,然後把那張紙當成檢查結果。 章是真的,紙上什麼都沒有。
修法很短:帶 novalidate 的表單不按;必填只用 aria-required 標、沒有原生 required 的也不按。
但第三條才是重點:不按不等於通過。 拒按的表單會報成 caveat,在報告上佔一行,交給人看。Day 12 講過同一個取捨,少判一條好過判錯一條。判錯的那條會安靜地留在報告上,看起來像沒事。
對照組就在同一個 repo 裡。
使用者給的網址會走一整套檢查:
拒絕 非 http(s) 的 scheme(file://、gopher://)
拒絕 loopback、私有網段、link-local、reserved、multicast
拒絕 IPv4-mapped IPv6 包裝(::ffff:127.0.0.1)
拒絕 主機名的「任一個」解析結果落在上述範圍
第三條是實作時踩到的。::ffff:127.0.0.1 這種寫法,Python 標準庫的 is_loopback 對它回 False,得先把包在裡面的 IPv4 拆出來才判得到。
第四條也不能省。一個網域可以同時解析出一個公開 IP 跟一個內網 IP,只檢查第一個就會被繞過去。
內網稽核是合法需求,所以留了 --allow-private-hosts 讓操作者明講。
真正的差別在掛載點的數量。

同一支工具、同一次自查、同一個作者。差別只在哪一條路被當成「危險操作」在設計。
當初那次自查寫的是「掛在四個地方」。現在是 20 處、散在 10 個檔 —— 抓取、爬蟲、樣式表下載,加上六支瀏覽器探針,每一支在跳轉之後都再驗一次。
因為第一次驗過的網址,可能把你導到別的地方去。
而這個數字從 4 長到 20,本身就是答案:四個掛載點是「我當時想得到的路」,20 個是「後來每開一條新路就補一次」。縱深防禦不是一次做完的設計,是每加一個功能都要再問一次的紀律。
送出鍵那條路,就是我忘了問的那一次。
第三層很無聊,全部都是數字:
sitemap 單檔 25 MB · 巢狀深度 3 · 全部合計 5 萬個網址
樣式表 單張 5 MB,而且快取本身有筆數上限
LLM 快取 寫入前檢查是不是 symlink,是就不寫
HTML 報告 CSP:default-src 'none'; base-uri 'none'; form-action 'none'
沒有上限的迴圈,對方只要餵一個夠大的東西就能拖垮你,而你會以為那是自己的效能問題。
sitemap 那三個數字是同一件事的三個面向:單檔太大、巢狀太深、總量太多,任何一個沒設上限,剩下兩個都攔不住。一個 sitemap 可以指向另一個 sitemap,而那個又可以指回來。

四層擋的東西完全不一樣,共同點只有一個:都是在假設對方不懷好意的前提下寫的。
CSP 那條值得多講一句。一份無障礙報告裡面,塞滿了受檢網站的 HTML 片段 —— 出問題的那些標籤、那些屬性值,全部原樣引在報告裡。那份報告本身就是一個把不可信內容渲染出來的頁面。
而報告會被寄給客戶、附在信裡、上傳到共用磁碟。它離開你的機器之後還會被打開很多次。
一個檢查工具最後產出的東西,也可能變成攻擊面。
最後一層跟你寫的程式碼沒關係。
lxml >=6.1 CVE-2026-41066 XXE
playwright >=1.59 CVE-2026-2441 Chromium use-after-free
python-dotenv >=1.2.2 CVE-2026-28684 symlink
還有一個不是升版,是換掉:sitemap 的 XML 解析改用 defusedxml。標準庫的 XML 解析器對付不了刻意寫壞的 XML。
playwright 那條最值得注意,因為它不在主要依賴裡。它掛在選配的分組,一般安裝不會裝它,因為它連瀏覽器一起下載大約 290 MB。
但只要有人跑 --render 就會用到它。CVE 公告不會管你的套件設定檔怎麼分類。 你標成選配的那幾萬行,一樣是你的攻擊面。
aria-required 不會啟動瀏覽器驗證,novalidate 會關掉它,兩者都讓空送變成真送aria-label 是機器唯一讀得到的意圖,不只螢幕報讀軟體在讀caveat,讓它在報告上佔一行明天 Day 15:我的圖片一張都沒有漏掉 alt。但那個滿分是怎麼來的:我替其中大部分的圖,決定了它不用被唸出來。