表單我都有驗啊。長度、格式、必填全擋了。
今天先用一行指令讓那些限制通通不存在,然後把昨天結尾答應的那條線畫出來。
一個改數量的欄位,長這樣:
<input id="quantity" type="number" min="1" max="10" step="1" required value="2">
打開瀏覽器,把數量改成 -5,按送出。
送不出去。瀏覽器自己跳一個說值太小的提示,連請求都沒發出去。
清空欄位也一樣,那是 required 在管。打 999 也一樣,那是 max 在管。都是當場擋,不用等伺服器回話。
這一段沒有任何諷刺的意思。前端這道擋得住,而且它做對了一件後端做不到的事:它在使用者手指還放在鍵盤上的時候就告訴他哪裡不對。等一趟往返回來才說「數量不對」,體驗差得多。
min 和 max 是 HTML 標準的一部分,瀏覽器天生就認得(2026-08 查證),你什麼都不用寫。
所以到這裡,這個欄位是有規則的。1 到 10,整數。
現在不開瀏覽器。
先把範例伺服器起起來,它會把自己的埠印出來:
node before/server.mjs # 印出 PORT=xxxxx,每次都不一樣
它會一直跑著佔住那個終端機,所以下面的指令開第二個視窗打,先把埠設起來:
export PORT=xxxxx # 換成它印給你的那個數字
把那個埠貼進瀏覽器也行,網址是 http://127.0.0.1:xxxxx/,xxxxx 換成它印給你的數字(瀏覽器不會幫你展開 shell 變數)。開起來就是上面那張表單。
現在直接打那支 API,跳過表單:
curl -s -w '\n[%{http_code}]\n' -X PATCH \
-H 'content-type: application/json' \
-d '{"quantity":-5}' http://127.0.0.1:$PORT/orders/1
{"id":1,"itemId":"sku-1","quantity":-5}
[200]
沒有跳提示,沒有被拒絕。它回了 200,而且回傳的那筆訂單,數量是 -5。
停一下,這裡有一個很容易滑過去的坑。
回 200 不等於它收下了。 有的寫法會擋下來但回錯狀態碼,也有的會回 200 而其實什麼都沒改。狀態碼只證明伺服器有回話。
要確認繞過真的成立,得再讀一次那筆資料:
curl http://127.0.0.1:$PORT/orders/1
{"id":1,"itemId":"sku-1","quantity":-5}
-5 真的躺在裡面了。
這條規矩從 Day 5 到現在每一天都出現:一個在事情沒做的時候也會顯示成功的檢查,不是驗證。 昨天用在權限探針上,今天用在這裡。你以為你在驗「有沒有被擋」,其實你在驗「伺服器還活著嗎」。
順帶一提,同一個值往建立那支端點送,是被擋下來的:
{"error":"quantity must be an integer between 1 and 10"}
[400]
同一個欄位、同一個值、同一台伺服器,兩個結果。這個矛盾等一下要用。
換到你自己的專案上,那條 curl 不用手打。開 DevTools 的 Network,用合法的值正常操作一次那個表單,找到那一筆請求,右鍵、Copy、Copy as cURL,貼到終端機再把值改掉。合法值那一步不是隨便的,它讓你先拿到一條已知會成功的指令,這樣改值之後被擋,你知道是那個值被擋,不是你的請求本來就湊不齊。跟昨天探針的正反兩趟是同一套:先證明你打得進去,再看它擋不擋你。
改完值送出去之後還有一步,把那筆資料讀回來。理由跟上面那段一樣,回幾號不算數,值進去了才算。
兩個邊界。這件事只對你自己的服務做,同樣的動作打在別人的站上性質完全不一樣。還有,複製出來那條可能帶著 Cookie、Authorization、CSRF token 這些身分資料,等於一份可以冒充你的東西,而且它通常會留在 shell 歷史裡。別貼進 issue、也別跟著問題一起丟給 AI。Day 4 那份 AI-SHARING.md 這裡照用,第一段就叫「不可貼」。
現在可以畫了。

