docs: P11 server-hardening delivered (server 0.17.0 / deploy 0.7.0 / crearte 0.24.1)
ROADMAP P11 row -> 完成 with the D-A->D-A' re-pin narrative; doc index updated; CHANGELOG 0.3.4 (Done section, bilingual) recording the endpoint-verification catch: the original subnet-trust design was a rate-limit bypass under compose's docker SNAT, corrected to an empty TRUSTED_PROXIES default. Register the P11 spec + plan in the doc index.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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 个 `<th>` 补 `scope="col"`(两处空表头补 sr-only「操作」)+ 五星评分可访问名(逐星「评 N 星」+ 分组 `role="group"` 动态标签 + 字形 `aria-hidden`)。另加两个源码级守卫(`contrast.test.ts` 十一配对对比度、`tableScope.test.ts` 全仓 `<th` 必带 scope)与 `e2e/a11y.spec.ts`。零后端/部署改动 | **第二批完成**(2026-10-01 合并推送:crearte `338acbf` 0.24.0,merge-base `ee4edf7`。五任务(T1-T4 派发 + T5 控制者本人)各经实现→任务级审查→裁定,四次任务级审查全 PASS,另加全分支终审(spec §1-§6 全量对照 + 跨任务集成缝隙 + mutation 抽查)。五腿全绿:vitest **626**(基线 601+新增 25)/ vue-tsc 零错 / build+build-runtime OK / 主套件 **80+1skip**(基线 77+1skip)/ noauth 4。终审独立实跑六腿与控制者数字逐条一致,裁定 APPROVE with notes(零关键/零重要)。波次内两次 spec 层修正:① T1 实现者抓出 spec 初稿 D-I 缺陷(per-toast `role=status` 与文字同时创建,部分读屏不播报首条)→ re-pin D-I′ 为常驻播报区,控制者直修 `f01baae` 并附带钉死饱和态 watch 陷阱(源必须是末尾 id 而非长度,`MAX_VISIBLE=3` 时 3→3 不触发);② 控制者简报两处数量笔误(对比度 3.16 应为 2.83、「19 个 th」是行计数、元素实为 24)均由实现者以 spec/实测为准纠正,spec §1 已同步。打磨批挂账四条(router 升级复看弱断言、撤评语义进可访问名、手动关最新 toast 的播报重念、spec 口径已修)。C 暗色模式 / OG 卡片 / 播放计数 / 移动端页头等后续批挂账待启动) |
|
||||
| P11 | 服务端安全硬化 | 限流表硬上界(`maxEntries` 失败关闭:满时先过期清理、仍满则对新面孔 429 且不插入,既有键语义逐字不变;闭合缺陷 B「同窗口 map 无界增长」)+ `LOG_LEVEL`/`LOG_FORMAT` 入口层小写化与 trim(闭合 P8 deferred 缺陷 C)+ `TrustedProxies` 空时启动 `slog.Warn`(D-C 大声留痕)+ 两个 compose-SNAT 网关陷阱反面钉桩 + deploy `TRUSTED_PROXIES` **空默认**与 README 专节(真实 prod 按实际反代 IP/网段配)+ nginx `X-Real-IP` 与九处 `nosniff`/`Referrer-Policy`(`always`)。CSP 与 HSTS 有意不做(前者需独立设计批,后者待 TLS 批) | **完成**(2026-10-02 合并推送:server `9da39b6` 0.17.0(merge-base `d627e12`)/ deploy `740958a` 0.7.0(`12f7c10`)/ crearte `d25c497` 0.24.1(`338acbf`)。五任务各经实现→任务级审查→裁定全 PASS(零关键零重要,9 条次要转打磨批),另加全分支终审(qwen3.8-max)裁定「需修复后合并」——N1(WARN hint 竟建议 subnet 信任,即 D-A′ 判定的绕过配置)/N2(spec 五处 subnet 线未随 re-pin 更新)/N3-N5 均已闭合,N6(validate.yml 加 `TRUSTED_PROXIES` 空默认机器守卫)转打磨批。**本批最重要事件:端到端验收抓出设计级错误并 re-pin D-A→D-A′**——原 D-A 信任整个 compose 子网,但真实 prod 栈实测:docker 对发布端口做 SNAT,nginx 看到的源恒为网桥网关且网关也在子网内,gin 跳过可信网关后**采纳客户端预置 XFF**(`X-Forwarded-For: 8.8.8.8` → `ip=8.8.8.8`,限流可自选桶绕过);而真实浏览器(无 XFF)在 compose 下恒塔缩到网关——缺陷 A 对合法流量根本修不了(SNAT 固有)。spike_snat 六用例信任矩阵钉死后改为:空信任默认(XFF 整体忽略、无绕过、D-C 告警如实提示单桶)+ subnet 固定回退 + README 重写真实 prod 配法 + server 两个反面钉桩。修正后 prod 真实栈回归五项全中(告警在位、伪造 XFF 解析为 nginx 容器 IP 绝非 8.8.8.8、单桶 429 正常、404 上安全头完好、卷全留存)。验收全链:server gofmt/vet/build 净 + test 11 包 ok + `-race` 12 包 ok(Debian 镜像 CGO=1)+ 真库 db-test 集成 ok(skip 守卫 0);crearte vitest **626**/typecheck 零错/build OK/主 e2e **80+1skip**/noauth 4;终审独立复跑六腿与控制者数字逐条一致。教训入账:隔离 spike 证明组件语义、不证明配置在真实拓扑下正确——安全/网络类修复合并前必须走真实流量路径的端到端验证;验收测错路径(用伪造 XFF 当回归)比不测更危险。CSP / TLS+HSTS / 打磨批(P9-B 四条 + P11 九条次要 + N6)挂账待启动) |
|
||||
|
||||
## 排序理由
|
||||
|
||||
@@ -66,3 +67,4 @@
|
||||
| P9 UX 第一批(浏览器反馈包) | `docs/specs/2026-10-01-p9-ux-browser-feedback-design.md` | `docs/plans/2026-10-01-p9-ux-browser-feedback.md`(第一批已执行,2026-10-01:crearte 0.22.0) |
|
||||
| P10 UX 第一批(日常交互包) | `docs/specs/2026-10-01-p10-ux-daily-interaction-design.md` | `docs/plans/2026-10-01-p10-ux-daily-interaction.md`(第一批已执行,2026-10-01:crearte 0.23.0) |
|
||||
| P9-B UX 第二批(可访问与键盘效率包) | `docs/specs/2026-10-01-p9b-a11y-keyboard-design.md` | `docs/plans/2026-10-01-p9b-a11y-keyboard.md`(第二批已执行,2026-10-01:crearte 0.24.0) |
|
||||
| P11 服务端安全硬化 | `docs/specs/2026-10-02-p11-server-hardening-design.md` | `docs/plans/2026-10-02-p11-server-hardening.md`(已执行,2026-10-02:server 0.17.0 / deploy 0.7.0 / crearte 0.24.1) |
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# P11 服务端安全硬化批 — 实现计划
|
||||
|
||||
日期:2026-10-02 | spec:`docs/specs/2026-10-02-p11-server-hardening-design.md`(权威,冲突时 spec 赢)
|
||||
|
||||
## 全局约束(逐字进每个任务简报)
|
||||
|
||||
1. **零新依赖**:`crearte-server/src/go.mod` 与 `go.sum` 不得出现新 require;前端 `package.json` 不动。
|
||||
2. **Go 工具链(硬性)**:宿主 Go 是 1.18,**不可用**。所有 `go build/vet/test` 必须在容器里跑:
|
||||
|
||||
```sh
|
||||
cd <repo>/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:<pw>@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 <branch>` → 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 手动尾巴)。
|
||||
@@ -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()`。
|
||||
Reference in New Issue
Block a user