iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Claude AI

奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲系列 第 27 篇

Day 27:安全性防禦工事:API 認證、Rate Limit 與雲端資安基本盤補強

  • 分享至 

  • xImage
  •  

claude_27

系列:奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲
今日工具:claude.ai(威脅建模)/ Claude Code / Terraform(SSM Parameter Store、WAF)/ tfsec + gosec
今日進度:存檔綁 token、簽章密鑰進 SSM、限流退到邊緣、WAF 開一週、CI 加兩個掃描器

前言

Day 26 之後 API 很快,但它有一個從 Day 13 就在的洞:/api/v1/saves/{id}/* 只認 UUID。UUID 猜不到沒錯,但存檔 id 會出現在網址、localStorage、任何一張截圖裡;拿到別人的 id 就能替他做選擇。單人遊戲談安全聽起來奢侈,但 README 承諾過「經得起驗證的完整專案」。

今天先想再做:用 STRIDE 列威脅,再決定哪些今天修、哪些誠實留著。而無伺服器把「留著」那欄變長了——有一件我以為做得到的事,今天發現在 Lambda 上根本做不到。

一、先做威脅建模,再寫程式

我把 Day 13 的 REST 契約與架構圖貼給 claude.ai,請它用 STRIDE 逐項列威脅,標出「一人專案可接受」與「必須修」。收斂後的表:

威脅 情境 今天的對策 殘留風險
Spoofing 拿別人的存檔 id 操作 存檔綁 JWT,sub 必須等於路徑 id token 外洩等於存檔外洩
Tampering 前端偽造 won: true Day 12 的範圍驗證+Day 23 的合理區間 精心偽造的戰報擋不住
Repudiation 「我沒選過那個」 同一張表的稽核 item:EVENT# 與 BATTLE# —
Information disclosure 洩漏連線字串 沒有連線字串可洩漏,DynamoDB 只靠 IAM role;唯一的祕密是簽章密鑰,在 SSM SecureString —
DoS 灌 POST /saves 表塞不爆,會被塞爆的是帳單:API GW 20 rps/burst 40、reserved_concurrent_executions = 10、Budgets 兩封信 每 IP 限流做不到
Elevation 執行環境被拿下 一個執行角色、三條 inline policy:那個 log group、那張表與 /index/GSI1、那個 SSM 參數 —

Claude 最有價值的貢獻是「Tampering 那格不要騙自己」:戰鬥在前端跑,後端只能檢查合理性,真正的反作弊要把戰鬥搬到伺服器重算,那是另一個專案的規模。這句話原封不動進了 ADR-0006。

今天要疊的關卡都在同一條路徑上:

claude_27_diagram_01

二、存檔 token 與密鑰的一生

不做帳號系統,單人遊戲不需要 email 與密碼。POST /saves 建檔時簽一個只綁這份存檔的 HS256 token(claims 只有 iss/sub/iat/exp,30 天到期,過期就是「這份存檔封存了」),之後那四條路由都要帶它。四步依序執行,第三步的 keyFn 最省不得:

_, err := jwt.ParseWithClaims(raw, &claims, func(t *jwt.Token) (any, error) {
	if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
		return nil, errors.New("unexpected signing method")
	}
	return []byte(secret), nil
}, jwt.WithIssuer(issuer), jwt.WithExpirationRequired())

不檢查演算法,攻擊者把 header 改成 alg=none 就能自己簽,這是 JWT 函式庫最經典的洞。三種拒絕也刻意分開:沒帶 401、帶錯 401、帶了別人的 403,Day 22 的 client 因此能區分「清掉 session 重新建檔」與「這不是你的存檔」。denyJSON 與 httpapi.writeError 格式一樣,只是不能反向 import 父套件。

router.go 用 r.Group 不用 r.Route:四條路由留在同一個 chi.Mux 上只多掛一層,後者會開子 mux,Day 8 的 NotFound 蓋不到。

放 header 不放 cookie 是取捨:cookie 要處理 CSRF 與 SameSite,Bearer header 讓 curl、contract test、Playwright 都能直接帶——可測試性贏了,代價是 token 在 localStorage、XSS 拿得到,「前端不渲染任何使用者輸入的 HTML」是對這件事的回答。前端因此多一個 stores/session.ts。

密鑰有一個明確取捨:不放環境變數。deploy role 有 lambda:GetFunctionConfiguration,明文環境變數等於把簽章密鑰一併交給部署角色。它也多兩條 ECR statement:推映像的五個 layer 動作加 PutImage,Resource 鎖死那一個 repository;以及 ecr:GetAuthorizationToken,Resource 只能寫 *——這個 action 沒有資源層級授權,是 ECR 的設計不是偷懶,它換到的只是一張 12 小時的登入票,拿不到任何映像內容。

claude_27_diagram_02

那條 policy 裡沒有 kms:Decrypt,是刻意的。第一版有,Resource 寫 alias/aws/ssm——但 identity policy 對 KMS action 不吃 alias,只認 key/<key-id>,那個 id 要打 AWS API 才查得到。而且不需要:託管金鑰的 key policy 本來就對帳號內經由 kms:ViaService 走 SSM 的呼叫者開放解密,正是 GetParameter 走的路。多寫那句不生效也不報錯——這種「看起來很安全」的條款最危險,我在原地留了註解說明它為何不在。

JWT_SECRET(明文)仍在,只給本機與測試;正式環境只設 JWT_SECRET_PARAM。兩個都沒有:本機印一行 WARN 用開發密鑰,部署模式直接 os.Exit(1)。比舊架構的 Secrets Manager 少一筆每月 US$0.40。

三、限流:一個做不到的東西,與兩個做得到的東西

我原本要寫行程內每 IP token bucket——golang.org/x/time/rate 加一個 map 加一把 mutex,四十行。寫到一半才想清楚:這在 Lambda 上完全無效。每個執行環境有自己的 map,AWS 隨時新增與回收環境,同一個 IP 的兩個併發請求很可能落在兩個環境,各自看到乾淨的桶子。它不只擋不住人,還給人「限流做好了」的錯覺,比沒有更糟。所以那檔案沒進倉庫,後端永遠不產生 429。

剩下的選項都在邊緣:一是 API Gateway stage 的 throttling,REST API v1 用 aws_api_gateway_method_settings 對 */* 設 20 rps/burst 40,缺點寫在名字裡,它是全域的;二是 WAF 的 rate-based rule,每 IP 每五分鐘 2,000 次,唯一真正的每 IP 限流,代價每月 US$8。

