Files
crearte-monorepo/docs/specs/2026-10-02-p11-server-hardening-design.md
T
XingfenD 708f95eb3d 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.
2026-10-02 03:54:41 +08:00

28 KiB
Raw Blame History

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)

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 挡时序侧信道;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()。