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

一場原本只是「順便看看」的對話,最後變成把整個後端拆開重寫的故事。
為什麼會做這件事
故事是這樣的。我有一個用 Vue 3 + FastAPI + PostgreSQL 寫的個人 portfolio 網站,前台部署在 GitHub Pages,後端跑在 Render 免費方案。寫完上線之後我就把它丟著,偶爾回來更新一下文章和專案。
某天我心血來潮問了一個問題:「我這個網站,有哪些資安設定還沒處理?」
結果答案讓我有點臉綠。
第一個發現:我的 CMS 後台 API 完全沒鎖
最致命的一條:
我以為自己有做 JWT 驗證,因為 README 上是這樣寫的、登入路由也確實會發 token。但實際上後端的所有 CRUD 路由——POST /api/projects、PUT /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}"}
問題:
- 沒有 auth(接續上面那條)
- 副檔名直接從 filename 拆字串——可以傳
.html、.svg(含 XSS)、.php、甚至沒有副檔名的檔案 - 沒檢查 MIME type
- 沒有大小限制——可以被當作免費檔案空間 / 塞爆磁碟
任何人都能上傳一個 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 條)
- 所有 CRUD 沒有 JWT 保護
- /api/upload 完全沒驗證
- SECRET_KEY 有 fallback
- DATABASE_URL 預設值含真實密碼
🟠 High(4 條)
- CORS
allow_methods=["*"]+allow_headers=["*"]+ credentials request.client.host在 Render 拿到的是 proxy IP,登入鎖定形同虛設- JWT 用 deprecated 的
datetime.utcnow() - 沒有 rate limiting
🟡 Medium(8 條)
GET /api/posts//api/projects沒過濾is_published,草稿外洩- 缺少統一例外處理,stack trace 會回前端
- 缺安全標頭(HSTS、X-Frame-Options 等)
- 上傳目錄直接
StaticFiles服務,配合上面的 upload 漏洞=現成 XSS - JWT 30 分鐘 + 沒有撤銷機制
api_logsmiddleware 寫入,沒清理機制,會無限長大- 前端後端 URL 寫死在 source
- 依賴沒做漏洞掃描
修補過程:派 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) 嗎?
這個問題比看起來的還重要。
