diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 01fb521..5aa5343 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -6,6 +6,13 @@ All notable changes to this repository should be documented in this file.
The format loosely follows Keep a Changelog and can be adapted to the team's habits.
本文档参考了 Keep a Changelog 的思路,也可以根据团队习惯调整。
+## [0.3.4] - 2026-10-02
+
+### Done / 完成
+
+- P11 server-hardening batch delivered across all three product repos: crearte-server `9da39b6` (0.17.0, merge-base `d627e12`), crearte-deploy `740958a` (0.7.0, `12f7c10`), crearte `d25c497` (0.24.1, `338acbf`). Five tasks each through implement → task-level review → adjudication (all PASS, zero critical/important; nine minor notes parked to the polish batch), plus a whole-branch final review on the strongest model. **Shipped**: rate-limiter hard cap (`maxEntries` fail-closed — when the table is full it runs one expiry sweep and, still full, refuses NEW keys with 429+Retry-After without inserting; existing keys keep byte-identical semantics, so `len(hits) ≤ maxEntries` holds inductively; evict-oldest was rejected because an attacker could flush legitimate users' counters); `LOG_LEVEL`/`LOG_FORMAT` lower-cased and trimmed at the config boundary (closes the P8-deferred strict-vs-lenient divergence without touching `internal/observability`); one-time startup `slog.Warn` when `TrustedProxies` is empty (loud trace instead of silent degradation — every client behind a reverse proxy sharing one rate-limit bucket); two negative regression pins for the compose-SNAT gateway trap; deploy `TRUSTED_PROXIES` **empty default** plus a dedicated README section; nginx `X-Real-IP` on both `/api/` locations and `nosniff`/`Referrer-Policy strict-origin-when-cross-origin` with `always` at nine insertion points (2 server blocks + 7 self-headed locations, because nginx `add_header` inheritance is all-or-nothing). CSP and HSTS deliberately out of scope (CSP needs its own design batch — game subdomains load user works via iframe+SW; HSTS waits for the TLS batch — deployment is plain `listen 80`). **The batch's defining event — endpoint verification caught a design-level error and the spec was re-pinned (D-A → D-A′)**: the original design trusted the whole compose subnet `172.28.0.0/24`, on the stated premise that the subnet is a closed boundary containing only this project's web containers. Live-stack measurement falsified the premise twice over: docker SNATs published host ports, so nginx's source is always the bridge gateway `172.28.0.1` — which is INSIDE that subnet; gin therefore walks past the trusted gateway and adopts whatever `X-Forwarded-For` prefix the client pre-seeded (measured on the running prod stack: a client sending `X-Forwarded-For: 8.8.8.8` made the server log `ip=8.8.8.8`, i.e. **anyone could pick their own rate-limit bucket — the "fix" was a bypass**); and real browsers (no XFF) collapse to the gateway regardless of any `TRUSTED_PROXIES` value, because SNAT destroys the real client IP before nginx ever sees it — defect A is unfixable in the compose-local rehearsal form. A six-case trust-matrix spike (`spike_snat`) then pinned the correct semantics: subnet-trust → spoof adopted; trust-nginx-only or trust-nothing → spoof blocked; real-prod shape (nginx directly seeing real clients, trusting nginx) → real IP recovered and spoofed prefixes ignored. Re-pinned design: **empty trust list by default** (XFF ignored entirely, no bypass, honest D-C warning about the single bucket — acceptable for a single-user local rehearsal), subnet pin reverted (its only purpose was feeding TRUSTED_PROXIES; keeping it would only add a pool-overlap failure mode; verified live — the stack came up on docker's dynamic `172.19.0.0/16` and every assertion held), README rewritten with the topology, the trap, the real-prod configuration duty and a spoof-probe verification recipe. The controller's own mid-wave regression had tested the WRONG path (two client-supplied XFF values "counting independently" — that IS the spoof path); the corrected verification uses the real-browser path plus the spoof probe as a negative test. Final review verdict 「需修复后合并」 — N1 (the WARN hint literally recommended the subnet-trust configuration that D-A′ had just classified as a bypass; reworded to "the reverse proxy's own IP/CIDR — never a range that also contains clients or the docker bridge gateway"), N2 (five spec locations still described the reverted subnet pin; corrected), N3/N4/N5 (spike count, stale D-A headings, test count +9→+12) all closed pre-merge; N6 (a validate.yml machine guard asserting the empty default — no automated test can catch a compose-file regression, since the server pins construct Deps directly) parked to the polish batch per the reviewer's own "not blocking" ruling. Acceptance: server gofmt/vet/build clean, `go test ./...` 11 packages ok, `-race` 12 packages ok (Debian golang:1.24 + CGO_ENABLED=1 — alpine lacks gcc and its apk CDN is unreachable from this host), integration leg against real db-test all ok with skip-guard 0; crearte vitest **626** / vue-tsc clean / build OK / main e2e **80+1skip** / noauth 4; deploy four-profile `config -q`, empty default + override passthrough, named-volume set byte-identical to baseline; post-fix live prod-stack regression five-for-five (warning present; spoofed XFF resolves to the nginx container IP, never `8.8.8.8`; single-bucket 429 at the login cap; security headers intact on a 404; bare `down`, all volumes preserved). The final reviewer independently re-ran six legs, matching the controller's numbers item-for-item. Lessons recorded: an isolated spike proves component semantics, not that your configuration is correct in the real topology — security/network fixes require a live-stack end-to-end pass over the REAL traffic path before merge; and an acceptance test that exercises the wrong path is more dangerous than no test.
+- P11 服务端安全硬化批交付,横跨三个产品仓:crearte-server `9da39b6`(0.17.0,merge-base `d627e12`)、crearte-deploy `740958a`(0.7.0,`12f7c10`)、crearte `d25c497`(0.24.1,`338acbf`)。五任务各经实现→任务级审查→裁定(全 PASS,零关键零重要;9 条次要转打磨批),另加强模型全分支终审。**交付内容**:限流表硬上界(`maxEntries` 失败关闭——表满时先跑一次过期清理、仍满则对新面孔 429+Retry-After 且不插入;既有键语义逐字不变,故 `len(hits) ≤ maxEntries` 归纳成立;「逐最旧」被否因为攻击者可用新 IP 冲刷正常用户的计数器);`LOG_LEVEL`/`LOG_FORMAT` 在配置入口小写化与 trim(闭合 P8 挂账的严格/宽容分歧,`internal/observability` 零触碰);`TrustedProxies` 为空时的一次性启动 `slog.Warn`(大声留痕而非静默降级——反代后所有客户端共享一个限流桶);两个 compose-SNAT 网关陷阱反面钉桩;deploy `TRUSTED_PROXIES` **空默认** + README 专节;nginx 两个 `/api/` 块的 `X-Real-IP` 与九个落点的 `nosniff`/`Referrer-Policy strict-origin-when-cross-origin`(均带 `always`;2 个 server 块 + 7 个自带头 location,因 nginx `add_header` 继承全有或全无)。CSP 与 HSTS 有意不做(CSP 需独立设计批——游玩子域经 iframe+SW 加载用户作品;HSTS 待 TLS 批——当前部署是纯 `listen 80`)。**本批定义性事件——端到端验收抓出设计级错误、spec re-pin(D-A→D-A′)**:原设计信任整个 compose 子网 `172.28.0.0/24`,前提是「子网是只含本项目 web 容器的封闭边界」。真实栈实测两度证伪该前提:docker 对发布端口做 SNAT,nginx 看到的源恒为网桥网关 `172.28.0.1`——而它**就在该子网内**;gin 于是跳过这个可信网关、采纳客户端预置的 `X-Forwarded-For` 前缀(运行中的 prod 栈实测:客户端发 `X-Forwarded-For: 8.8.8.8`,服务端日志 `ip=8.8.8.8`,即**任何人可自选限流桶——「修复」本身是个绕过**);且真实浏览器(无 XFF)无论 `TRUSTED_PROXIES` 配什么都塌缩到网关,因为 SNAT 在 nginx 看到之前就销毁了真实客户端 IP——缺陷 A 在 compose 本机演练形态下不可修。六用例信任矩阵 spike(`spike_snat`)随后钉死正确语义:信任子网→伪造被采纳;只信任 nginx 或信任空→伪造被拦截;真实 prod 形态(nginx 直接见真实客户端、信任 nginx)→真实 IP 恢复且伪造前缀被忽略。re-pin 后设计:**默认空信任列表**(XFF 整体忽略、无绕过、D-C 告警如实提示单桶——单用户本机演练可接受)、subnet 固定回退(其唯一目的是喂 TRUSTED_PROXIES;保留只会新增网段冲突失败模式;实栈验证——栈起在 docker 动态分配的 `172.19.0.0/16` 上全部断言成立)、README 重写(拓扑、陷阱、真实 prod 的配置责任、伪造探测验证法)。控制者自己波次中的回归曾测错路径(两个客户端自带 XFF「各自独立计数」——那正是伪造路径);修正后的验证走真实浏览器路径 + 伪造探测作反面测试。终审裁定「需修复后合并」——N1(WARN hint 竟字面推荐 D-A′ 刚判定为绕过的 subnet 信任配置;改写为「反代自身的 IP/网段——绝非同时包含客户端或 docker 网桥网关的范围」)、N2(spec 五处仍描述已回退的 subnet 固定;已改)、N3/N4/N5(spike 份数、D-A 旧标题、测试计数 +9→+12)均合并前闭合;N6(validate.yml 加空默认机器守卫——server 钉桩直接构造 Deps、不读 compose 文件,故无任何自动化测试能抓住 compose 文件回归)依终审者本人「不阻塞」判定转打磨批。验收:server gofmt/vet/build 净、`go test ./...` 11 包 ok、`-race` 12 包 ok(Debian golang:1.24 + CGO_ENABLED=1——alpine 无 gcc 且其 apk CDN 从本宿主不可达)、真库 db-test 集成腿全 ok 且 skip 守卫 0;crearte vitest **626** / vue-tsc 零错 / build OK / 主 e2e **80+1skip** / noauth 4;deploy 四 profile `config -q`、空默认+覆盖跟随、命名卷集合与基线逐字相同;修正后 prod 真实栈回归五项全中(告警在位;伪造 XFF 解析为 nginx 容器 IP、绝非 `8.8.8.8`;login 上限处单桶 429;404 上安全头完好;裸 `down`、卷全留存)。终审者独立复跑六腿,与控制者数字逐条一致。教训入账:隔离 spike 证明的是组件语义、不是你的配置在真实拓扑下正确——安全/网络类修复合并前必须走真实流量路径的实栈端到端验证;测错路径的验收比不测更危险。
+
## [0.3.3] - 2026-10-01
### Done / 完成
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 3247aa3..b29fb97 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -37,6 +37,7 @@
| P9 | UX 增强(第一批:浏览器反馈包) | 路由级 `document.title` 两段式(router `afterEach` 按 19 个 name 全表设静态基线 + 四个数据页 watch 数据后精化)+ 作品页 `meta description`(截 120 码点、默认回落)+ `StatePanel` 骨架对齐(cards 3→6 张、新增 `lines` 变体六页八实例)。零后端/部署改动 | **第一批完成**(2026-10-01 合并推送:crearte `26cd625` 0.22.0(`a196603`+`3f1de38`+审查补钉 `e9e4c56`)。五腿全绿:vitest **551**(基线 512+新增 39)/ vue-tsc 零错 / build OK / 主套件 72+1skip(含新增标题 e2e 腿,`landing.spec.ts` 首条兼容钉一字未动)/ noauth 4。独立审查 PASS with notes:ISSUE-1(离开作品页 description 未回落默认,spec D-C 子项)与 ISSUE-2(「七页」实为六页文案)均已在合主前补钉+补测。B 效率可访问 / C 暗色模式两批挂账待启动) |
| P10 | UX 增强(第一批:日常交互包) | toast 体系(useToast 单例 store + ToastHost 挂 App.vue)+ 投稿表单脏数据离开守卫(onBeforeRouteLeave confirm + beforeunload)+ 作品标签可点(GameView/GameCard tags → /games?tag= RouterLink)+ 作品页复制链接分享(clipboard + toast)+ 最近玩过条带(recent.ts localStorage + GameHost ready 记录 + LandingView 条带)+ 页头下拉外点/Esc 关闭。零后端/部署改动 | **第一批完成**(2026-10-01 合并推送:crearte `ee4edf7` 0.23.0,merge-base `26cd625`。六任务各经实现→任务级审查→裁定,另加全分支终审(spec §3/§4/§5 全量对照)。五腿全绿:vitest **601**(基线 551+新增 50)/ vue-tsc 零错 / build OK / 主套件 **77+1skip**(基线 72+1skip)/ noauth 4。审查补钉四处:`fc0270e`(AppHeader 卸载清理 spy)、`dbf4bf0`(hosted 重开 sync-flush 再记,spec D-H re-pin)、`bb9bb35`(e2e 夹具修正——spec 测试计划自身误选 external-runtime 夹具 2048/a-dark-room,换 abs-paths/storage)、`836b2fb`(AppHeader 路由收起回归,spec §5 明列项)。终审裁定「需修复后合并」两条件均已闭合。B 效率可访问 / C 暗色模式 / OG 卡片 / 播放计数等后续批挂账待启动) |
| P9-B | UX 增强(第二批:可访问与键盘效率包) | WCAG AA 色彩对比达标(`--color-success` 加深 + error toast 换 `bg-accent-ink`)+ toast 播报区重构(常驻 sr-only 播报层与视觉层解耦,收口 P10 终审转办项 T1(d)「实时区嵌交互按钮」)+ skip-link(`App.vue` 根首子,`focus:z-[80]`)+ SPA 导航后焦点管理(`focusMain()`,仅路径变化触发、`preventScroll`)+ 管理端表格 24 个 `
` 补 `scope="col"`(两处空表头补 sr-only「操作」)+ 五星评分可访问名(逐星「评 N 星」+ 分组 `role="group"` 动态标签 + 字形 `aria-hidden`)。另加两个源码级守卫(`contrast.test.ts` 十一配对对比度、`tableScope.test.ts` 全仓 ` | /crearte-server/src && docker run --rm \
+ -v "$PWD":/src -w /src \
+ -v crearte_gomod:/go/pkg/mod \
+ -e GOCACHE=/gocache -v crearte_gocache:/gocache \
+ -e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn \
+ golang:1.24-alpine go test ./internal/api/ -run 'X' -count=1
+ ```
+
+ 冷构建 1–3 分钟属正常,用 `exec` 后台 + 耐心轮询,**不要**因为没立刻出结果就重跑。`proxy.golang.org` 在本机不可达,漏掉 `GOPROXY` 会挂死。
+3. **禁触文件**:`crearte-server` 的 `internal/bundle/**`、`internal/repository/migrations/**`、`internal/storage/**`、`cmd/**`(除 spec 明列);`crearte` 的 `src/app/**`、`src/e2e/**`、`vite.config.ts`、`package.json`、各 config;`crearte-deploy` 的 `scripts/**`、`.github/**`、`backups/**`。
+4. **compose 数据身份红线**:不得重命名或增删任何卷(`pgdata-*`、`bundle-keys-*`、`minio-data-*`、`dev_node_modules`、`mock_node_modules`)。不得改 `depends_on` 健康门控。不得移除 `${VAR:?…}` 守卫。
+5. **绝不 `docker compose … down -v`**(会毁 postgres 数据、bundle keys、MinIO 对象,且 MinIO bucket 初始化须手工重做)。收尾用裸 `down`。
+6. **`TEST_DATABASE_URL` 绝不指向 `db-debug`/`pgdata-dev`**——`db-debug` 与 `db-dev` 共享 `pgdata-dev` 卷,误指会清空开发数据。集成测试只用 `db-test`(`pgdata-test`,127.0.0.1:5432)。
+7. **TDD**:先写/改测试跑红,再实现跑绿;报告里贴 RED/GREEN 的命令与输出摘要。
+8. **每任务独立 commit**,前缀 `fix(security):` / `fix(config):` / `feat(observability):` / `chore(deploy):` + 任务号;**CHANGELOG 不由任务写**,控制者波次末统一补。
+9. **分支纪律**:commit 前 `git branch --show-current` 必须是本任务指定分支;不 merge、不 push、不碰 wrapper 仓(ROADMAP/spec/plan 记账归控制者)。
+ - `crearte-server` → `fix/p11-server-hardening`
+ - `crearte-deploy` → `feat/p11-trusted-proxies`
+ - `crearte` → `feat/p11-trusted-proxies`
+10. **精确使用 spec 给定值**:subnet 与 `TRUSTED_PROXIES` 默认值均为 `172.28.0.0/24`(已核实宿主 `172.17.0.0/16` docker0、`172.18.0.0/16` baota_net 之外空闲);`X-Real-IP` 用 `$remote_addr`;`Referrer-Policy` 用 `strict-origin-when-cross-origin`;`X-Content-Type-Options` 用 `nosniff`;两个 `add_header` 都带 `always`。
+11. **数量/数值必须实测核实**:简报或 spec 里引用的计数(几处 `location`、几个 `add_header`、几个限流器)动手前自己 grep 核准,发现不符以实测为准并在报告里披露。本仓上一波(P9-B)控制者连错两次数字(对比度 3.16 应为 2.83、「19 个 th」是行计数应为 24 元素),均由实现者纠正——这条纪律有效,继续保持。
+12. **验收命令**(每任务收尾自跑并贴摘要):server 任务 = 本包 `go test` + `go vet ./...` + `gofmt -l`(应无输出);deploy/crearte 任务见各自简报。全量腿由控制者波次末跑。
+
+## 任务分解与并行度
+
+文件面两两不相交才可并行。同一 Go 包内并发编辑会让兄弟任务的 `go test` 看到半成品而假红,故 **T1 与 T2 同属 `internal/api`,必须串行**。
+
+| 任务 | 仓 / 包 | 改动文件 | 波次 |
+|---|---|---|---|
+| T1 限流表硬上界(缺陷 B) | server / `internal/api` | `ratelimit.go`、`ratelimit_test.go` | **1** |
+| T2 信任代理链 + 启动告警(缺陷 A、D-C) | server / `internal/api` | `router.go`、新 `clientip_test.go`、`router_test.go` | **2**(T1 后) |
+| T3 LOG 归一 + `parseTrustedProxies` 覆盖(缺陷 C) | server / `internal/config` | `config.go`、`config_test.go` | **1** |
+| T4 compose 固定 subnet + `TRUSTED_PROXIES`(D-A) | deploy | `docker-compose.yml`、`.env.example`、`README.md` | **1** |
+| T5 nginx 反代头加固(D-E) | crearte | `deploy/nginx.conf.template` | **1** |
+| T6 验收(控制者本人,不派发) | 三仓 | — | **3** |
+
+**波次 1 并行**:T1 + T3 + T4 + T5(四个不同仓/包,零文件重叠)。
+**波次 2**:T2(等 T1 落地,同包串行)。
+**波次 3**:T6 控制者验收 + 终审。
+
+## T6 验收腿(控制者本人)
+
+1. **server**:`gofmt -l`(空)、`go vet ./...`、`go test ./... -count=1`、`go test ./... -race -count=1`、`go build ./...`;集成腿用 `db-test`(`docker compose --profile debug up -d db-test` 后 `TEST_DATABASE_URL=postgres://crearte:@127.0.0.1:5432/crearte?sslmode=disable go test ./... -count=1`),确认 skip 守卫数为 0。
+2. **deploy**:四 profile `docker compose --profile dev|prod|debug|mock config -q` 全过;`config` 输出断言 `api-prod`/`api-dev` 的 `TRUSTED_PROXIES` = `172.28.0.0/24`、`networks.default.ipam.config[0].subnet` 同值、**卷名集合与改动前逐字相同**。
+3. **crearte**:五腿全量不回退(vitest **626** / vue-tsc 0 / build OK / 主 e2e **80+1skip** / noauth **4**)——nginx 模板属部署资产,前端测试不应受影响,但必须实跑确认。
+4. **端到端(缺陷 A 的回归钉桩,最关键一腿)**:`docker compose --profile dev up -d --build` → `ps` 健康 →
+ - 启动日志**无** D-C 告警(因为 T4 注入了 `TRUSTED_PROXIES`);
+ - 用两个不同 `X-Forwarded-For` 各打 `/api/auth/login` 若干次,确认**各自独立计数**(改前是共享桶 → 第二个用户首次即 429;改后两个用户互不干扰);
+ - 伪造抗性:客户端自带 `X-Forwarded-For: 8.8.8.8` 打一次,确认服务端日志/限流键取的是**网关追加后的真实值**而非 `8.8.8.8`;
+ - `nginx -t` 校验模板渲染;确认新安全头出现在响应里(含 4xx 路径,验 `always`)。
+ - 收尾 `docker compose --profile dev down`(**绝不 `-v`**)。
+5. **prod profile 冒烟**:`--profile prod up -d --build` → `/healthz` + `/metrics` → `down`。
+
+## 记账(控制者,波次末)
+
+- `crearte-server/docs/CHANGELOG.md` → **0.17.0**;`crearte-deploy/docs/CHANGELOG.md` → **0.7.0**;`crearte/docs/CHANGELOG.md` → **0.24.1**;wrapper `docs/CHANGELOG.md` → **0.3.4**。四仓均双语(同条目英中相邻行、条目间空行、高版本在上)。
+- 三内仓各自 `git merge --no-ff ` → push → 删分支(内仓不得在 master 直接 commit)。wrapper 可 master 直提(2026-09-29 已获批)。
+- wrapper `docs/ROADMAP.md`:新增 P11 行 + 文档索引表补 spec/plan 两行(AGENTS.md 硬性要求)。
+- `memory/2026-10-02.md`:记录本波缺陷链(A 掩盖 B)、spike 方法论、以及「修一个缺陷可能让另一个从不可达变可达」这条教训。
+
+## 打磨批 / 挂账(不在本批)
+
+- CSP(iframe + Service Worker 加载用户作品,需独立设计批)。
+- 共享限流存储(Redis 等)——多实例化前置条件,见 spec D-G。
+- P9-B 打磨批四条(vue-router 升级复看弱断言、撤评语义进可访问名、手动关最新 toast 的播报重念、spec 口径已修)。
+- 备份恢复演练实证(`backup.sh`/`restore-drill.sh` 已交付但 prod 栈演练未跑,属 owner 手动尾巴)。
diff --git a/docs/specs/2026-10-02-p11-server-hardening-design.md b/docs/specs/2026-10-02-p11-server-hardening-design.md
new file mode 100644
index 0000000..7c09714
--- /dev/null
+++ b/docs/specs/2026-10-02-p11-server-hardening-design.md
@@ -0,0 +1,290 @@
+# P11 服务端安全硬化批 — 设计文档
+
+日期:2026-10-02 | 涉及仓:`crearte-server`(主)、`crearte-deploy`(编排)、`crearte`(仅 nginx 模板)| 分支:`fix/p11-server-hardening`(server)/ `feat/p11-trusted-proxies`(deploy、crearte)
+
+## 1. 问题(三处实测缺陷,全部有 spike 证据)
+
+### 缺口 A:限流桶全站塌缩为单桶(高危)
+
+`crearte-server` 的六个限流器实例全部以 `ctx.ClientIP()` 为键(`internal/api/ratelimit.go:42`)。gin 的 `ClientIP()` 只有在 **peer 本身落在 `SetTrustedProxies` 信任列表内**时才会解析 `X-Forwarded-For`;否则忽略该头、返回 peer IP。
+
+部署拓扑事实:
+
+- `api-prod` / `api-dev` **都不发布宿主端口**,唯一入站路径是 nginx(prod)或 vite(dev)反代。
+- nginx 设 `X-Forwarded-For $proxy_add_x_forwarded_for`(`crearte/deploy/nginx.conf.template`,两个 server 块各一处),于是 API 看到的 peer 恒为 nginx 容器 IP。
+- `TRUSTED_PROXIES` 在 `crearte-deploy/docker-compose.yml` 与 `.env.example` 中**均未定义**,README 也未提及 → `parseTrustedProxies("")` 返回空列表 → `SetTrustedProxies([]string{})`。
+
+后果:`ClientIP()` 恒返回 nginx 容器 IP,**全站所有用户共用一个限流桶**。
+
+spike 实测(`crearte-server/.superpowers/sdd-p11/spike_clientip_test.go.evidence`,gin v1.11.0):
+
+| 输入 | `ClientIP()` 返回 |
+|---|---|
+| XFF=`203.0.113.7`,peer=`172.18.0.3` | `172.18.0.3` ← 真实用户 IP 被丢弃 |
+| XFF=`198.51.100.42`,peer=`172.18.0.3` | `172.18.0.3` ← 第二个不同用户仍是同一值 |
+
+限流器行为实测:两个不同用户各发一次,第三个请求即 `429`(`[204 204 429]`)——**第二个用户的首次尝试就被限流**。
+
+> **⚠️ 端到端验收修正(2026-10-02,re-pin 前)**:上面两条 spike 把 peer 设为容器 IP(`172.18.0.3`)并直接带 XFF,**跳过了 compose 里横在 nginx 前面的 docker SNAT 层**。起真实栈后实测(见 `.superpowers/sdd-p11/FINDING-endpoint-snat.md` 与 `spike_snat_test.go.evidence`):docker 对发布端口做 SNAT,nginx 看到的源恒为网桥网关 `172.28.0.1` 而非真实客户端,故 nginx 追加进 XFF 的也是网关。**结论:在 compose 本机演练形态下,`ClientIP()` 无论如何都无法解析出真实客户端 IP**——缺陷 A 的「塌缩」是 SNAT 的固有后果,`TRUSTED_PROXIES` 修不了它;而按原 D-A 信任整个 `/24`(含网关)反而**引入限流绕过**(客户端预置 XFF 会被采纳)。原 D-A 已作废,见 §2 D-A′。
+
+影响面按严重度排序(六桶的 limit/window 见 `internal/api/router.go`):
+
+1. **`bundle-key`(60/min,`RouteBundleKey`)无鉴权且是游玩必需路径**:任意访客刷 60 次即让**全站所有用户无法加载任何 hosted 游戏**。这是当前最可被利用的一条——攻击者无需账号。
+2. **`register`(5/hour)全站共享**:第 6 个访客(含爬虫、预取、健康检查)之后,**全站一小时无法注册**。
+3. **`login`(10/min)全站共享**:任何 10 次登录尝试后全站锁死一分钟;同时这也让**按 IP 的口令爆破防护形同虚设**(攻击者与全体用户同桶,限流并不单独针对攻击者,而 `DummyVerify` 只挡了时序侧信道)。
+4. `upload`(10/min)、`submission`(20/hour)、`reaction`(30/min):多用户同时投稿/评分时互相挤兑。
+
+### 缺口 B:限流表无界增长(高危,且被 A 掩盖)
+
+`maxTrackedIPs = 10000` **不是上限,只是过期清理的触发条件**。`allow()` 的清理循环只删除「窗口已过期」的表项;当大量不同 IP 在**同一窗口内**到达时,没有表项可删,map 持续增长。
+
+spike 实测(`spike_xff_test.go.evidence`):`NewRateLimiter(60, time.Hour)`,15000 个不同 IP 各发一次 → `len(hits) == 15000`,超「上限」5000 条,**无一被逐出**。
+
+耦合关系(本批的核心判断):**A 当前正在掩盖 B**——因为所有用户塌缩成一个 nginx 容器 IP,生产上 map 实际只有 1 条表项。修复 A 让 XFF 生效的同时,会把 B 从「结构上存在但不可达」变成「攻击者可达」:每个真实访客、每个僵尸网络节点、每个 IPv6 地址都成为一条新表项,而 `register`/`submission` 的窗口长达 1 小时。因此 **A 与 B 必须同批落地**,不得只做 A。
+
+内存量级:`windowCounter`(`time.Time` 24B + `int` 8B = 32B)+ map 桶开销 + IP 字符串键(15–45B)≈ 每条 100–150B。10000 条 ≈ 1.5MB,可控;无界则随攻击流量线性增长。
+
+### 缺口 C:`LOG_LEVEL` / `LOG_FORMAT` 大小写策略不一致(低,P8 deferred 债)
+
+同一对取值在两层被以两种策略校验:
+
+- `internal/config/config.go:88`(`applyLogging`)**严格**:`switch cfg.LogLevel { case "debug","info","warn","error": }`,`LOG_LEVEL=INFO` 直接返回错误、**进程启动失败**。
+- `internal/observability/logging.go`(`parseLevel` / `newLogger`)**宽容**:`strings.ToLower(strings.TrimSpace(level))`,接受 `INFO`、` info `。
+
+调用顺序是 `config.Load()`(严格)→ `observability.InitLogging(cfg.LogFormat, cfg.LogLevel)`(宽容),所以宽容分支永远收不到大小写不规范的输入,属死代码;而运维写 `LOG_LEVEL=INFO` 会得到一次启动失败。P8 第二批审查已裁决 deferred,本批收口。
+
+## 2. 决策表
+
+| # | 主题 | 定案 | 理由 |
+|---|---|---|---|
+| D-A′(re-pin,作废原 D-A) | 信任代理链怎么配 | **compose 默认 `TRUSTED_PROXIES` 为空**(不注入 subnet)。保留 `TRUSTED_PROXIES` 的 `.env` 可覆盖项与管线,但**默认值改为空**,并把「真实 prod 必须按实际反代 IP/网段配置」写进 README 作为部署前置。**subnet 固定同步回退**:初版(commit `1a7570b`)曾固定 `172.28.0.0/24`,但其唯一目的是给 `TRUSTED_PROXIES` 一个稳定值——空信任默认下与子网值无关,保留反而新增「与宿主其他项目网段冲突」的失败模式,docker 动态分配即可(实栈验证:栈起在动态 `172.19.0.0/16` 上全部断言成立,见 deploy CHANGELOG 0.7.0 Removed 段)。 | 端到端实测推翻原 D-A 的前提「subnet 是封闭边界、边界内只有本项目 web 容器」:**docker 网桥网关 `172.28.0.1` 也在该 subnet 内**,而 compose 发布端口时 docker 做 SNAT,使 nginx 看到的源恒为网关。信任整个 `/24` 于是把网关划进信任范围 → gin 右向左走信任跳时跳过网关、**采纳客户端预置的 XFF**(spike_snat 实测 `8.8.8.8, 172.28.0.1` + 信任 `/24` → `ClientIP()=8.8.8.8`)→ **限流可被客户端自选桶绕过**,比原缺陷更糟。而真实浏览器(不带 XFF)在 compose 下恒塌缩到网关(SNAT 固有),**缺陷 A 对合法流量无法靠 TRUSTED_PROXIES 修复**。故安全默认是空信任列表:XFF 被整体忽略、无绕过、`ClientIP()` 取 peer(nginx 容器 IP,仍是单桶但不引入新漏洞),D-C 告警如实提示。spike_snat 第 6 用例证明:真实 prod 形态(nginx 直接见真实客户端、信任 nginx)下 `ClientIP()` 能取到真实 IP 且拦截伪造——所以**空默认不损害真实 prod**,只是把配置责任交给运维(README 写明)。 |
+| D-B | 限流表如何变成真上界 | `RateLimiter` 增加 `maxEntries` 字段(由 `NewRateLimiter` 设为 `maxTrackedIPs`)。`allow()` 在**新建表项前**:若 `len(hits) >= maxEntries`,先跑一次过期清理;仍满则**拒绝**该请求(返回 `false` → `429` + `Retry-After`),并且**不插入**表项 | 「满则拒」而非「满则逐最旧」:逐最旧会让攻击者用新 IP 冲刷把正常用户的计数器挤掉(等价于绕过限流),而满则拒是**失败关闭**——在极端情况下宁可多拒也不失去上界。既有键(已在表中的 IP)永远不受影响,只影响「表满时的新面孔」,而表满本身就是异常态。清理循环从 `> maxTrackedIPs` 改为 `>= maxEntries`,并把「清理」与「仍满则拒」写成同一条路径,避免只删不判的旧语义。 |
+| D-C | 桶塌缩要不要加可观测性 | 在 `router.go` 装配处,当 `TrustedProxies` 为空且引擎已挂载限流器时,`slog.Warn` 一条启动告警,说明「所有客户端将共享同一限流桶」 | 这是「静默降级」类缺陷的通用解法:配置缺失时不猜测、不静默,而是**大声告诉运维**。空信任列表本身是合法配置(直连、无反代的部署),所以不能报错退出,但必须留痕。告警走既有 `slog`,不加新依赖、不加新配置项。 |
+| D-D | `LOG_LEVEL`/`LOG_FORMAT` 哪边迁就哪边 | 在 `config.applyLogging` 中先 `strings.ToLower(strings.TrimSpace(v))` 归一,再按小写白名单校验;`observability` 层**保持原样** | 两层都宽容会让「配置值到底是什么」失去单一真相;两层都严格会让 `INFO` 这种常见写法启动失败。选择在**入口层归一**:`cfg.LogLevel` 从此恒为规范小写,下游(含 `observability.parseLevel` 的 ToLower)成为无害的幂等操作,无需改动、无需删它的宽容逻辑(它还被 `newLogger` 的单元测试直接使用)。 |
+| D-E | nginx 模板要不要改 | 给两个 `location /api/` 块补 `proxy_set_header X-Real-IP $remote_addr;`,并在 server 块补 `add_header X-Content-Type-Options "nosniff" always;` 与 `add_header Referrer-Policy "strict-origin-when-cross-origin" always;` | `X-Real-IP` 给限流与日志一个**不经链式追加、不可被客户端预置污染**的单跳真相(nginx 用 `$remote_addr` 覆写,客户端发什么都会被替换),作为 XFF 的冗余校验与未来 `TrustedPlatform` 选项的入口。`nosniff` 与 `Referrer-Policy` 是静态资源与 API 反代共用的最低成本加固;`always` 保证 4xx/5xx 响应也带头。**不加 CSP**:本站的游玩子域要靠 iframe + Service Worker 加载用户上传的作品,CSP 需要单独一批设计与验证(挂账)。 |
+| D-F | dev 侧要不要开 vite `xfwd` | **不做** | 已核实 vite 8.3.0 的 `ProxyOptions` 支持 `xfwd?: boolean`(`node_modules/vite/dist/node/index.d.ts:605`,bundled http-proxy 认它)。但 spike case 3/4 证明:dev 下浏览器经宿主端口进 web-dev,vite 看到的 peer 是 docker 网关(`172.28.0.1`),开 `xfwd` 只会把塌缩值从 vite 容器 IP 换成网关 IP——**不修复任何东西**,且 dev 本就是单用户 localhost 环境。改它属于无收益的前端改动,故 P11 不碰 `crearte` 的 `vite.config.ts`。 |
+| D-G | 限流是否升级为共享存储(Redis 等) | **不做**,仅在 spec 记录权衡 | 多实例部署下进程内 map 仍是每实例独立(P3 已文档化该权衡)。引入 Redis 会带来新依赖、新故障域与新 compose 服务,而当前拓扑是单实例;「A 修好后按真实 IP 分桶」已经恢复了限流的设计意图。挂账为未来多实例化的前置条件。 |
+| D-H | 是否顺手做 `td`→`th scope="row"` 之外的其他前端项 | **不做** | P11 是服务端批,前端只碰 `crearte/deploy/nginx.conf.template`(属部署资产,非应用代码)。保持批次边界清晰,避免与 P9-B 打磨批、C 暗色模式批冲突。 |
+
+## 3. 实现
+
+### 3.1 `internal/api/ratelimit.go`(D-B)
+
+```go
+type RateLimiter struct {
+ mu sync.Mutex
+ limit int
+ window time.Duration
+ maxEntries int
+ hits map[string]*windowCounter
+}
+
+func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
+ return &RateLimiter{
+ limit: limit,
+ window: window,
+ maxEntries: maxTrackedIPs,
+ hits: map[string]*windowCounter{},
+ }
+}
+```
+
+`allow()` 改为(保持既有签名与 `now` 注入以便测试):
+
+```go
+func (l *RateLimiter) allow(ip string, now time.Time) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ counter, ok := l.hits[ip]
+ if !ok {
+ // 新面孔:先确认表有空间。maxEntries 是硬上界(D-B),
+ // 满则失败关闭——拒绝且不插入,避免无界增长。
+ if len(l.hits) >= l.maxEntries {
+ l.evictExpired(now)
+ if len(l.hits) >= l.maxEntries {
+ return false
+ }
+ }
+ l.hits[ip] = &windowCounter{start: now, count: 1}
+ return true
+ }
+ if now.Sub(counter.start) >= l.window {
+ counter.start = now
+ counter.count = 1
+ return true
+ }
+ if counter.count >= l.limit {
+ return false
+ }
+ counter.count++
+ return true
+}
+
+// evictExpired 删除窗口已过期的表项。调用方必须持有 l.mu。
+func (l *RateLimiter) evictExpired(now time.Time) {
+ for key, counter := range l.hits {
+ if now.Sub(counter.start) >= l.window {
+ delete(l.hits, key)
+ }
+ }
+}
+```
+
+要点:**既有键的路径语义与旧实现逐字等价**(窗口过期则重置为 `count:1` 并放行;未过期且已达 limit 则拒;否则自增放行),只有「新面孔」多了上界检查。旧实现里 `counter.start` 的重置发生在 map 赋值处,新实现改为原地改字段——效果相同,避免为已存在的键重新分配结构体。
+
+### 3.2 `internal/config/config.go`(D-D)
+
+```go
+if v := os.Getenv("LOG_FORMAT"); v != "" {
+ cfg.LogFormat = strings.ToLower(strings.TrimSpace(v))
+}
+if v := os.Getenv("LOG_LEVEL"); v != "" {
+ cfg.LogLevel = strings.ToLower(strings.TrimSpace(v))
+}
+```
+
+白名单 `switch` 与错误信息**保持不变**(校验的仍是小写集合;错误消息里回显的是归一后的值,便于运维看到实际被解析成什么)。`DefaultLogFormat`/`DefaultLogLevel` 已是小写,不动。
+
+### 3.3 `internal/api/router.go`(D-C)
+
+在 `SetTrustedProxies` 之后、路由注册之前插入:
+
+```go
+if len(proxies) == 0 {
+ // 无反代直连是合法拓扑,但此时 ClientIP() 恒为 peer IP。若 API 位于
+ // nginx/vite 之后而未配 TRUSTED_PROXIES,所有客户端会共享同一个限流桶
+ // (bundle-key 60/min、register 5/hour、login 10/min 均按 IP 计),
+ // 少量流量即可让全站拒绝服务。故大声留痕而非静默降级。
+ slog.Warn("api: no trusted proxies configured; client IP resolution falls back to the peer address, so every client behind a reverse proxy shares one rate-limit bucket",
+ "hint", "set TRUSTED_PROXIES to the reverse proxy's own IP/CIDR — never a range that also contains clients or the docker bridge gateway, or clients can spoof X-Forwarded-For to pick their own rate-limit bucket")
+}
+```
+
+`internal/api` 需新增 `log/slog` import。
+
+### 3.4 `crearte-deploy/docker-compose.yml`(D-A′)
+
+**不新增顶层 `networks` 键**——subnet 固定已回退(理由见 §2 D-A′;初版 `1a7570b` 加了它,`08149aa` 删掉),docker 动态分配默认网络即可,空信任默认与子网值无关。
+
+`x-api-env` 锚点(第 20–26 行)新增一行,但**默认值为空**(D-A′)——compose 本机演练下 docker SNAT 使真实客户端 IP 不可达,空信任列表是安全默认(XFF 整体忽略、无绕过、D-C 告警如实提示单桶):
+
+```yaml
+ TRUSTED_PROXIES: ${TRUSTED_PROXIES:-}
+```
+
+真实 prod 部署(nginx 直接见真实客户端,或前置公网 LB)由运维在 `.env` 里把 `TRUSTED_PROXIES` 设为实际反代 IP/网段(README 写明)。注意空默认下 `parseTrustedProxies("")` 返回空切片,`router.go` 的 `if proxies == nil` 分支不触发(空切片非 nil),但 `len(proxies) == 0` 仍真 → D-C 告警照常发。
+
+兼容性:`api-dev`/`api-prod` 的 `networks: default: aliases: [api]`(服务级接入声明)不受影响——无顶层 `networks` 键时 compose 自动创建默认网络,服务名解析不变。**不得改动任何卷名**(`pgdata-*`/`minio-data-*`/`dev_node_modules`/`mock_node_modules` 是数据身份),不得改 `depends_on` 健康门控,不得移除 `${VAR:?…}` 守卫。
+
+### 3.5 `crearte-deploy/.env.example`(D-A′)
+
+在「prod 写侧可选项」段之后新增:
+
+```
+# --- 客户端 IP 解析与限流(默认留空,通常无需设置)---
+# compose 本机演练下 docker 对发布端口做 SNAT:反代看到的源恒为网桥网关而非真实客户端,
+# 真实客户端 IP 在本机形态下不可达,限流因而是单桶(演练环境单用户,可接受)。
+# 切勿设为整个 compose 网段:网关也在网段内,信任它会让客户端预置的 X-Forwarded-For
+# 被采纳,使限流可被自选桶绕过。详见 README「客户端 IP 解析与限流」。
+# 真实 prod(反代直接见真实客户端,或前置公网 LB)才需设为实际反代 IP/网段:
+# TRUSTED_PROXIES=
+```
+
+(`COMPOSE_SUBNET` 项随 subnet 固定回退一并移除。)
+
+### 3.6 `crearte/deploy/nginx.conf.template`(D-E)
+
+两个 `location /api/` 块各补一行:
+
+```
+ proxy_set_header X-Real-IP $remote_addr;
+```
+
+两个 `server` 块各补两行(`listen`/`server_name` 之后):
+
+```
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+```
+
+注意 nginx `add_header` 的**继承陷阱**:子 `location` 内出现任何 `add_header` 会**完全屏蔽**父级 `server` 的 `add_header`。现有 `location = /index.html`、`/assets/`、`/data/`、`/data/bundles/` 都已有自己的 `add_header`,故这些路径**不会**继承新的两个安全头。定案:在 server 级加,并**同时**在已有的全部**七个**自带 `add_header` 的 `location` 块内各自补上同样两行(第一个 server:`= /index.html`、`/assets/`、`/data/bundles/`、`/data/`;第二个 server:`= /__bootstrap`、`= /sw.js`、`= /agent.js`),保证全站一致(这属于「加固必须无洞」而非过度设计)。合计落点:2 个 server 块 + 7 个 location 块 = **9 处**,每处两行 `add_header`;另 `X-Real-IP` 共 **2 处**(两个 `location /api/` 各一行)。
+
+### 3.7 README(`crearte-deploy/README.md`,D-A′/D-E)
+
+新增一节「客户端 IP 解析与限流」,说明:
+- **compose 本机演练形态**:docker 对发布端口做 SNAT,nginx 看到的源恒为网桥网关 `172.28.0.1`,真实客户端 IP 不可达 → 默认 `TRUSTED_PROXIES` 空,限流是单桶(演练环境单用户,可接受),D-C 启动告警会如实提示。
+- **为什么默认不信任整个子网**:网关也在子网内,信任它会让客户端预置的 XFF 被采纳(spike_snat 实测 `8.8.8.8` 被当成客户端 IP)→ 限流可被自选桶绕过。这是本批端到端验收抓出并 re-pin 的关键修正。
+- **真实 prod 部署**:nginx 直接见真实客户端(host 网络/直接暴露)或前置公网 LB 时,把 `TRUSTED_PROXIES` 设为实际反代 IP/网段;此时 gin 右向左走信任跳能取到真实客户端 IP 且拦截伪造前缀(spike_snat 第 6 用例)。
+- **如何验证**:查启动日志有无 D-C 告警(空信任列表时应有);真实 prod 配好后用两个不同真实来源打 `/api/auth/login` 观察是否各自独立计数。
+- **`X-Real-IP` 与 `XFF` 的分工**:XFF 链式追加、可被客户端预置前缀污染(gin 从右向左走信任跳故仍安全);`X-Real-IP` 由 nginx 用 `$remote_addr` 覆写、单跳不可伪造。
+
+compose 行为变更必须同步 README(AGENTS.md 硬性要求)。
+
+## 4. 边界(明确不做)
+
+- **不加 CSP**(iframe + Service Worker 加载用户作品,需独立设计批)。
+- **不引入 Redis / 共享限流存储**(D-G,单实例拓扑下无收益,挂账为多实例化前置条件)。
+- **不改 `vite.config.ts`**(D-F)。
+- **不改 `observability/logging.go`**(D-D:归一在入口层做,下游宽容逻辑保留且变幂等)。
+- **不改口令散列 / JWT / CORS**:已核实均为正确实现——pbkdf2-sha256 **600000** 轮 + `subtle.ConstantTimeCompare` + 不存在用户走 `DummyVerify` 挡时序侧信道;JWT `HS256` 钉死 + `WithValidMethods` + `WithExpirationRequired` + 32 字节密钥强校验;CORS 白名单精确匹配 + `Vary: Origin`。不是靶子,不动。
+- **不改任何既有 handler 的 `Cache-Control`**:已核实 auth/reactions/account/admin_console 的敏感响应均已带 `no-store`。
+- **不改六个限流器的 limit/window 数值**:本批修的是「按谁计数」与「表能否无界」,不是配额策略。
+
+## 5. 测试计划
+
+### T1(server)限流表硬上界
+`internal/api/ratelimit_test.go` 扩展:
+1. **RED→GREEN 上界**:`ratelimit_test.go` 是 `package api`(同包),可直接构造 `&RateLimiter{limit: …, window: …, maxEntries: 4, hits: map[string]*windowCounter{}}` 注入小上界——**不得为测试往生产代码加导出/私有构造函数**。填满表后:新 IP 被拒(`429`)、`len(hits)` **不超过** `maxEntries`、既有 IP 仍可正常计数(不被挤掉)。
+2. **过期后可回收**:表满 → 时间推进超过窗口 → 新 IP 放行且 `len(hits)` 回落。
+3. **既有语义不回退**:`TestRateLimiterAllowsUpToLimit`、`TestRateLimiterWindowResets` 逐字保留且继续绿(注意两者都用 `gin.New()` 且不设 `SetTrustedProxies`、只设 `RemoteAddr` 不设 XFF,gin 默认信任全网段故 `ClientIP()` 取 peer IP——新增上界逻辑不得改变这条路径)。
+4. 把 spike 的 15000-IP 场景改写成**断言**(`len(hits) <= maxEntries`)而非日志——这是缺陷 B 的回归钉桩。该用例须用 `maxEntries` 注入的小值跑(否则真造 15000 条会拖慢套件);另保留一条用 `NewRateLimiter` 的断言,钉住生产构造确实用 `maxTrackedIPs` 作上界。
+
+### T2(server)信任代理链解析
+`internal/api/router_test.go` 扩展 + 新 `internal/api/clientip_test.go`:
+1. `Deps.TrustedProxies` 为空 → `NewRouter` 返回的引擎对「peer 在容器网段 + XFF 带真实 IP」的请求,`ClientIP()` 返回 **peer IP**(XFF 整体忽略——这正是 D-A′ 的安全默认,也是 compose 本机演练的实际形态)。
+2. `Deps.TrustedProxies = ["172.28.0.0/24"]` → 同样请求返回 **XFF 中的真实 IP**(真实 prod 形态:nginx 直接见真实客户端并追加,信任 nginx 后能取到真实 IP)。
+3. **伪造抗性**(把 spike 五个场景变成断言):客户端预置 `X-Forwarded-For: 8.8.8.8, <真实IP>` / 多跳伪造 / 伪造信任网段内 IP / 双层代理——全部返回最右侧不可信跳,**不得**返回客户端可控的前缀值。
+4. **启动告警**:`TrustedProxies` 为空时 `NewRouter` 产生一条 `slog.Warn`(用 `bytes.Buffer` + `slog.New(slog.NewJSONHandler(...))` 捕获,仿 `observability/logging_test.go:23` 既有范式),断言消息含 `trusted proxies`;非空时**不**产生该告警。告警断言只调 `NewRouter`、不发请求,避免 `RequestLogger` 噪声混入捕获 buffer。
+5. all-trusted 边界钉桩(`spike_alltrusted_test.go.evidence`):XFF 全为信任跳时 gin 返回最左条目(dev 拓扑的网关 IP)——作为已知可接受行为钉桩(spec D-F),测试注释说明缘由。
+6. **SNAT 网关陷阱钉桩(新增,`spike_snat_test.go.evidence`)**:`TrustedProxies = ["172.28.0.0/24"]`(含网关)+ peer=nginx 容器 IP + XFF=`8.8.8.8, 172.28.0.1`(客户端预置 + nginx 追加 SNAT 网关)→ `ClientIP()` 返回 **`8.8.8.8`**(客户端可控值被采纳)。这是**反面钉桩**:记录「信任整个含网关的 subnet 会引入限流绕过」这个陷阱,防止将来有人把 compose 默认改回 subnet 信任。测试注释须说明这正是 D-A′ 把默认改为空的原因。另钉:`TrustedProxies` 空 + 同 XFF → 返回 peer(nginx IP),伪造被忽略。
+
+### T3(server)LOG 归一 + `parseTrustedProxies` 覆盖
+`internal/config/config_test.go` 扩展 `TestApplyLogging`:
+1. `LOG_LEVEL=INFO`、`LOG_FORMAT=" JSON "` → 成功,`cfg.LogLevel == "info"`、`cfg.LogFormat == "json"`。
+2. `LOG_LEVEL=" warn "`(两侧空格)→ 成功归一为 `warn`。
+3. `LOG_LEVEL=bogus` → 仍报错(既有断言保留)。
+4. 空值 → 仍取默认(既有断言保留)。
+5. 新增 `parseTrustedProxies` 测试(当前**零覆盖**;归 T3 因为它属 `internal/config` 包,与 T2 的 api 包分开以免并行冲突):`"172.28.0.0/24"` → 单元素切片;`"10.0.0.1, 192.168.0.0/16"` → 两元素(含 trim);`""` 与 `" , , "` → 空切片;`"not-an-ip"`、`"172.28.0.0/33"` → 报错。
+
+### T4(deploy + crearte)编排与反代(D-A′ 修正后)
+1. `docker compose --profile dev|prod|debug|mock config -q` 四个 profile 全过(AGENTS.md 要求)。
+2. 断言 `config` 输出中 `api-prod`/`api-dev` 的 `TRUSTED_PROXIES` **默认为空**(未设 `.env` 时),且输出**无** subnet 固定(顶层 `networks` 键不存在,`config | grep subnet` 为空);另断言 `TRUSTED_PROXIES=10.0.0.0/8` 覆盖时跟随(管线仍通)。
+3. **卷名不变**核查:`config` 输出的 `volumes` 键集合与改动前逐字相同(数据身份红线)。
+4. `nginx -t` 校验模板渲染结果(`envsubst` + `docker run nginx:1.27-alpine nginx -t`)。
+5. 起 prod 栈端到端(**D-A′ 的关键回归,用真实浏览器路径而非客户端自带 XFF**):`up -d --build` → `ps` 健康 → ① 启动日志**有** D-C 告警(空信任列表);② **不带任何 XFF** 打 `/api/auth/login`,api 日志 `ip=` 应为 nginx 容器 IP(单桶,SNAT 固有,如实记录);③ **客户端自带 `X-Forwarded-For: 8.8.8.8`** 打一次,api 日志 `ip=` 应仍为 nginx 容器 IP(**不是** `8.8.8.8`)——证明空信任列表下伪造 XFF 被忽略、无绕过。裸 `down`(绝不 `-v`)。
+
+### T5(波次末,控制者)验收腿
+- server:`gofmt -l`、`go vet`、`go test ./...`(docker 化 Go 1.24 + `GOPROXY=goproxy.cn`)、`-race` 腿、`TEST_DATABASE_URL` 指向 `db-test` 的集成腿(**绝不**指向 `db-debug`/`pgdata-dev`)。
+- deploy:四 profile `config -q` + CI 的 `validate.yml` compose 腿。
+- crearte:五腿全量不回退(vitest 626 / vue-tsc 0 / build OK / 主 e2e 80+1skip / noauth 4)——nginx 模板改动属部署资产,前端测试不应受影响,但必须实跑确认。
+- 端到端:dev 栈 + prod 栈各起一次,`/healthz` 与 `/metrics` 冒烟,`down`(**绝不 `-v`**)。
+
+## 6. CHANGELOG / 版本
+
+- `crearte-server`:`0.17.0`(Fixed 段记 A/B/C,Added 段记 D-C 启动告警;Tests 段记测试增量)。
+- `crearte-deploy`:`0.7.0`(Changed 段记 `TRUSTED_PROXIES` 空默认 + SNAT 绕过理由;**Removed 段记 subnet 固定回退**;文档段记 README 新节与 .env.example 说明块)。
+- `crearte`:`0.24.1`(Changed 段记 nginx 模板的 `X-Real-IP` + 两个安全头;纯部署资产,patch 级)。
+- wrapper `crearte-monorepo`:`0.3.4`(Done 段记 P11 交付 + ROADMAP P11 行 + 文档索引表补 spec/plan 两行)。
+- 格式遵循各仓既有惯例:同条目英文行紧接中文行(无空行),不同条目间空行,高版本在上。
+
+## 7. 证据存档
+
+四份 spike 已存于 `crearte-server/.superpowers/sdd-p11/`(`.gitignore` 已加 `.superpowers/`,不入库):
+
+- `spike_clientip_test.go.evidence` — 缺陷 A:塌缩实测 + 共享桶 `[204 204 429]`。
+- `spike_xff_test.go.evidence` — 修复安全性(gin 右向左走信任跳,5 个伪造场景全过)+ 缺陷 B(15000 IP → `len(hits)=15000`)。
+- `spike_alltrusted_test.go.evidence` — all-trusted 边界(XFF 全为信任跳时返回最左条目 = 网关 IP),D-F 判定依据。
+- `spike_snat_test.go.evidence` — **compose SNAT 信任矩阵(D-A′ re-pin 的决定性证据)**:复刻 compose 拓扑(peer=nginx 容器 IP、XFF 含 SNAT 网关),六用例证明 ① 信任整个 `/24`(含网关)时客户端预置 `8.8.8.8` 被采纳(绕过);② 只信任 nginx IP 或信任空时伪造被拦截;③ 真实 prod 形态(nginx 见真实客户端、信任 nginx)下能取到真实 IP 且拦截伪造前缀。
+- `FINDING-endpoint-snat.md` — 端到端验收发现全文:实测证据链(prod 栈 `ip=` 分布)、根因链(docker SNAT → nginx 追加网关 → 信任 subnet 含网关 → gin 跳过网关采纳客户端值)、原 D-A 设计错误如实记录、修正方向与 spike 验证。
+
+gin v1.11.0 `ClientIP()` 源码已交叉验证:`trusted := c.engine.isTrustedProxy(remoteIP)`,仅当 peer 在信任列表内且 `ForwardedByClientIP` 时才走 `validateHeader`,否则 `return remoteIP.String()`。
|