如何建立安全的 Secrets API?SSRF 防護實作與測試環境設定

Published on: | Last updated:

為什麼要蓋一個 API,然後再親手把它駭掉?

最近我花了不少時間在研究 API 安全,但老實說,光看別人的文章、理論,總覺得有點隔靴搔癢。你知道的,那種感覺就像看食譜學做菜,沒自己下廚永遠不知道火侯多難控制。所以我決定來真的——從零開始,親手打造一個 API,把它武裝起來,然後,再想辦法從內部把它搞垮。

這個專案就是我的廚房。一個有著完整 JWT 驗證、加密儲存的秘密管理服務,但又故意留了一個 SSRF 的後門。整個過程,從資料庫設定、身份驗證流程,到加密、最後的滲透測試,全都在我自己的 Kali Linux 環境裡跑一遍。我的目的不是要做出一個完美的產品,而是想搞懂,那些安全漏洞一開始到底是怎麼被「設計」出來的。然後,再把它修好。

先說結論:到底搞了個啥?

簡單講,這趟旅程讓我學到最重要的事是:安全不是一個功能,而是一種思維習慣。你必須在寫每一行 code 的時候,都像個駭客一樣思考:「這段 code 怎麼被玩壞?」

整個專案的核心,就是圍繞著「身份驗證 (Auth)」、「加密儲存 (Vault)」,和最後的「伺服器端請求偽造 (SSRF)」這三個主題。接下來我就來拆解一下整個過程的筆記。

JWT 驗證流程示意圖:使用者如何用令牌通行
JWT 驗證流程示意圖:使用者如何用令牌通行

第一站:打地基,把環境架起來

萬事起頭難,一開始就是先把開發環境搞定。這部分雖然有點枯燥,但地基沒打好,後面絕對會蓋出歪樓。

我用的技術棧很單純:

  • 後端框架:Node.js + Express
  • 安全性:Helmet (加減擋一些基本攻擊)、CORS (為了以後可能接前端)
  • 資料庫:PostgreSQL 配上 Sequelize 這個 ORM
  • 語言:TypeScript。老實說,一開始用 TS 有點囉嗦,但後面你會感謝它的型別檢查,少踩很多坑。

比較重要的幾個點:

1. 環境變數 (.env):
這是天條。所有敏感資訊,像是資料庫連線字串、JWT 的密鑰、加密用的 Key,全部都要寫在 .env 檔案裡,然後用 dotenv 這個套件讀進來。千萬、千萬不要把這些東西寫死在程式碼裡,不然 git commit 一推出去就全世界都看到了。

# .env file example
PORT=4000
DATABASE_URL=postgresql://user:password@localhost:5432/secretsdb
JWT_ACCESS_SECRET=a_very_very_long_random_string_for_access
JWT_REFRESH_SECRET=another_super_long_random_string_for_refresh
ENCRYPTION_KEY=a_32_byte_string_for_aes_encryption!!
SSRF_DEMO_MODE=true

2. TypeScript 的小坑:
當我用 Middleware 驗證完 JWT 之後,我很自然地想把使用者資訊(比如 user ID)掛在 Express 的 req 物件上,像這樣:req.user = decoded.sub。結果 TypeScript 馬上大叫,說 Request 型別上沒有 user 這個屬性。對,它不認識。這時候就得自己擴充型別宣告,告訴 TS:「老兄,相信我,我加了這個東西。」

開發環境的一角:當伺服器成功啟動時的療癒瞬間
開發環境的一角:當伺服器成功啟動時的療癒瞬間

3. 資料庫權限問題:
這個也蠻經典的。資料庫跟使用者帳號都建好了,結果 Sequelize 就是沒辦法同步 model、建立 table。搞了半天,原來是忘了給新建立的 user 對 database 和 schema 的操作權限。下了幾行 GRANT 指令才搞定。這種小事超浪費時間,但每個後端工程師大概都遇過。

第二站:蓋門禁,打造安全的 JWT 驗證

環境好了,接著就是蓋一個穩固的門禁系統。沒有驗證,API 就等於是裸奔。

我選擇用 JWT (JSON Web Tokens) 來做身份驗證。簡單說,JWT 就像一張有時效性的通行證。使用者用帳密登入後,伺服器就發給他一張簽了名的通行證 (Access Token) 和一張可以換新證的卡 (Refresh Token)。之後使用者要存取需要權限的資源時,只要出示這張通行證,伺服器檢查簽名沒問題就放行,不用每次都去查資料庫。

