哈囉,我是 001

Hello, I amDJQCQ

回列表

我用 AI Agent 平行修補了個人網站的 16 個資安問題,這是我學到的事

一場原本只是「順便看看」的對話,最後變成把整個後端拆開重寫的故事。

為什麼會做這件事

故事是這樣的。我有一個用 Vue 3 + FastAPI + PostgreSQL 寫的個人 portfolio 網站,前台部署在 GitHub Pages,後端跑在 Render 免費方案。寫完上線之後我就把它丟著,偶爾回來更新一下文章和專案。

某天我心血來潮問了一個問題:「我這個網站,有哪些資安設定還沒處理?」

結果答案讓我有點臉綠。


第一個發現:我的 CMS 後台 API 完全沒鎖

最致命的一條:

我以為自己有做 JWT 驗證,因為 README 上是這樣寫的、登入路由也確實會發 token。但實際上後端的所有 CRUD 路由——POST /api/projectsPUT /api/posts/{id}DELETE /api/snippets/{slug}、還有 /api/upload——全部沒有任何驗證

換句話說:

任何人只要知道我的 API URL(README 早就公開了),都可以:

  • 新增 / 修改 / 刪除我的所有專案
  • 改我的部落格文章
  • 上傳任意檔案到我的 server

我的「JWT 驗證」實際上只存在於登入這一個路由上。發完 token 就沒人管它了。Token 從來不需要被檢查。

這是一個完全技術正確但邏輯災難級的錯誤——程式跑得起來、看起來有在做事、登入也會成功、token 也會發出來——只是後面所有的路由根本不在乎你有沒有 token。


第二個發現:上傳路由有 4 個問題疊在一起

/api/upload 是個經典反面教材:


      
@app.post("/api/upload")
async def upload_image(file: UploadFile = File(...)):
    file_ext = file.filename.split(".")[-1]
    file_name = f"{uuid.uuid4()}.{file_ext}"
    file_path = f"{UPLOAD_DIR}/{file_name}"
    with open(file_path, "wb") as buffer:
        shutil.copyfileobj(file.file, buffer)
    return {"url": f"/static/uploads/{file_name}"}

問題:

  1. 沒有 auth(接續上面那條)
  2. 副檔名直接從 filename 拆字串——可以傳 .html.svg(含 XSS)、.php、甚至沒有副檔名的檔案
  3. 沒檢查 MIME type
  4. 沒有大小限制——可以被當作免費檔案空間 / 塞爆磁碟

任何人都能上傳一個 evil.html 到我自己 domain 下,然後傳給受害者點,從受害者瀏覽器看起來這個 XSS 載荷是由「mike 的網站」發出的。


第三個發現:fallback 預設值是個未爆彈


      
SECRET_KEY = os.getenv("SECRET_KEY", "default_secret_key_change_me")

這行很常見、很方便、也是個炸彈。

如果今天我換伺服器、忘了設環境變數,JWT 就會用這個眾所皆知的字串簽。任何讀過我 source code 的人都能偽造 token。

更糟的:


      
SQLALCHEMY_DATABASE_URL = os.getenv(
    "DATABASE_URL",
    "postgresql://postgres:Day25143@localhost:5432/portfolio_db"
)

我甚至把本機 DB 密碼直接 commit 進 git。雖然 .env 沒上傳,但這串 fallback 字面值已經在 main.py 裡留下。應該當作「外洩過」處理。


完整 16 條清單

把所有問題列出來,按嚴重性分類:

🔴 Critical(4 條)

  1. 所有 CRUD 沒有 JWT 保護
  2. /api/upload 完全沒驗證
  3. SECRET_KEY 有 fallback
  4. DATABASE_URL 預設值含真實密碼

🟠 High(4 條)

  1. CORS allow_methods=["*"] + allow_headers=["*"] + credentials
  2. request.client.host 在 Render 拿到的是 proxy IP,登入鎖定形同虛設
  3. JWT 用 deprecated 的 datetime.utcnow()
  4. 沒有 rate limiting

🟡 Medium(8 條)

  1. GET /api/posts / /api/projects 沒過濾 is_published,草稿外洩
  2. 缺少統一例外處理,stack trace 會回前端
  3. 缺安全標頭(HSTS、X-Frame-Options 等)
  4. 上傳目錄直接 StaticFiles 服務,配合上面的 upload 漏洞=現成 XSS
  5. JWT 30 分鐘 + 沒有撤銷機制
  6. api_logs middleware 寫入,沒清理機制,會無限長大
  7. 前端後端 URL 寫死在 source
  8. 依賴沒做漏洞掃描

修補過程:派 AI agent 平行做事

這次我做了一個實驗:讓 Claude Code 派 background agent 平行修這些問題,我只負責決策跟驗證。

策略很簡單:

  • 同檔案的改動 → 一個 agent 串行做
  • 不同檔案的改動 → 多個 agent 平行做

例如 Critical 4 條全部改 main.py,所以一個 agent 一次處理完。但 High 階段我把不同區段的工作切給兩個 agent:

  • Agent A 改 CORS / login / log middleware
  • Agent B 改 GET routes / 加新 middleware

