Pre-signed URL 深入筆記 · Level 5:安全性質與陷阱

jkopay-member · 學習文章 · 2026-07-07 16:52

這是 pre-signed URL 學習路線圖的 Level 5。前面幾站你已經知道:pre-signed URL 就是「把一個 HTTP 請求先簽好、塞進一條網址」,任何人拿到它就能在期限內下載或上傳一個私有物件,全程不帶帳密。功能面你已經會用了。這一站要補的是「安全直覺」——那些會讓你半夜被叫起來的坑。

本篇可獨立閱讀。每個陷阱都用同一個節奏講:症狀(你會看到什麼)→ 為什麼會這樣 → 怎麼避。讀完你會對「這條 URL 到底有多危險、能被鎖多緊」有踏實的判斷。

本站終點:給你一句需求(「讓外部廠商上傳報表,限 image/pdf、限 15 分鐘、只准公司網段」),你能當場說出:期限怎麼設、要不要綁 Content-Type、為什麼純 PUT 擋不住檔案大小、要不要上 signatureAge / SourceIp 條件。文末有這題的完整解答。

先備名詞:30 秒暖身

核心名詞先在這裡講定,其餘專有名詞(如 SignedHeadersUNSIGNED-PAYLOAD)出現時會就地解釋。以下四個先用白話講白,讀下去就不卡:

陷阱 1:期限一定要短

結論先講:能設多短就設多短,短到「剛好夠用」為止。pre-signed URL 是 bearer token,它活著的每一秒都是一個「誰撿到都能用」的窗口,期限越長、暴露面越大。

你能設的範圍(依產生方式不同):

產生方式期限上限
用 AWS 主控台(console)點出來的1 分鐘 ~ 12 小時
用 AWS CLI / SDK 程式產(且用 IAM user 長期憑證)最長 7 天(604800 秒)
用 CLI / SDK 程式產,但簽章用的是臨時憑證(STS / role / EC2)比 7 天更短——被憑證壽命壓著(見陷阱 2)

那到底該設多短?AWS 的官方最佳實務指出:pre-signed URL 可以比 STS 臨時憑證還短——在條件對的情況下,60 秒都是可行的(STS 臨時憑證最短是 15 分鐘,presigned 沒有這個下限)。原則很簡單:這條 URL 預計多久內會被用掉,就設多久,別「反正設長一點比較保險」——那是反過來的,設長才不保險。

怎麼避:用「業務上合理的最短時間」當期限。下載連結給人點:幾分鐘到一小時。後端拿到就馬上用的:幾十秒到幾分鐘。不要無腦設 7 天。

陷阱 2:它常常「提早」過期

症狀:你明明設了 Expires = 7 天,結果 URL 過一兩個小時就開始回 403 / ExpiredToken,怎麼算都不對。

為什麼:如果你是用臨時憑證(STS / IAM role / EC2 instance profile)簽出這條 URL,那 URL 的真正壽命是「你設的 expires」和「那組臨時憑證本身的壽命兩者取較短的那個。AWS 白紙黑字:presigned URL「不可能比簽它的憑證活得更久」,就算你設更長的到期時間也一樣。

而臨時憑證的預設壽命往往很短:

簽 URL 用的憑證來源它自己多久到期
STS AssumeRolerole session 結束就失效,預設 1 小時
EC2 instance profile(機器 metadata 拿到的)會定期輪替,最長約 6 小時
ECS task / 容器角色通常 1~6 小時輪替一次
IAM user 長期 access key不會自己過期(所以才能撐到 7 天)

所以在 EC2 上跑的服務,拿 instance profile 簽一條「7 天」的 URL,實際上約 6 小時後就死了——因為那把臨時憑證輪替掉了。這是最常見的「為什麼我設 7 天卻提早死」。

怎麼避:(1) 心裡有數——臨時憑證簽出來的 URL,真正期限被憑證壓著。(2) 真的需要長效 URL,才用 IAM user 長期憑證來簽;但要清楚,長期 access key 本身就是一筆資安負債——它不會自動過期、一旦外洩得靠人工撤銷,AWS 的安全建議也是能不用就不用、優先改用臨時憑證。也就是說「用長期憑證換長效」是拿一個風險換另一個,不是免費的。(3) 需要 URL 一產出就馬上用掉,那臨時憑證完全 OK,因為你根本用不到那麼久。