數字沒變:一位玩家全程約 40 次請求、攤在 25 分鐘裡,平均 0.03 rps,最密集的連續按對白也不到 2 rps。結論從「10 rps 是 5 倍」變成「20 rps 全域是尖峰的 10 倍」。所以常駐的是第一項,第二項只開一週。每 IP 的精準限流做不到,這句話寫進 ADR-0006 的已知限制,不寫進 README 的功能清單。

順手處理了 X-Forwarded-For。Day 8 的 middleware.RealIP 會把它寫回 RemoteAddr,但這個 header 可以偽造;舊架構靠「ALB 只收 CloudFront 的流量」那條安全群組規則讓它可信,現在沒有 VPC,就沒有那條規則。真正不可偽造的是 payload 1.0 的 requestContext.identity.sourceIp。裁決:RealIP 保留,但還原出來的 IP 只准進 log,不准進任何授權或限流決策。

middleware/secure.go 加三個標頭(nosniff、X-Frame-Options: DENY、Referrer-Policy: no-referrer),本機沒有 CloudFront 時也生效。HSTS 交給新的 response_headers_policy,只掛在 default behavior 上,/api/* 的標頭仍由 Go 負責——同一組標頭掛兩層會互相覆蓋,選一邊當來源比較不會 debug 到半夜。

四、邊緣、掃描器,與一個 35 分鐘的雷

modules/waf 建一個 scope = "CLOUDFRONT" 的 Web ACL:兩組 AWS 管理規則(CommonRuleSet、KnownBadInputsRuleSet)加那條 rate-based rule。CloudFront 的 WAF 一定要建在 us-east-1(CloudFront 的全域規定,不是台北的毛病),所以 envs/prod 多一個 provider "aws" { alias = "us_east_1" } 傳進模組,distribution 掛 web_acl_id。Web ACL 整個被 count = var.enable_waf ? 1 : 0 包住,今天開起來,plan 從 51 個資源變成 52 個。

上線第一小時,WAF 就擋了 37 個對 /wp-login.php、/.env 的掃描。它們本來只會打到 chi 拿 404,但證明「掛在網路上就有人在敲門」不是修辭。代價直白:US$8.03/月,是其餘全部(US$1.35)的六倍,明天的月費表會處理它。

CI 加 securego/gosec 與 aquasecurity/tfsec-action。掃描器的價值不在第一次的清單,在於每個 PR 都會再跑。tfsec 第一次跑出 4 個:DynamoDB 未用 KMS CMK、log group 未用 CMK、Lambda 未開 X-Ray、S3 網站 bucket 未開 access logging。四筆的理由是同一句:CMK 每月固定 US$1、X-Ray 的追蹤費會超過運算費、再開一個 bucket 收 log 比它擋掉的風險貴——都比整包月費貴。我只在 DynamoDB 那筆放 #tfsec:ignore 並附三行理由,其餘寫進 ADR-0006;每個 ignore 都要附理由,否則三個月後沒人分得出那是取捨還是偷懶。

gosec 兩個:writeJSON 忽略 json.Encode 的回傳值(接受,回應寫到一半沒有補救辦法);runHTTPServer 的 http.Server 沒設 ReadHeaderTimeout(G112)。第二個是真的洞,只是打不到:正式環境是 Lambda,根本沒有 http.Server。我記成待辦而不是誤報——這兩個字的差別,就是三個月後有沒有人去修。

雷在部署後:本機全綠、CI 全綠、apply 成功,然後正式環境所有存檔路由回 401。Day 22 的 /api/* behavior 沒設 origin request policy,CloudFront 預設不把 Authorization 轉給 origin。指向 AllViewerExceptHostHeader 才通——在 API Gateway 當 origin 時它從「最佳實務」變成「必要」:API GW 用 Host 決定送到哪個 API,把瀏覽器的 Host 原封轉過去會吃 403,不能用 AllViewer。花了 35 分鐘,明天週記再列。

小結

今天加的每一層都很薄:JWT 四步、SSM 三個資源、WAF 三條規則、兩個掃描器。真正花時間的是想清楚哪些做得到,而無伺服器的答案跟我以為的不一樣:行程內限流是這一天最重要的產出,因為它沒有被寫出來。一個在 ECS 上完全正確的模式,搬到 Lambda 就從「有效」變成「有害的錯覺」。安全不是把每格打勾,是知道哪格沒打勾、為什麼,以及那格是不是根本不該存在。

今日產出

  • [x] middleware/{auth,secure,deny}.go 與 auth_test.go;router.go 的 r.Group;handlers_save.go 多回一個 token
  • [x] platform.ResolveJWTSecret 三段解析:明文 → SSM SecureString → 開發密鑰/啟動失敗
  • [x] frontend/src/stores/session.ts 與 session.test.ts;main.ts 的 restore() + setTokenProvider()
  • [x] infra/terraform:random_password、aws_ssm_parameter、第三條 inline policy、modules/waf、response_headers_policy、/api/* 的 origin request policy(51 → 52)
  • [x] ci.yml 加 tfsec 與 gosec;docs/adr/0006-security.md 五條已知限制

明日預告

Day 28:【週記】Day 22-27 回顧:從本機到 AWS,那些部署當下才會踩到的雷——九個雷、一張月費表,以及今天這個 US$8 的 WAF 為什麼明天就關掉。


上一篇
Day 26:效能優化總攻:Vue 渲染瓶頸與 Go API 延遲的雙線調校
系列文
奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言