這裡面有幾個關鍵實作點:

  • 密碼絕對不能存明文: 我用 bcrypt 把使用者的密碼加鹽、雜湊後才存進資料庫。驗證時也是比對雜湊值,絕對不會有機會看到原始密碼。
  • Access Token 要短命: 我的 Access Token 可能只有 15 分鐘壽命。就算被偷了,損失也有限。
  • Refresh Token 要長命且安全: 用來換取新的 Access Token,壽命可能是一天或一週。它的管理要更小心。
  • Middleware 是保全: 我寫了一個 requireAuth 的 middleware,把它安插在所有需要保護的路由前面。它的工作很簡單:檢查請求 Header 有沒有帶合法的 JWT,沒有或過期就直接擋掉,回傳 401 Unauthorized。

為了確認這套系統真的有用,我沒急著寫前端,而是直接用 curl 這個最原始的工具,一行一行指令去跟 API「對話」。

像是先註冊一個用戶:

curl -X POST http://localhost:4000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com", "password":"SafePassword123"}'

然後用這個帳號登入,拿回 token:

curl -X POST http://localhost:4000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com", "password":"SafePassword123"}'

最後,試著不帶 token 去存取一個受保護的資源,預期要看到被拒絕的訊息。這樣一步步測試,才能確保門禁系統真的有在工作。

第三站:蓋金庫,確保資料靜態加密

有了門禁,接下來就是最重要的部分:蓋一個可以安全存放秘密的「金庫」(Vault)。

這個 Vault API 提供了基本的 CRUD 功能(新增、讀取、更新、刪除)。但重點不是功能,而是背後的安全機制。

1. 靜態加密 (Encryption at Rest):
使用者存進來的任何秘密內容,在寫入資料庫之前,我都會用 AES-256-GCM 演算法把它加密成一串亂碼。這代表什麼?就算今天有人把我的整個資料庫偷走,他看到的也只是一堆看不懂的加密文字,拿不到真正的秘密。資料只有在授權使用者來讀取時,才會在記憶體中即時解密後回傳。

2. 資料隔離 (Row-Level Security):
這點超級重要。使用者 A 只能存取他自己的資料,絕對不能看到使用者 B 的。聽起來像廢話,但很多新手開發者會犯錯。他們可能會這樣寫查詢:SELECT * FROM secrets WHERE id = :secretId。問題是,如果使用者 A 知道了使用者 B 的某個 secret ID,他就能直接存取了!

正確的做法是,查詢時一定要同時綁定使用者 ID。像這樣:SELECT * FROM secrets WHERE id = :secretId AND userId = :currentUserId。這個 currentUserId 是從我們前面 JWT middleware 解析出來的,絕對可信。這樣就確保了你永遠只能在「你自己的抽屜」裡找東西。

當我嘗試用 user1 的 token 去讀取 user2 的資料時,伺服器很乾脆地回了 404 Not Found。這就對了!不要回 403 Forbidden,因為那樣等於告訴攻擊者「嘿,這個東西存在,只是不讓你碰」,洩漏了不必要的資訊。

常見的 API 安全迷思與正確做法

說到這裡,我覺得可以整理一下,很多時候我們覺得「有做」就好,但「怎麼做」才是關鍵。下面這個表是我自己整理的一些想法。

安全面向 常見但危險的做法 比較建議的做法
密碼儲存 直接存明文(最糟!),或是用 MD5/SHA1 這種老掉牙的 Hash。 bcryptArgon2。它們不只慢,還能加鹽 (salt),大幅提高破解難度。
身份驗證 用一個不會過期、內容簡單的 Token。或是把使用者權限直接寫在 Token 裡。 短期的 Access Token + 長期的 Refresh Token。Token 裡只放最小必要資訊,像是 User ID。
處理使用者輸入 直接把使用者傳來的 URL、ID、檔案路徑拿去用。心臟真的很大顆。 永不信任使用者輸入! 做嚴格的驗證、清理 (Sanitize)。如果是 URL,就用白名單限制能連線的網域。
錯誤訊息 回傳詳細的錯誤堆疊 (stack trace) 或「帳號不存在」、「密碼錯誤」這種太明確的訊息。 回傳通用的訊息,例如「登入資訊有誤」或「資源不存在 (404)」。不要洩漏內部資訊。