陷阱 3:URL 就是鑰匙,別寫進 log

結論:一條 pre-signed URL 外洩,就等於存取權外洩。它是 bearer token,撿到的人在期限內就能照著做那件事——下載你的私有檔、或往你 bucket 塞東西。

而它比一般憑證更容易外洩,原因在「簽章放在 query string」。AWS 官方點名這個風險:簽章是 X-Amz-Signature 這個 query 參數,而 query string 是網址的一部分——網址天生到處被記錄:伺服器 access log、瀏覽器歷史、頁面跳轉帶的 Referer header、公司 proxy 的流量記錄……都會把整條網址(含簽章)抄下來。相比之下,把秘密放在 HTTP header 反而比較少被記進 log。

官方原話重點:「Query string parameters ... 風險是 client 可能把 URI 連同簽章一起記進 log」。AWS 建議:記 log 時把 X-Amz-Signature 遮掉(redact),或整段 query string 遮掉,或把它當高機密處理。S3 自己的 server access log 與 CloudTrail 就是這樣做的——會保留 URI 但把 X-Amz-Signature 塗掉。

怎麼避:

陷阱 4:簽了就不能改一個字

症狀:你(或前端)把 URL 拿到手後,順手改了點東西——換個檔名、多加一個 query 參數、改個 header——馬上收到 SignatureDoesNotMatch

為什麼:這不是 bug,是特性。server 收到請求時,會拿請求裡的 method、路徑、被簽進去的 header、query 參數重新算一次簽章,再跟 URL 上的 X-Amz-Signature 比對。只要有任何一個被簽進去的東西差了一個字元,重算出來的簽章就對不上 → 直接拒絕。

這正是 pre-signed URL 的安全保證:它鎖死了「只能做當初簽的那一件事」——不能把 GET 改成 DELETE、不能把 photo.jpg 改成 secret.pdf、不能偷加參數。想改任何一項,只能重新簽一條。

怎麼避:URL 產出來就原封不動地用。前端別「幫忙」加參數或改 header。AWS 排查 SignatureDoesNotMatch 的官方建議也包含這句:確認 method、headers、query string 在「產 URL」和「用 URL」兩端完全一致。(另一個隱形殺手是 proxy:有些公司 proxy 會偷改 header 或 query,導致簽章對不上——排查時先試著繞過 proxy。)

陷阱 5:兩邊時鐘要對得上

症狀:程式看起來都對,卻一直 SignatureDoesNotMatch,或 URL「一產出來就過期」。

為什麼:簽章跟時間綁得很死。URL 裡的 X-Amz-Date 是「簽章當下的時間」,到期判斷就從這裡起算。如果產 URL 的機器系統時間跟 S3 / MinIO 伺服器差太多(clock skew),server 會覺得這條 URL 的時間戳很可疑——太舊就當它過期、對不上驗證窗口就直接 SignatureDoesNotMatch。AWS 明說:就算只是很小的時間漂移,都可能讓簽章失效。

怎麼避:讓產 URL 的機器開 NTP 自動對時(跟時間伺服器同步)。這是 AWS 排查 SignatureDoesNotMatch 的第一條建議。容器 / 雲主機通常預設就有,自架環境要特別確認。

陷阱 6:綁 Content-Type 是雙面刃

一句話:簽 URL 時如果把 Content-Type 也列進「要被簽章保護的 header 清單」(這份清單就叫 SignedHeaders),那上傳方送檔案時就必須帶一模一樣的 Content-Type,差一個字都會失敗。

好處(拿來當限制):你想「這條上傳 URL 只准用來傳 image/jpeg」,就把 Content-Type: image/jpeg 簽進去。對方要用這條 URL,header 就得帶 image/jpeg,否則 SignatureDoesNotMatch

壞處(最常見的踩雷):前端上傳時沒帶對 header——你簽的是 image/jpeg,瀏覽器或前端框架自作主張送了 application/octet-stream 或別的,於是上傳一直失敗,還很難查,因為「程式看起來都對」。

# 若簽 URL 時綁了 Content-Type: image/jpeg,
# curl 上傳時就必須帶「一模一樣」的值,否則 SignatureDoesNotMatch
curl -X PUT --upload-file ./photo.jpg \
     -H "Content-Type: image/jpeg" \
     "<presigned-put-url>"
