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:
2026-10-02 03:54:41 +08:00
parent 0e63dc2448
commit 708f95eb3d
4 changed files with 375 additions and 0 deletions
@@ -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()`。