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.
28 KiB
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):
bundle-key(60/min,RouteBundleKey)无鉴权且是游玩必需路径:任意访客刷 60 次即让全站所有用户无法加载任何 hosted 游戏。这是当前最可被利用的一条——攻击者无需账号。register(5/hour)全站共享:第 6 个访客(含爬虫、预取、健康检查)之后,全站一小时无法注册。login(10/min)全站共享:任何 10 次登录尝试后全站锁死一分钟;同时这也让按 IP 的口令爆破防护形同虚设(攻击者与全体用户同桶,限流并不单独针对攻击者,而DummyVerify只挡了时序侧信道)。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)
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 注入以便测试):
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)
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 之后、路由注册之前插入:
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 告警如实提示单桶):
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挡时序侧信道;JWTHS256钉死 +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 扩展:
- RED→GREEN 上界:
ratelimit_test.go是package api(同包),可直接构造&RateLimiter{limit: …, window: …, maxEntries: 4, hits: map[string]*windowCounter{}}注入小上界——不得为测试往生产代码加导出/私有构造函数。填满表后:新 IP 被拒(429)、len(hits)不超过maxEntries、既有 IP 仍可正常计数(不被挤掉)。 - 过期后可回收:表满 → 时间推进超过窗口 → 新 IP 放行且
len(hits)回落。 - 既有语义不回退:
TestRateLimiterAllowsUpToLimit、TestRateLimiterWindowResets逐字保留且继续绿(注意两者都用gin.New()且不设SetTrustedProxies、只设RemoteAddr不设 XFF,gin 默认信任全网段故ClientIP()取 peer IP——新增上界逻辑不得改变这条路径)。 - 把 spike 的 15000-IP 场景改写成断言(
len(hits) <= maxEntries)而非日志——这是缺陷 B 的回归钉桩。该用例须用maxEntries注入的小值跑(否则真造 15000 条会拖慢套件);另保留一条用NewRateLimiter的断言,钉住生产构造确实用maxTrackedIPs作上界。
T2(server)信任代理链解析
internal/api/router_test.go 扩展 + 新 internal/api/clientip_test.go:
Deps.TrustedProxies为空 →NewRouter返回的引擎对「peer 在容器网段 + XFF 带真实 IP」的请求,ClientIP()返回 peer IP(XFF 整体忽略——这正是 D-A′ 的安全默认,也是 compose 本机演练的实际形态)。Deps.TrustedProxies = ["172.28.0.0/24"]→ 同样请求返回 XFF 中的真实 IP(真实 prod 形态:nginx 直接见真实客户端并追加,信任 nginx 后能取到真实 IP)。- 伪造抗性(把 spike 五个场景变成断言):客户端预置
X-Forwarded-For: 8.8.8.8, <真实IP>/ 多跳伪造 / 伪造信任网段内 IP / 双层代理——全部返回最右侧不可信跳,不得返回客户端可控的前缀值。 - 启动告警:
TrustedProxies为空时NewRouter产生一条slog.Warn(用bytes.Buffer+slog.New(slog.NewJSONHandler(...))捕获,仿observability/logging_test.go:23既有范式),断言消息含trusted proxies;非空时不产生该告警。告警断言只调NewRouter、不发请求,避免RequestLogger噪声混入捕获 buffer。 - all-trusted 边界钉桩(
spike_alltrusted_test.go.evidence):XFF 全为信任跳时 gin 返回最左条目(dev 拓扑的网关 IP)——作为已知可接受行为钉桩(spec D-F),测试注释说明缘由。 - 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:
LOG_LEVEL=INFO、LOG_FORMAT=" JSON "→ 成功,cfg.LogLevel == "info"、cfg.LogFormat == "json"。LOG_LEVEL=" warn "(两侧空格)→ 成功归一为warn。LOG_LEVEL=bogus→ 仍报错(既有断言保留)。- 空值 → 仍取默认(既有断言保留)。
- 新增
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′ 修正后)
docker compose --profile dev|prod|debug|mock config -q四个 profile 全过(AGENTS.md 要求)。- 断言
config输出中api-prod/api-dev的TRUSTED_PROXIES默认为空(未设.env时),且输出无 subnet 固定(顶层networks键不存在,config | grep subnet为空);另断言TRUSTED_PROXIES=10.0.0.0/8覆盖时跟随(管线仍通)。 - 卷名不变核查:
config输出的volumes键集合与改动前逐字相同(数据身份红线)。 nginx -t校验模板渲染结果(envsubst+docker run nginx:1.27-alpine nginx -t)。- 起 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.ymlcompose 腿。 - 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()。