怎麼避:要不要綁 Content-Type,取決於你要不要用它當限制。要綁,就務必讓前端送出「與簽章時完全相同」的值,並把這件事寫進前後端的約定。不需要限制類型時,乾脆別把 Content-Type 簽進去,少一個對不上的來源。

註:Content-Type 有沒有被簽進 SignedHeaders,跟你用的 SDK 與呼叫方式有關(例如 AWS SDK for .NET 在 GetPreSignedUrlRequest 設了 ContentType 就會簽它)。以你們專案實際用的 SDK 行為為準。

陷阱 7:純 PUT 擋不住「傳太大 / 傳錯東西」

結論:一條 presigned PUT URL 只綁三件事——HTTP method、物件 key、以及(可選的)被簽進去的 header。它沒有任何欄位可以限制「檔案多大」或「內容是什麼」。

為什麼:PUT 的簽章裡根本沒有「檔案大小」這個東西。簽 URL 時 S3 不把檔案內容算進簽章——那個本來要放「內容雜湊」的位置,pre-signed URL 一律填一個固定字串 UNSIGNED-PAYLOAD(意思就是「內容不簽」),所以你換內容、換大小,簽章照樣成立。這代表:拿到一條 presigned PUT URL 的人,可以往那個 key 塞任意大小、任意內容的檔案——你只綁了「傳去哪個 key」,沒綁「能傳什麼」。就算你靠陷阱 6 綁了 Content-Type,那也只擋得住「header 送錯」,擋不住「header 寫 image/jpeg 但內容其實是 10GB 的別的東西」。

怎麼避:真的需要限制「檔案大小上限 / key 前綴 / content-type 白名單」,不要用 presigned PUT,改用 presigned POST(policy-based upload)——它是把一份「policy」簽進去,可以強制大小範圍、前綴、類型等條件。(POST policy 的細節屬於 Level 7,這裡只要記得「純 PUT 限不了,要限就換 POST policy 或用 bucket policy 條件」。)

陷阱 8:用 IAM 從伺服器端上鎖

前面幾條都是「產 URL 這端」要注意的。但你還有第二道防線:在 policy 上加條件,讓 S3 在「收到請求」時再擋一次。這不受前端怎麼用 URL 影響,是伺服器端的硬規則。兩個最實用的條件:

a) s3:signatureAge:拒絕「太老的簽章」

就算 URL 的 X-Amz-Expires 還沒到,你也可以要求「簽章產出來超過 N 毫秒就一律拒絕」,把外洩窗口再壓小。AWS 官方範例:簽章超過 10 分鐘(600000 毫秒)就 deny。

{
  "Effect": "Deny",
  "Principal": { "AWS": "*" },
  "Action": "s3:*",
  "Resource": "arn:aws:s3:::my-bucket/*",
  "Condition": {
    "NumericGreaterThan": { "s3:signatureAge": "600000" }  // 毫秒;>10 分鐘就拒
  }
}

別把門檻設太低(有上界,下界也要顧):簽章從產出到真正被用,中間要扣掉網路延遲加上少許時鐘誤差。門檻壓到只剩幾秒(例如照樣造句寫 5000=5 秒)會把正常請求也一併誤殺,反而製造難查的間歇性失敗。實務上留在數十秒以上(約 60 秒起跳)較安全——這是操作經驗的下界,不是官方硬性規定的數字。

b) aws:SourceIp / VPC 條件:限制來源

想讓 URL「只准從公司網段用」,就用來源條件擋掉其他來源——這樣就算 URL 外洩到外網,別人也用不了。這條可以加在「發出請求的 IAM principal」上,也可以加在「bucket / access point」上——不是只能加 bucket(兩邊都設也行)。

AWS 官方的來源限制範例(掛在 principal 或 bucket 都成立):

{
  "Sid": "NetworkRestrictionForIAMPrincipal",
  "Effect": "Deny",
  "Action": "*",
  "Resource": "*",
  "Condition": {
    "NotIpAddressIfExists": { "aws:SourceIp": "<公司 IP 網段>" },
    "BoolIfExists": { "aws:ViaAWSService": "false" }
  }
}