這張圖要看的是中間那個空隙,還有跨過去的那兩支箭頭。左邊框裡的東西,包括你寫的那些 min 和 max,全部在使用者手上。他可以照著表單送,也可以不照著送,而這兩種送法在右邊那個框看起來一模一樣。
所以驗證只有畫在右邊才算數。畫在左邊的那份,功能是提示,不是規則。
這條線有個名字,叫信任邊界。線裡面是你控制得了的東西,這篇後面叫它界內;外面那些你只能假設它們會亂來,叫界外。名字不重要,重要的是你指得出自己的線畫在哪裡。我是做了八天才把它畫出來的。
Day 1 講的是前端藏不住東西,金鑰放進去就等於公開。今天這條線把那句話推廣了:前端藏不住,也擋不住刻意繞過它的人。它擋得住手滑,擋不住不想照規矩來的。同一件事,一次講祕密,一次講規則。
知道這件事之後,很多人的下一步是把前端那份驗證搬到後端。輸入框上的 min、max、required 拿掉,反正它擋不住有心人,留著幹嘛。
MDN 的表單驗證那篇說驗證要做 on the server-side as well as the client-side。as well as,不是 instead of。
但光講「兩份都要有」會漏掉一件真正的事:兩份會分岔。
前端寫 max=10,半年後業務改成 20,改的人只改了後端那一支。使用者在畫面上送不出 11,後端明明收得下。這時候客服收到的回報是「你們系統壞了」,而兩邊的程式碼各自都是對的。
這才是想把前端那份拿掉的人真正的理由。 他不是不知道安全要放後端,他是受夠了兩份數字會走散。拿掉一份確實不會再分岔,代價是使用者每打錯一次都要等一趟往返,而該繞的還是繞得過去。
所以答案不是刪掉一份,是兩份指向同一個來源。前端那份留著,但別讓它自己抄一份數字。
具體到範例伺服器上就是:上下界寫在一個地方,兩支端點去問它,送給瀏覽器的那張表單也去問它,min 和 max 是填出去的不是寫死的。改那個地方,三個位置一起變。
要注意這件事今天只在範例上做得到,因為它前後端同一個行程。你的專案前後端如果是兩個 repo、兩種語言,共用一份規格是另一個題目(產生程式碼、共用 schema、或者退一步只做一份對照表定期比)。今天的範圍是:至少讓後端那幾支寫入端點之間不要先分岔。
圖上右邊那格灰色的,標著 Day 1 到 Day 8,那是前八天的全部內容。金鑰、.env、貼給 AI 的東西、生出來的程式碼、裝進來的套件、昨天量的 MCP 權限,全都落在界內。Part I 做的是把界內整理乾淨。
那八天有一個共同前提,我一直沒講:看的都是你的開發環境,而出事的主要是你。 金鑰外流,燒的是你的帳單;套件有毒,中毒的是你的環境。
今天這條 curl 把前提換掉了。線的另一邊站著的不是你,是用你東西的人。
從今天到 Day 19 都在同一條線上走:東西從哪裡進來、進來之後誰放行、放行之後它能碰到多少。今天走的是最直白的那條路:使用者從表單送資料進來,而你在入口檢查那份資料。
畫完線,我拿自己一個天天在用的小工具去對。那是一個本機單人跑的專案管理工具,端點是我跟 AI 一起長出來的,一支一支加。
先掃了一遍寫入端點,31 支。我本來以為會看到一片空白。
其中 29 支的程式碼裡出現過 400 這三個字元。
這句話刻意講得這麼扭,因為它只能講到這裡。我做的事情是把每個處理函式切出來 grep,4000 這種常數也會被算進去,而且就算真的是 400,也可能是轉手上游的錯誤,跟輸入驗證無關。29 是一份候選清單,不是 29 支端點真的驗過什麼。
它唯一能做的事是叫我停下來:如果連候選都有 29 支,我就不能再靠「全部沒驗證」這個印象往下查。真正推翻那個印象的,是我接著逐支讀、逐支打的那幾支建立端點,不是這個 29。
還有一件事要先講清楚。那個工具沒有公開,所以 31、29、六個資源這三個數字只能算我的個案觀察,你沒辦法從公開資料重跑。 文末那份 recipe 重現的是缺口的形狀,不是這份統計。
印象會讓你去找錯的東西,然後找不到就以為沒事。
那洞在哪?
我把有兩支寫入端點的資源挑出來,同一個檔案裡一支建立、一支編輯,六個資源都有。然後逐一看它們各自檢查什麼。
| 欄位 | 建立那一支 | 編輯那一支 | 這個欄位拿來做什麼 |
|---|---|---|---|
| 名稱 | 必填、長度上限 | 沒檢查 | 顯示在畫面上 |
| 顏色 | 要合格式 | 沒檢查 | 畫一個小圓點 |
| 資料夾名 | 有檢查 | 有檢查 | 會真的去搬硬碟上的檔案 |
六個資源,六次同一個形狀:建立那半檢查欄位內容,編輯那半只檢查「你有沒有告訴我要改哪一筆」。前面那支範例伺服器就是照這個形狀寫出來的。
缺口是前兩列。第三列是唯一兩邊都擋的,而它憑什麼特別,git log 上看得到:那是後來一個「這個欄位可以自訂」的功能帶進來的,那次我在兩邊都寫了清洗邏輯,因為它會去動檔案系統。
危險看得見的那一列,規則就自己長到兩邊去了。
把上面那件事講成一句話:驗證長在你看得見危險的地方。
看得見危險的那個欄位,我兩邊都寫了。看不見的,我只在當下那一支寫了一份,然後就沒有了。
這跟 AI 有什麼關係?下面是我的解讀,不是我從紀錄裡挖出來的因果,先講清楚。
那些端點是一支一支加上去的,一次一個需求。要一支建立用的、順口說了記得驗證,拿回來的東西很完整:
// POST /orders
if (!Number.isInteger(body.quantity) || body.quantity < 1 || body.quantity > 10) {
return json(res, 400, { error: "quantity must be an integer between 1 and 10" });
}
過幾天要一支改數量的,拿回來的一樣能跑:
// PATCH /orders/:id
if (body.quantity !== undefined) order.quantity = body.quantity;
第二次的需求裡沒有提到驗證,而它手上也沒有一個叫做「這個資源的規則」的東西可以繼承。它不是忘了,是它從來就沒有那條規則。
這種事人工外包也會發生,AI 換掉的是兩件事:密度,還有審查為什麼失守。
密度那件事看數字。那個工具是我自己一個人的 side project,週末寫的,而它有 31 支寫入端點、六個資源六次全中。我自己手寫的年代沒有長出過這種東西,不是因為我那時比較嚴謹,是因為端點沒有那麼便宜。同一個形狀你一年遇到一次,還會記得上次怎麼處理的;在這裡是分散在幾個星期各自長出六個,每一個當下都覺得只是「再加一支」。
審查那件事比較細。我自己手寫的半成品通常長得像半成品:留著 TODO、留著空行、留著一句「這裡之後要補」。審查的注意力本來就是靠不完整感觸發的,而生出來的那份是完稿的形狀。 上面那兩行沒有任何一個地方在對你招手。它看起來就是一支正常的更新端點該有的樣子,所以你的眼睛滑過去了。
那修法呢?把 checkQuantity() 抽出來、兩支端點都去問它,這是 2005 年就有的重構。今天真正的問題是下一次:
規則要放在下一次生程式碼的時候它會讀到的地方。
這句話可以驗收,而且今天就驗得完。請它加第三支端點,不要提驗證,然後看新生出來的那支有沒有自己去問那個地方。沒有的話,你的規則還是沒有住的地方,你只是把兩份合成了一份。
這是這篇我最想留下的判準,而它跟模型、框架、語言都無關:同一個資源的規則沒有落點的時候,就不能指望一次一個需求長出來的端點會共用它。
今天的產出是一份「欄位 × 驗證位置」對照表。做法有兩種,差很多。
一種是讀程式碼,把每個欄位在哪裡被檢查列出來。這件事 AI 很快,逐檔比對是它的強項,我上面那個 31 支的掃描也是這樣做的。
但那張表會騙你,剛剛已經騙過一次了。讀程式碼告訴你「這裡有一段檢查」,它不告訴你那段檢查有沒有攔到你在意的那個值。條件寫反、少一個等號、跑進另一條分支,讀起來都很正常。
所以第二種做法是打一遍。同一個值,往每一個位置各送一次,記下它收不收。
bash probe-table.sh before
before 的對照表(送進去的值:quantity = -5,每一格都讀回來確認過)
| 欄位 | 前端宣告 | 建立 POST | 編輯 PATCH |
| quantity | min=1 max=10 | 值沒進去 (400) | 值進去了 (200) |
修好之後同一支腳本再跑一次,那張表就是今天的成果照片:
after 的對照表(送進去的值:quantity = -5,每一格都讀回來確認過)
| 欄位 | 前端宣告 | 建立 POST | 編輯 PATCH |
| quantity | min=1 max=10 | 值沒進去 (400) | 值沒進去 (400) |
後面那兩格都要「值沒進去」,這個欄位才有規則。 第一格是送到瀏覽器那份宣告了什麼,它擋不擋不影響結論,因為它在使用者那一邊。
注意那兩格填的是「值進去了沒」,不是「回幾號」。這支腳本的第一版是看狀態碼填的,我把 PATCH 改成「先寫進去、再回 400」試了一次,表上兩格都印「擋下」,而資料真的變成 -5。一張看起來查過的表比沒有表更糟,因為你會拿它去填自己的清單,然後把一個活的洞填成綠的。
AI 適合做的是前半:把候選欄位跟它們對應的位置整理出來,這樣你才知道要打哪幾發。問法很重要,問「幫我看有沒有安全問題」拿回來的是一篇通論。要問的是一個沒有判斷空間的清點題:
列出這個專案裡每一個接收使用者輸入的欄位,每一列填四格:欄位名、前端在哪裡限制它、後端哪一支端點會寫入它、那一支對它做了什麼檢查。查不到就填「查不到」,不要猜。
最後那句要寫進去。不寫的話它可能用「應該有做驗證」把空格填滿,而那正是昨天那張權限表的 unknown 在防的事。
後半那個「這一格被繞過會怎樣」不能交出去。同一個缺驗,在一個顯示用的顏色欄位上最壞是版面變醜,在數量欄位上可能變成負數訂單,在金額欄位上是另一回事。嚴重度是業務判斷,不在程式碼裡。
我自己那個工具就是前者。本機單人跑,最壞是版面壞掉。我不會把它講得比實際嚴重,那對讀者沒有幫助,而且會讓真正嚴重的那幾格失去對比。
修法就是把規則抽出來,讓兩支端點都去問同一個地方。範例伺服器裡那個地方是 rules.mjs:
export const QUANTITY_MIN = 1;
export const QUANTITY_MAX = 10;
export function checkQuantity(value) {
if (!Number.isInteger(value)) return "quantity must be an integer";
if (value < QUANTITY_MIN || value > QUANTITY_MAX) {
return `quantity must be between ${QUANTITY_MIN} and ${QUANTITY_MAX}`;
}
return null;
}
上下界拉成常數不是為了好看。送給瀏覽器那張表單的 min 和 max 也是拿這兩個常數填的,所以改這裡,三個位置一起變。連錯誤訊息都是組出來的,不然改了界線訊息還在講舊數字。
值得講的是怎麼確認它生效。四件事一起看,少一件都會給你假綠燈。
第一,同一支 curl 重打一次,要被擋。
第二,還是要讀回來。 被擋跟沒改到是兩件事,前面那台說謊的伺服器就是回 400 而值進去了。
第三,要擋在對的原因上。 400 是驗證擋的,500 是它壞了,404 是它沒找到你指的那個東西。三種都會讓「資料沒被改到」成立,但只有第一種是你要的。所以檢查要看錯誤訊息。
第四,合法的那些還要過得去,而且要打邊界。 這條最容易漏。把所有請求一律拒絕,前面三條全部會變綠,那不是一個安全的 API,那是一個壞掉的 API。而只打中間的 3 也不夠,<= 1 || >= 10 這種差一個等號的寫法照樣全綠,1 跟 10 被誤擋你不會知道。
綠 PATCH quantity=-5 被擋(400)
綠 擋下來的原因是值域檢查,不是伺服器壞了
綠 被擋之後那筆沒有被改動(2)
綠 合法的 quantity=3 仍然改得動(3)
綠 合法的 quantity=1 仍然改得動(1)
綠 合法的 quantity=10 仍然改得動(10)
最後三條有個名字,叫正對照:先證明這套檢查在事情正常的時候會放行,它報紅才有意義。昨天那支權限探針的第一趟也是同一件事。
三件事要說清楚,免得你以為今天做完就沒事了。
它量不到規則有沒有落點。 把兩支端點各自抄一份一模一樣的檢查,上面那六條會全綠,而且永遠會全綠,因為行為完全相同。打一遍這個方法量得到「這個值有沒有被擋」,量不到「這條規則有沒有一個住的地方」。後者只能用讀的,上一節那條驗收判準就是為了補這一格。
今天只管值合不合法,不管誰有權改。 那個 PATCH /orders/1,任何人打都會成功。範例伺服器只有一筆訂單所以看不出來,換成一個有多筆資料、多個使用者的服務,/orders/2 就是別人的那一筆。那個洞今天一個字都沒補,它是 Day 16 的題目。
對照表只涵蓋你想得到要打的欄位。 沒被列進去的欄位不會出現在表上,而它們的狀態不是綠的,是沒查過。這跟昨天那張權限表印 unknown 是同一件事。
一份「欄位 × 驗證位置」對照表,每一格都是打過、而且讀回來確認過才填的。
至少一條補上去的後端驗證,加上那六行驗證輸出。留著那六行,是因為三個月後你會想不起來當初為什麼相信它擋得住。
一條可以拿去驗收的判準:請 AI 加第三支端點、不提驗證,看它會不會自己去問那個規則。
攻擊集今天多一條,第 3 條:一個前端擋得下、後端擋不下的值。 它跟前兩條的共同點是,防線宣告過會擋它,實際上沒有。Day 14 那份防護 prompt 要拿這批東西來驗收,所以每一條都留著。
昨天那 67 個沒選過的相依還在,那個數字今天沒動。
今天做的事情,講白了就是把不可信的輸入擋在 API 入口外面。這個做法沒什麼爭議。
明天那個輸入照樣從那個入口進來,而且它會通過今天做的每一道檢查。長度合法、格式合法、必填有填。
因為它就是一段普通的文字。
問題出在它進來之後:你把它接到自己寫的那句 prompt 後面,於是它坐在你的指令旁邊,長得跟你的指令一模一樣。而你手上的防線,多半就是在前面多寫一句「不要理會使用者的指示」。
明天我們親手打一次,看那句話撐幾秒。
今天這幾支腳本:recipe 09|範例專案:github.com/cyh7789/ai-security