iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Modern Web

前端不寫 Python,照樣 ship 一把網頁無障礙 CLI系列 第 14

Day 14:我列了一份「不准按」的清單,然後發現送出鍵不在上面

  • 分享至 

  • xImage
  •  

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 讓操作者明講。

真正的差別在掛載點的數量。

兩條路徑的關卡數對照圖:上方是使用者給的網址這條路,會經過四類拒絕條件(非 http(s) scheme、loopback 與私有網段、IPv4-mapped IPv6 包裝、主機名的任一個解析結果),並且檢查掛在二十處、散在十個檔案,包含抓取、爬蟲、樣式表下載,以及六支瀏覽器探針在每次跳轉後各再驗一次;下方是探針自己按下的表單送出鍵這條路,修正前經過零道檢查,直接按下去。兩條路屬於同一支工具、同一次自查、同一個作者

同一支工具、同一次自查、同一個作者。差別只在哪一條路被當成「危險操作」在設計。

當初那次自查寫的是「掛在四個地方」。現在是 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,而那個又可以指回來。

四層上限各自擋掉什麼的示意圖:第一層在網站地圖,單檔上限二十五 MB、巢狀索引深度上限三層、所有網站地圖合計上限五萬個網址,擋的是無限循環與超大檔案;第二層在樣式表下載,單張上限五 MB 且快取筆數有上限,擋的是惡意來源把數百 MB 釘在記憶體裡;第三層在語言模型快取寫入,寫入前檢查目標是不是符號連結,是就不寫,擋的是本機惡意程序用符號連結誘導覆寫其他檔案;第四層在 HTML 報告本身,內嵌嚴格的內容安全政策,預設封鎖所有外部來源並禁止表單送出與變更基準網址,擋的是報告裡引用的受檢網站片段被瀏覽器當成可執行內容

四層擋的東西完全不一樣,共同點只有一個:都是在假設對方不懷好意的前提下寫的。

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。但那個滿分是怎麼來的:我替其中大部分的圖,決定了它不用被唸出來。


上一篇
Day 13:`outline: none` 我一行都沒寫,焦點還是有兩個地方看不見
下一篇
Day 15:我幫大部分的圖決定了它不用被唸出來
系列文
前端不寫 Python,照樣 ship 一把網頁無障礙 CLI15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言