注意:這種來源限制不論加在 IAM principal 還是 bucket / access point,都會套用到所有對該資源的存取,不只 pre-signed URL 的情境——設之前要確認不會誤傷正常流量。

陷阱 9:你們的 store 是 MinIO——同一套機制,護欄卻少一截 ★ 對你們最切身

常有人問:「presigned URL 是不是 MinIO 特別容易被攻擊,S3 就還好?」先講結論:presigned 的簽章機制(SigV4)S3 跟 MinIO 是同一套,演算法本身沒有強弱之分(這點 Level 6 會講透可遷移性)。MinIO 的風險比較高,不是因為它「比較好破」,而是它通常被怎麼營運——爆炸半徑與偵測能力差一截。三個具體差別:

怎麼避(MinIO 環境)

誠實標註:MinIO 確實也提供 bucket policy 條件與 STS 短憑證,上述「護欄較弱」指的是常見部署的預設與實務,不是 MinIO 完全做不到;你們能配置到多嚴,以實際採用的 MinIO 版本與設定為準。

速查:症狀 → 病因 → 解法

你看到的症狀最可能的病因怎麼解
URL 提早過期(設 7 天卻幾小時就死)用臨時憑證(STS / role / EC2)簽的,被憑證壽命壓著要長效改用 IAM user 長期憑證(但那是資安負債);或接受短效即可
SignatureDoesNotMatchURL 被改過 / header 沒帶對 / 時鐘偏移 / proxy 竄改URL 原封不動用、header 對齊、機器 NTP 對時、試繞過 proxy
ExpiredToken簽 URL 的臨時憑證已失效刷新憑證後重新產 URL
上傳一直失敗但「看起來都對」簽了 Content-Type,前端送出的值對不上前端送出與簽章時完全相同的 Content-Type
廠商用一條 PUT URL 傳了超大 / 錯誤內容純 PUT 綁不了大小與內容改用 presigned POST policy 或 bucket policy 條件
URL 外洩被盜用簽章在 query string,被 log / 歷史 / proxy 記下期限設短、log 遮掉簽章、上 signatureAge / SourceIp
用 MinIO:URL 外洩後果比 S3 大、也較難察覺常用靜態 root 金鑰簽、缺 STS 自動過期與 IAM/CloudTrail 護欄、又要自己修 CVE改用低權限專用 service account 簽、能用 MinIO STS 就用短效、勤升版

過關演練:外部廠商上傳報表

需求:讓外部廠商上傳一份報表,限 image/pdf、限 15 分鐘、只准公司網段。逐項回答。
  1. 期限怎麼設?15 分鐘(900 秒)。這遠低於 7 天上限,沒問題。但要注意:如果你的服務跑在 EC2 / ECS 用臨時憑證簽,要確認那把憑證此刻至少還有 15 分鐘壽命,否則 URL 會被憑證提早壓死(陷阱 2)。
  2. 要不要綁 Content-Type?要——但要看清它的極限。綁 Content-Type 只能保證「對方送出的 header 值對得上」,能擋「header 亂帶」,擋不住「header 寫 application/pdf、內容其實是別的」。而且一條 PUT URL 只能綁單一個 Content-Type 值,要同時允許 image 和 pdf,得各簽一條 URL,或直接改用 POST policy 的類型白名單。
  3. 為什麼不能靠純 PUT 限大小?因為 PUT 的簽章裡根本沒有「大小」這個欄位(內容不簽、填 UNSIGNED-PAYLOAD),廠商可以往那個 key 塞任意大小的檔案(陷阱 7)。要真正限制「報表不能超過 X MB」和「只能 image/pdf」,正解是 presigned POST policy(可設 content-length-range 與類型白名單),不是 PUT。
  4. 要不要上 signatureAge / SourceIp 條件?SourceIp 要——「只准公司網段」正是它的用途,用 aws:SourceIp 擋掉公司網段以外的來源(走 VPC endpoint 就用 aws:SourceVpc(e))。這樣就算 URL 外洩到外網也用不了。signatureAge 可選:15 分鐘期限本身已經夠短,但若想再收緊「簽出來超過 N 分鐘就拒」,加一條 signatureAge 是免費的額外保險(記得門檻別壓到只剩幾秒)。

參考來源(實際爬過的權威連結)