403
伺服器完全看懂了這個請求,而且拒絕執行它——跟 401 不一樣的是,它沒有義務給你任何一個標頭去說明怎樣才行得通。
同樣三個數字,你站在請求的哪一邊,它就是不同的問題。直接讀你自己那一塊。
為什麼會看到它
使用者明明就登入著、請求卻收到 403,原因就那幾種,而且很少在你的 fetch 呼叫裡。最常見的是 CSRF token 沒帶、過期,或綁在一個之後已經輪替掉的 session 上:舉例來說,Django 的 `CsrfViewMiddleware` 在這種情況回的正是 403 而不是 401,所以一個開著過夜的頁面,第一個 POST 就開始失敗,而所有 GET 都還好好的。第二種是預簽網址過了期限——物件儲存在簽章的截止時間過去之後,會回一個 403 加上一段 access-denied 的內容,所以一條你快取起來重複用的網址會先正常一陣子,然後就不行了。第三種根本不是你的 API:某個 WAF 覺得這個請求長得像攻擊,於是回了它自己的 HTML 攔截頁,於是 `res.json()` 在一份從來不屬於你的文件上丟出例外。
該怎麼做
什麼都別做之前,先搞清楚你收到的是誰的頁面。如果 body 是 HTML 而不是你 API 的 JSON,去讀 `server`、找 `cf-ray`——一頁有品牌樣式、帶著 ray id 的攔截頁就是邊緣規則,而那個 ray id 就是客服查得到的字串。CSRF 那一種,請重新載入頁面去拿一個新的 token,不要把舊的再送一次;而且要把「強制重新整理之後就好了」當成診斷結果,不是修法。預簽網址請在要用的當下才去要一條新的,不要存起來。還有,不要寫重試:§15.5.4 明講用戶端不要用同一組憑證重送,所以在 403 上做重試迴圈,只會把你的 WAF 本來就不喜歡的流量放大。
為什麼會看到它
不是你自己的授權層拒絕的,就是它前面某個東西拒絕的,而錯誤紀錄通常一行就講明是哪個。在 nginx 上,有三種從外面看一模一樣的 403:存取模組裡的 `deny` 指令是照位址拒絕;`open() "/path" failed (13: Permission denied)` 是檔案系統拒絕了 worker 使用者,nginx 把它對應成 403;而請求一個 `autoindex off` 的目錄,會記下 `directory index of "/path/" is forbidden`,因為沒有索引檔可送、而列目錄又被關掉了。物件儲存上的陷阱不一樣,而且是故意的:AWS 的文件寫著,一個沒有 `s3:ListBucket` 權限的呼叫者,去要一把不存在的鍵時收到的是 403 Access Denied 而不是 404,用意就是不讓人拿回應去試探裡面有哪些鍵。
該怎麼做
在動任何應用程式碼之前先讀代理的錯誤紀錄,因為上面那三種 nginx 情況是三種不同的修法,而紀錄那一行免費幫你分好了。接著確認這個請求到底有沒有到你的應用程式:拿你自己的 request id 去翻自己的紀錄,如果它從頭到尾沒出現,拒絕就發生在上游,你授權程式碼裡的東西一件都不相關。檔案系統那一種,先看檔案的權限模式和擁有者,再看每一層上層目錄的執行位元,因為一個穿不過中間某層目錄的 worker,跟一個讀不到檔案的 worker,產生的是一模一樣的 403。物件儲存那邊,如果你在除錯時需要能分辨 404,就在測試用的政策裡給 `s3:ListBucket`,但要記得在正式環境裡,那份模糊是刻意的功能。至於 WAF,去攔截頁上找規則 id,然後拿它去對請求的 body,不是對路徑。
為什麼會看到它
網站知道你要什麼,而且決定不給你。這跟死連結不一樣,也跟密碼問題不一樣——它是一次拒絕,而且通常是在講「你是哪個帳號、在哪個地方、從哪個網路來」,跟你打了什麼字沒關係。限定地區或限定付費方案的內容、一份共用文件的權限給了 A 信箱卻用 B 信箱打開、一條過期的邀請連結,還有網站會擋的 VPN 或公共網路,全都會產生同一頁。
該怎麼做
先看你現在是用哪個帳號登入的,因為「連結分享到公司信箱、卻在登入私人帳號的瀏覽器裡打開」是最常見的單一原因,而且十秒鐘就能排除。如果你在 VPN、公司網路或公共熱點上,先關掉再試一次,因為有些網站會整段位址擋掉。如果那一頁上寫了一個 reference 或 ray id,把那串字留著——那是網站維運的人唯一能直接查到的東西。而如果連結是別人給你的,請他用你現在這個信箱重新分享一次,不要一直重試手上這條。
對著出錯的那個網址跑一次。它只印狀態碼、不印錯誤頁,所以你看到的是伺服器真正說了什麼,而不是瀏覽器畫出來的東西。
curl -sS -D - -w '\n%{http_code}\n' https://example.com/admin/這裡刻意沒有把 body 丟掉,因為在 403 上,那往往是唯一寫著原因的地方——§15.5.4 沒有要求任何說明用的標頭,所以那一頁本身就是證據。讀三件事。`server` 標頭和有沒有 `cf-ray`,告訴你這次拒絕是誰寫的:你自己的應用程式、你的代理,還是在兩者之前就回答的邊緣網路。body 告訴你這是哪一種拒絕——你 API 的 JSON 錯誤、nginx 的預設頁,還是一頁帶著規則 id 或 ray id、有品牌樣式的攔截頁。而 `www-authenticate` 不在,本身就是資訊:那個標頭在的話,你面對的其實是 401 的問題、補憑證有可能有用;它不在的話,往這個請求上加憑證不會有任何幫助。想知道是邊緣節點還是來源機拒絕的,同一行加上 `--resolve example.com:443:203.0.113.10` 再跑一次,它會照樣送原本的主機名稱和 SNI,但直接連到你指定的來源位址。403 在直連來源機時消失,就是邊緣或 WAF 規則;還在,就是你自己的伺服器。如果這個拒絕只在瀏覽器上重現得出來,就加 `-H 'user-agent: Mozilla/5.0'`,因為有些規則只看那一個標頭。
RFC 9110 §15.5.4 把 403 定義成:伺服器看懂了請求但拒絕滿足它;接著補上那條決定你怎麼查的條款——如果請求有提供認證憑證,伺服器認為那些憑證不足,而且用戶端「不應該」自動用同一組憑證重送。那是一條直接叫你不要重試的指示,也是為什麼把 403 放進重試迴圈永遠是 bug 而不是解法。同一節說,伺服器如果願意公開請求為什麼被禁,可以把原因寫在回應內容裡——注意是「可以」,不是「必須」。403 上沒有任何強制的標頭,而這正是它比 401 難診斷的原因:回應裡沒有任何欄位有義務指出是哪條規則觸發的,所以答案住在某份你得自己去翻的紀錄裡。§15.5.4 最後留了一個逃生口,而這一區大部分的混亂都是它造成的:如果伺服器不想透露這個資源存在,它「可以」改回 404——這代表「一個你明知道存在的東西回 404」和「一個你明知道存在的東西回 403」其實是同一個決定,只是做決定的管理者品味不同。從這裡可以推出兩件實務上的事。第一,403 不在 RFC 9110 §15.1 那份可依啟發式規則快取的狀態碼清單裡,所以中介裝置在沒有明確快取標頭的情況下不會把它存起來——一個在你把權限修好之後還陰魂不散的 403,是某個人寫下的快取政策,不是預設行為。第二,403 跟方法無關、也跟 scheme 無關:它完全沒有在講你認證了沒,這就是為什麼「補上憑證再重試」在這裡是錯的直覺,在 401 上才是對的。
| 容易搞混的 | 怎麼分 |
|---|---|
| 401 | 401 是「我不知道你是誰」,403 是「我知道,不行」。要讀標頭,不要讀字面:RFC 9110 要求每個 401 都要有 `WWW-Authenticate`,因為換憑證有可能成功;403 什麼都不要求,因為換了也不會成功。重試行為的差別也是從這裡來的——用戶端被期待用新憑證去重試 401,而 §15.5.4 則叫它不要拿同一組憑證去重送 403。 |
| 404 | 這兩個常常是同一個決定。§15.5.4 明白允許一台不想透露被禁資源存在的伺服器改回 404,而 AWS 的文件寫著 S3 對沒有 `s3:ListBucket` 的呼叫者做的正是這件事。所以一個在你認證之後變成 200 的 404,從頭到尾就不是路由問題;而一個你無法從別處確認是否存在的路徑回 403,可能還是兩個答案裡比較誠實的那一個。 |
| 429 | 限流器不一定要回 429。Cloudflare 的限流規則可以把動作設成直接封鎖而不是回 429,而 GitHub 的文件也寫著超過它的速率限制時可能回 403 也可能回 429。所以一個只在高負載時出現、等一下就自己好的 403,是限流不是權限——在你跑去稽核角色設定之前,先看它跟請求量有沒有相關。 |
451 | 451(RFC 7725,Unavailable For Legal Reasons)是更精確的代碼,用在「因法律要求而拒絕」而不是「營運者自己的政策」上,而且它要求回應帶一個 `rel="blocked-by"` 的 `Link` 標頭,指出提出要求的那一方。一個網站對法律封鎖的資源回 403 並沒有錯,只是比較不精確——但如果你看到的是 451,那個問題永遠不是技術能解的。 |
nginx | nginx 會從三個彼此無關的地方產生 403,而錯誤紀錄一行就分得出來。存取模組裡的 `deny` 是照用戶端位址拒絕;`open() ... failed (13: Permission denied)` 是檔案系統拒絕了 worker 使用者;而 `directory index of "..." is forbidden` 代表有人要了一個目錄、裡面沒有索引檔、而 `autoindex` 是關的。在這三個之間用猜的,代價遠比讀一行紀錄高。 |
這些全都在你的瀏覽器裡跑,不會上傳任何東西。
查 403 的時候,通常也會順手看一下這幾個。
去讀回應標頭,不要讀那個網頁。curl -sS -o /dev/null -D - https://example.com/path 會只印標頭、不印內容,而 server、via 和 cf-ray 三個加起來,就能指出是哪一層回答的。有 cf-ray 的值代表這個回應是 Cloudflare 處理的,那個值也是他們客服會跟你要的編號。想把邊緣節點整個排除掉,就加上 --resolve example.com:443:203.0.113.10 再打一次:它會照樣送出原本的主機名稱和 SNI,但直接連到你指定的來源位址——答案變了,就代表邊緣節點和來源機講的不是同一件事。
不重要,而且絕對不要拿它來做判斷。RFC 9112 要求用戶端忽略原因片語,伺服器可以隨意改它,而 HTTP/2 和 HTTP/3 根本沒有這個欄位——所以在 HTTP/1.1 上是 404 Not Found,到了 HTTP/2 就只剩一個 404。這個網站會寫出登記在案的片語,是因為那是大家會拿去搜尋、也是會出現在 HTTP/1.1 紀錄裡的字串,不是因為有任何軟體依賴它。
5xx 和 429 可以重試,4xx 不要——下一次送過去也不會有任何不同。如果回應帶了 Retry-After 就照它做,RFC 9110 定義它就是為了這件事,值可以是秒數也可以是一個時間點。沒有的話,就用帶抖動的指數退避加上一個硬上限,而且只對冪等的方法這樣做:一個被重試的 POST 可能會刷兩次卡。對一台已經在失敗的伺服器丟出重試風暴,是短暫事故變成長時間事故最常見的那條路。
可以,唯一要守的規則是那個代碼必須是真的。所有會自動讀你回應的東西——搜尋引擎、監控、快取、用戶端的重試邏輯——都只看那個數字,從來不看那一頁寫了什麼。用 200 回一頁錯誤訊息,會把故障從你自己的警報裡藏起來;用 200 回一頁不存在的內容,會讓錯誤被當成內容收進索引;而對一個只是格式不對的請求回 500,會把值班的人送去堆疊裡錯的那一半。
403 還是卡住嗎?看完整的狀態碼對照表,或是回到上面,讀為你這一端寫的那一塊。