我在 prompt 裡明確告訴他們「你只能改這些區段」「另一個 agent 在改別的區段」「插入新 middleware 請放在檔尾,不要插在 CORS 旁邊」。

結果幾乎沒衝突,10 個檔案、4 輪 agent、總共大約 20 次 tool call 就完成。

更有意思的是 agent 們會主動回報自己「順手發現但沒改的問題」——例如 Agent C 修完 rate limit 後告訴我 requirements.txt 是 UTF-16 編碼(pip 會吃但不正常)、@app.on_event("startup") 在新版 FastAPI 已 deprecated。這些不是我交代的任務,但 agent 在讀檔的時候注意到了。


修補中踩到的雷

不是每件事都順利。記錄幾個比較有趣的:

雷 1:refresh_token rotation 後忘了改前端

後端加了 JWT refresh token + 撤銷機制後,我以為改完了。結果一打開後台想新增文章——401 Unauthorized

問題是前端 axios 之前根本沒有 interceptor 自動帶 token。後端從不要驗證變成嚴格要驗證後,前端的「裸奔」request 全部炸掉。修法是在 api.ts 加 request interceptor 自動讀 localStorage 加 Authorization header,再加 response interceptor 處理 401 自動 refresh。

教訓:改後端 auth 之前先想前端怎麼帶 token,這是同一件事的兩面。

雷 2:Render 免費 PostgreSQL 找不到

要跑 ALTER TABLE projects ADD COLUMN is_published BOOLEAN DEFAULT TRUE 結果在 Render dashboard 怎麼找都找不到 PostgreSQL service,只看到兩個 web service。

最後發現它被藏在 “My project” 卡片裡面(不是 sidebar 的 Projects 那個)。Render 的 UI 蠻容易讓人找錯地方。

雷 3:pgAdmin 中文版的翻譯 bug

連線時 SSL mode 下拉選單把 Require 翻譯成「引用」,結果 pgAdmin 把這兩個中文字當值送出去,連線失敗:


      
connection is bad: invalid sslmode value: "引用"

切換到英文介面才正常。翻譯軟體有時候會把 enum value 也翻譯掉,這類 bug 很惱人。

雷 4:GitHub Pages 不支援私人 repo(免費方案)

我把 repo 改成 private 之後,網站突然 404:「There isn’t a GitHub Pages site here.」

原來免費方案不支援 private repo 的 Pages。改回 public 才恢復。順便發現 GitHub 在 visibility 切換後會把 Pages 設定重置成 GitHub Actions,要手動改回 Deploy from a branch。

雷 5:deploy.sh 的 stale dist

deploy.sh 會在 dist/ 內 init 一個 git repo 然後 force-push 到 gh-pages 分支。但如果你重跑沒先 rm -rf dist,舊的 dist/.git 還在、新檔案內容跟舊的一模一樣的話,git 會說「nothing to commit」然後因為 set -e 整個 script 後續都不會跑——你會以為部署成功,其實什麼都沒推。

教訓:部署前永遠 rm -rf dist


其他學到的事

1. 「我的 service 不重要」這個藉口很危險

個人 portfolio 看起來沒什麼好駭的對吧?但被竄改文章 = 名譽損失。被當免費檔案空間 = Render 流量超標被收費。被當作 phishing 跳板 = domain 被 Google Safe Browsing 標記。

2. fallback 值是「方便陷阱」

任何 os.getenv("X", "default") 都應該被審視:這個 default 在 production 出現的話,會發生什麼事?如果答案是「災難」,那就應該 raise 而不是 fallback。

3. 技術正確 ≠ 邏輯正確

我有「JWT 驗證」這個元件。我有 OAuth2PasswordBearer。我有 token 發放邏輯。每個元件都是對的。但沒有任何路由真的去呼叫驗證 dependency,所以整個機制是個擺設。

下次 review 自己的 auth 系統時,問題不應該是「我有沒有 JWT?」而是「我每一條寫入路由都有 Depends(get_current_user) 嗎?」

4. 補完之後還要驗證

改完的當下我以為完了,結果光是「點後台更新文章」就找出前端沒帶 token、cover_image URL 寫死 localhost、舊資料 DB 路徑壞掉等一連串問題。

部署後的冒煙測試清單值得寫在 README:

  • 公開 GET 還能用嗎?
  • 未登入打 POST 應該回 401(不是 200)
  • 安全標頭有出來嗎?
  • Rate limit 真的有觸發嗎?
  • 後台 CRUD 全流程跑一次
  • 上傳一張圖、看看顯示和儲存路徑對不對

結語

整個過程做了大約 4 小時,從第一個問題「有哪些資安設定還沒處理」到最後一條 commit。共修了 16 個問題、加了 1 個 DB table、新增了 6 個 endpoint、改了大概 30 個檔案。

最有價值的不是修補本身,而是過程中發現的「我以為我有,但其實沒有」的東西。下次我寫新 service 的時候,第一件事就會是:

所有寫入路由有 Depends(auth) 嗎?

這個問題比看起來的還重要。