最終站:親手駭掉自己蓋的房子 (SSRF 實驗)

好了,前面把房子蓋得這麼牢固,現在來做點好玩的事:拆了它。

我要模擬的是一種很常見但超級危險的漏洞,叫做「伺服器端請求偽造」(Server-Side Request Forgery, SSRF)。

SSRF 是什麼?簡單講,就是你誘騙一個網站的伺服器,讓它代替你,去訪問一些它本來不應該去訪問的內部網路資源。例如,雲端主機的後設資料服務 (metadata service)、內部的管理後台、或是沒對外開放的資料庫。

為了模擬這個,我故意開了一個有問題的 API 路由:/ssrf/fetch。它接受一個 `url` 參數,然後… 就直接用 `axios` 去請求那個 URL,再把結果回傳給你。

// 這段 code 是故意寫錯的!
const target = req.query.url as string;
const response = await axios.get(target); // 致命傷!沒有驗證 target
res.json({ data: response.data });

同時,我也在 API 裡藏了一個「內部後門」:/internal/meta。這個路由會回傳一些假的、但看起來很敏感的伺服器資訊,像是 AWS 的 instance ID 或是一些秘密 token。這個路由正常情況下是絕對不會對外開放的。

現在,好戲上場了。我沒辦法直接訪問 /internal/meta,但我可以叫 /ssrf/fetch 幫我去訪問啊!

於是我在終端機輸入:

curl "http://localhost:4000/ssrf/fetch?url=http://localhost:4000/internal/meta"

結果… 砰!伺服器乖乖地去訪問了自己的內部路由,然後把那些敏感資訊,透過 /ssrf/fetch 這個公開的管道,原封不動地洩漏給了我。

SSRF 攻擊概念:一把從內部打開的鎖
SSRF 攻擊概念:一把從內部打開的鎖

這在真實世界中超級危險。駭客可以利用 SSRF 去讀取雲端服務(像 AWS)的臨時憑證,然後就能接管你的雲端帳號。這也是為什麼在 OWASP Top 10 裡,SSRF 一直榜上有名。

說到這個,這類型的安全標準在不同國家或行業有不同要求。像在台灣,如果你做的系統跟政府或關鍵基礎設施有關,光是這樣還不夠,還得符合《資通安全管理法》一堆規定,稽核會更嚴格。不過我們這是自己練習,先搞懂原理比較重要。

所以,我到底學到了什麼?

搞了這麼一大圈,最大的收穫不是寫了多少 code,而是建立了一種「防禦性編程」的肌肉記憶。

  • JWT 驗證的細節很重要:不只是簽發 token,還要處理好過期、刷新、撤銷的機制。
  • 加密是最後防線:資料永遠要在存入前加密,而不是事後補救。
  • 權限檢查要做到滴水不漏:永遠不要相信前端傳來的 ID,一切以伺服器端驗證過的身份為準。
  • 任何讓伺服器發起網路請求的功能都要加倍小心:只要 URL 是使用者可控的,就等於是開了一個潛在的後門,必須用白名單嚴格過濾。

自己蓋一個靶,再自己打,這個過程真的比看十篇理論文章還有用。你會更深刻地理解,一個小小的疏忽,是如何演變成一個巨大的安全漏洞的。


那你呢?在開發或維護 API 時,有沒有遇過什麼讓你頭痛的安全問題?或是有什麼覺得很酷的攻擊手法?在下面留言聊聊吧!

Related to this topic:

Comments

  1. profile
    Guest 2025-10-24 Reply
    唉,API安全這種東西,其實我以前也不太懂啦。都是因為小孩現在超迷資安,我這當爸媽的只好跟著摸一點門道。說真的,那個什麼JWT啊,搞了半天我才大概有點感覺 - 每次聽到accessToken、refreshToken,我腦海畫面就是家裡客廳那堆鑰匙。有幾把能開普通門,有一把像保險箱專用,不是只靠密碼就行了,反正就是分工合作的概念吧。 還記得去年,他整天拿著電腦自己玩什麼bcrypt加密測試。他說那堆亂碼就像你要搞一堆奇怪鎖頭,每天都得防著有人來試所有可能的方法進來。我問他:所以如果有天真的被破解怎辦?他還蠻輕鬆的講:「所以系統設計本來就會留後路啦,給自己多點時間補救。」其實聽起來挺現實的,只能一步步學著放寬心吧。