34 KiB
pkgs/all(全量聚合宿主)代码审计报告
| 项 | 内容 |
|---|---|
| 审计对象 | pkgs/all(module 路径 bsm/full/pkgs/all,全量聚合宿主) |
| 所属域 | 聚合运行入口(组合基础与平台 10 个 + 电商交易 4 个 + 资金支付 1 个,共 15 个服务) |
| 审计日期 | 2026-09-22 |
| 代码规模 | Go 文件 27 个 / 1331 行(其中 _test.go 4 个 / 238 行);etc/default_dev.yaml 145 行;go.mod 148 行 |
| 监听端口 | gRPC 0.0.0.0:12000、HTTP 0.0.0.0:12001(pkgs/all/etc/default_dev.yaml:3-8) |
| 结论摘要 | 分层清晰、分流实现可读,但上线路径缺失(无 prod 配置、构建脚本不覆盖聚合入口)、两个密钥默认值可直接启动(JWT 签名密钥与 session 密钥均为占位串)且入口不做校验、grpc-gateway 的 404 与业务 NotFound 无法区分导致真实错误响应被 Gin 的 404 覆盖、gRPC reflection 无开关且匿名可达、全链路无 TLS、进程级客户端 IP 可被 X-Forwarded-For 伪造。另有匿名白名单与模块自身声明口径不一致、缺注册表测试护栏等一致性问题。 |
1. 定位与职责
pkgs/all 是"全量聚合宿主":在一个进程内启动 15 个业务模块的 gRPC 服务、grpc-gateway 路由和原生 REST 路由,进程内注入一份共享的数据库、Redis、etcd 与内存缓存,并统一管理监听地址、鉴权和优雅关闭。
它不承载任何业务逻辑:所有 internal/service/*.go 只做 ExposeOptions 组装与注入(pkgs/all/internal/service/ads.go:9-20 等 15 个文件,每个 14-22 行),路由与落库全部在 module/ 各模块内。它也不负责建表与种子数据:聚合路径从不调用模块的 impl.NewImpl(),只调用模块 service.applyDependencies(module/ec/order/service/dependencies.go:19-29)。
与 pkgs/ecmall 的关系:两者是同一份代码的两份拷贝,internal/server 下 4 个文件逐字节相同(见 6.3-C5),注册表仅差 cloud 一项。
2. 代码结构与关键文件
| 路径 | 行数 | 职责 |
|---|---|---|
cmd/main/main.go |
58 | 进程入口:--workspace 解析 → config.New → impl.NewImpl → server.New → service.Expose → 注册信号 → srv.Start |
internal/config/config.go |
124 | 聚合配置结构(Server/Authorization/Databases/Services/5 个模块专项节)、监听地址规范化与校验、共享配置回填、Enabled 服务开关 |
internal/server/server.go |
133 | gRPC server(挂 unary 鉴权拦截器 + reflection)、grpc-gateway mux、gin engine、/rpc/:module/:service/:method 动态路由、404 兜底分流、*http.Server 参数、Stop |
internal/server/authorization.go |
126 | JWT 签发校验(HS256)、HTTP 中间件、gRPC unary 拦截器、匿名白名单匹配、/rpc/... → /{pkg}.{Svc}/{M} 路径规范化 |
internal/server/dynamic.go |
194 | 动态 RPC:通过 gRPC reflection 取 descriptor → dynamicpb + protojson → 内网 gRPC Invoke,方法描述符缓存、4 MiB 请求体上限、头部白名单透传 |
internal/server/response.go |
78 | 统一错误体 {code,message,details,timeseq}、gRPC code → SDK errcode 映射 |
internal/impl/impl.go |
42 | 进程级共享资源:Memory、Redis、DB、Etcd |
internal/service/service.go |
46 | 注册表(15 项)+ session 中间件 + 按 Enabled 逐个 Expose |
internal/service/{ads,cloud,cms,feedback,fts,initial,logs,mgt,passport,sender,address,mall,market,order,wallet}.go |
14-22 | 各模块注入:Dependencies + (GRPC/Gateway 或 Engine)+(可选 Config) |
etc/default_dev.yaml |
145 | 唯一一份配置:端口、DB/Redis DSN、SecretKey、Authorization(含匿名白名单)、Services、5 个模块专项节 |
internal/server/authorization_test.go |
102 | 匿名/裸 JWT/Bearer 拒绝、iat 超期拒绝 |
internal/server/dynamic_test.go |
76 | 动态 RPC 调 grpc.health.v1.Health/Check、头部透传过滤 |
internal/server/server_test.go |
24 | gin 路由可用性 |
internal/config/config_test.go |
36 | dev 配置非空 + gRPC/HTTP 端口不同 + 密钥长度 + 5 个专项节存在 |
go.mod |
148 | 15 个 module replace 到 ../../module/...,bsm-sdk/core replace 到 ../../../../bsm-sdk/core |
注册表(internal/service/service.go:14-33,15 项,顺序即注册顺序):ads, cloud, cms, feedback, fts, initial, logs, mgt, passport, sender, address, mall, market, order, wallet。与 etc/default_dev.yaml:46-61 的 Services 列表逐项一致(已核对)。
3. 启动流程与请求分流
启动顺序(cmd/main/main.go:22-57):
flag解析--workspace(默认default),经utils.MustString按^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$校验并转小写(SDKutils/ext.go:39-46)。config.New(ServiceKey):conf.New读<Prefix>/etc/<key>_<mode>.yaml→ 归一化两个监听地址 → 校验两地址不同、Authorization.Key非空且长度 ∈ {16,24,32}、Expire>0→ 写env.JwtSecretKey与coreVars.JwtExpire→ 回填共享配置(pkgs/all/internal/config/config.go:56-83)。impl.NewImpl():Memory → Redis → DB → Etcd(internal/impl/impl.go:30-42)。注意 DB 缺失时 SDK 直接panic(见 6.3-C3)。allserver.New(...):建 gRPC server(无条件reflection.Register)+ gin engine(gin.New()+ Logger + Recovery)。service.Expose(srv):先挂 session 中间件,再按注册表 +Enabled逐个注册;任一模块Expose出错则整体panic(internal/service/service.go:35-46、cmd/main/main.go:42-44)。srv.Start(grpcAddr, httpAddr):先监听两个 TCP,再建动态网关(失败则关闭两个 listener),挂/rpc/:module/:service/:method,组装 404 兜底 handler,起两个 goroutine 并阻塞在第一个返回值上。
请求分流(internal/server/server.go:61-78 + authorization.go:96-120):
flowchart TD
C["客户端请求"] --> A["httpMiddleware:canonicalHTTPPath + isAnonymous<br/>authorization.go:55-66"]
A -->|"匿名 或 JWT 有效"| G["grpc-gateway mux ServeHTTP(响应写入 bufferedResponse)<br/>server.go:63-65"]
A -->|"鉴权失败"| E1["HTTP 200 + {code,message,details,timeseq}<br/>authorization.go:122-126"]
G -->|"status != 404"| F1["flush 缓冲响应给客户端<br/>server.go:66-68"]
G -->|"status == 404"| H["gin ServeHTTP 兜底<br/>server.go:70"]
H --> R1["POST /rpc/:module/:service/:method<br/>server.go:61"]
R1 --> D["resolveMethod:gRPC reflection 取 descriptor(带缓存)<br/>dynamic.go:115-176"]
D --> D1["streaming → 直接报 Unimplemented<br/>dynamic.go:79-82"]
D --> D2["protojson 解 JSON(≤4 MiB)→ 内网 gRPC Invoke(insecure)<br/>dynamic.go:84-112"]
H --> R2["/rest/fts/... /rest/logs/... /rest/mgt/...(各模块自注册)<br/>service/{fts,logs,mgt}.go → module 内层 JwtAuth"]
H -->|"无路由"| N["gin 默认 404(纯文本)"]
D2 --> E2["内网 gRPC 再次经过 unary 拦截器校验 JWT<br/>authorization.go:42-53"]
四条入口的能力边界:
| 入口 | 形态 | 覆盖范围 | 鉴权节点 |
|---|---|---|---|
| 原生 gRPC | /{pkg}.{Svc}/{M} |
12 个 gRPC 模块(fts/logs/mgt 无 gRPC 面) | gRPC unary 拦截器(server.go:32) |
| grpc-gateway | POST /{pkg}.{Svc}/{M} |
同 12 个模块 | HTTP 中间件 + 内网 unary 拦截器(双重) |
| 动态 RPC | POST /rpc/{pkg}/{Svc}/{M} |
同 12 个模块,仅 unary | HTTP 中间件(按网关形态匹配白名单)+ 内网 unary 拦截器 |
| 原生 REST | /rest/{fts,logs,mgt}/... |
仅这 3 个模块 | HTTP 中间件 + 模块内层 JwtAuth(仅 /rest/fts/uploader、/rest/mgt/{user,app,role,pmn,dpt}/*) |
4. 配置与安全
| 项 | 现状 | 证据 |
|---|---|---|
| 监听地址 | gRPC 12000 / HTTP 12001,BindIP 0.0.0.0;两地址相同则 panic |
etc/default_dev.yaml:3-8;config.go:60-62 |
| 端口缺省 | Port 为空时随机分配 1024-65535,且"两地址不同"校验必然通过 |
SDK conf/new.go:87-97;config.go:85-89 |
| JWT | HS256,裸 JWT 放 Authorization 头(无 Bearer);校验算法、exp、iat 不为未来、now-iat ≤ Expire |
authorization.go:68-94;authorization_test.go:41 |
| JWT 密钥 | Authorization.Key 必须 16/24/32 字节;写入全局 env.JwtSecretKey,模块签发处共用 |
config.go:63-73 |
| 默认密钥 | CHANGE_ME_32_BYTE_JWT_SECRET_KEY(恰好 32 字节 → 通过校验) |
etc/default_dev.yaml:22;config.go:66-69 |
| session 密钥 | config.Spec.SecretKey(= conf.Base.SecretKey)拼 "-session" 作为 cookie store 的 HMAC key;无任何校验 |
internal/service/service.go:36;SDK conf/types.go:11;config.go:63-72 未校验 |
| 默认 session 密钥 | CHANGE_ME |
etc/default_dev.yaml:17 |
| 鉴权中间件覆盖 | HTTP:单一中间件包住"gateway + gin"整个 handler,因此 /rpc/... 与 /rest/... 全部在覆盖内;gRPC:仅 grpc.UnaryInterceptor(无 Stream) |
server.go:32,74;authorization.go:55-66 |
| 匿名白名单 | 11 个 gRPC 网关形态 + 8 个 REST 路径,共 19 条;/grpc.reflection.、/grpc.health. 前缀硬编码放行 |
etc/default_dev.yaml:24-43;authorization.go:96-103 |
| gRPC reflection | 无条件注册、无开关;且被 isAnonymous 放行为匿名 |
server.go:33;authorization.go:98-100 |
| TLS | 全模块无 TLS:net.Listen("tcp", ...)、内网 insecure.NewCredentials();grep -i "tls|certificate|x509" pkgs 无命中 |
server.go:45,49;dynamic.go:45 |
| HTTP server 参数 | ReadHeaderTimeout=10s、IdleTimeout=120s、MaxHeaderBytes=1MiB、无 ReadTimeout/WriteTimeout |
server.go:72-78 |
| gRPC server 参数 | 无 MaxRecvMsgSize、无 keepalive、无 StreamInterceptor | server.go:32 |
| 客户端 IP | gin.New() 未 SetTrustedProxies(gin v1.12.0 默认信任 0.0.0.0/0、::/0) |
server.go:34;gin gin.go:225,214 |
| 限流 | 入口层无任何限流/防爆破逻辑 | grep -i "ratelimit|limiter|throttle" pkgs 无命中 |
| 白名单一致性 | 与模块自身声明不一致(logs 三条 REST、ads.Fetch/ByPos) | 见 6.1-A7 |
| 服务开关 | BSM_SERVICES(非空即覆盖 Services)> Services;名字大小写不敏感、含 all 即全开 |
config.go:109-124 |
| 运行模式 | gin 未按 BSM_RuntimeMode 设置模式 |
server.go:34;SDK middleware/mode.go:9-16 |
| CORS | 聚合未启用;独立 fts/mgt 启用(SDK 实现为 AllowAllOrigins: true) |
server.go:34;module/base/fts/cmd/main/main.go:26、module/base/mgt/cmd/main/main.go:39;SDK middleware/cors.go:8-18 |
匿名白名单逐条核对结果:11 条 gRPC 条目全部对应真实方法(passport 的 Login.{Pwd,Code,Quick}、Register.{Pwd,Code}、Forget.{Verify,Reset}、Verify.{Request,JumioCallback} 见 module/base/passport/proto/*.proto;mall.Staff/Login 见 module/ec/mall/proto/staff.proto:9;market.Agency/Login 见 module/ec/market/proto/agency.proto:9),8 条 REST 条目也全部对应已注册路由(module/base/fts/internal/routers/register.go:22-23、module/base/logs/internal/routers/register.go:24、module/base/mgt/internal/routers/register.go:31-36)。即无死条目;问题在"该放行的没放行"(见 6.1-A7)。
5. 审计发现
5.1 安全
| 级别 | 位置 | 问题 |
|---|---|---|
| 高 | etc/default_dev.yaml:17 + internal/service/service.go:36 |
session 密钥用未校验的占位值。srv.HTTP.Use(sessions.Sessions("mysession", cookie.NewStore([]byte(config.Spec.SecretKey+"-session")))),SecretKey 取自 conf.Base(SDK conf/types.go:11),配置值为 CHANGE_ME;config.New 只校验 Authorization.Key(config.go:63-72),从不校验 SecretKey。默认配置下 cookie 的 HMAC 密钥是公开可猜字符串,mysession 可被伪造(当前唯一使用点是 module/base/mgt/internal/logic/hello/ping.go:25-35 的 SessionDemo 演示)。 |
| 高 | etc/default_dev.yaml:22 + internal/config/config.go:66-69 |
JWT 签名密钥默认值是可用的公开字面量。CHANGE_ME_32_BYTE_JWT_SECRET_KEY 恰好 32 字节,直接通过长度校验并写入 env.NewEnv().JwtSecretKey(config.go:73)。任何知道该字面量的人都能自行 HS256 签发带 iat/exp 的 token,通过 authorization.go:74-79 的校验。模块的 token 也由同一密钥签发(module/base/mgt/internal/logic/pub/login.go:90、module/base/passport/internal/logic/common/token.go:10),无法通过"只改某一侧"缓解。 |
| 高 | pkgs/all/etc(目录仅 1 个文件) |
不存在生产配置。conf.New 读 <Prefix>/etc/default_<mode>.yaml(SDK conf/new.go:35-36),BSM_RuntimeMode 默认 dev(SDK env/env.go:20);设为 prod 时读 default_prod.yaml,缺失即回退 workspace_default_prod.yaml(new.go:39-43),两者都不存在 → log.Fatalf(new.go:49-52)。即 BSM_RuntimeMode=prod 的聚合进程无法启动,只能以 dev 模式上线(偏离 README 的"评估关闭 reflection、入口层启用 TLS"前提)。 |
| 中 | internal/server/server.go:33 + authorization.go:96-100 |
gRPC reflection 无开关且匿名可达。reflection.Register(grpcServer) 无条件执行,isAnonymous 对 /grpc.reflection. 前缀直接返回 true,因此匿名用户可拉取全部已注册服务的 FileDescriptorProto,枚举 35 个服务/方法名与完整消息结构(dynamic.go:124-143 本身就走这条路径)。这不泄露数据,但等于把内部 API 全貌公开,便于后续针对性构造调用。 |
| 中 | internal/server/authorization.go:98 |
/grpc.health. 被硬编码为匿名,但全仓库没有任何模块注册 health 服务(grep -rn "health" module 仅命中注释与 wallet/logic/payment/hello.go 的字符串)。当前是死名单;一旦按惯例补上 health 服务,它会先天地对匿名开放。 |
| 中 | internal/server/server.go:45,49 + dynamic.go:45 |
全链路明文。两个 listener 都是 net.Listen("tcp", ...),无 TLSConfig;动态网关到本机 gRPC 用 insecure.NewCredentials();pkgs 下 grep -i "tls|certificate|x509" 无任何命中。JWT 与业务数据以明文在 0.0.0.0:12000/12001 上传输,反向代理是否终止 TLS 属于部署前提,进程内无任何能力。 |
| 中 | internal/server/server.go:34、dynamic.go:180-188 |
进程级客户端 IP 可被伪造。聚合用 gin.New() 且未调用 SetTrustedProxies,gin v1.12.0 默认 trustedProxies = {"0.0.0.0/0","::/0"} 且 ForwardedByClientIP=true(gin gin.go:225,214),因此 c.ClientIP() 直接采用客户端自报的 X-Forwarded-For;动态 RPC 又把请求里所有 x- 前缀头原样透传给内网 gRPC(dynamic.go:182-188),grpc-gateway 也会把请求 X-Forwarded-For 拼进 metadata(gateway runtime/context.go:185-193)。受影响落点示例:登录 token 里记录的 client IP(module/base/mgt/internal/logic/pub/login.go:90)。 |
| 中 | internal/server/authorization.go:55-66、server.go:35 |
匿名登录入口无任何限流。白名单里的 `passport.Login/Pwd |
| 中 | etc/default_dev.yaml:24-43 对比 module/base/logs/internal/routers/register.go:18-29、module/base/ads/etc/ads_dev.yaml:15-16 |
匿名口径不一致(双向)。① logs 的 `POST /rest/logs/create |
| 低 | internal/server/authorization.go:96-120 |
白名单为精确字符串匹配,且 gRPC 网关形态与 REST 真实路径混列在同一 []string 里;/rpc/... 只在"恰好 4 段"时才被改写成网关形态(canonicalHTTPPath)。新增方法/路由若忘记登记,只会静默变成"需要 JWT",没有任何一致性校验或启动期告警。 |
| 低 | internal/server/dynamic.go:115-176 |
动态 RPC 的 descriptor 缓存键由请求路径拼接(moduleName + "." + serviceShortName,dynamic.go:73),未命中时每次都向内网反射服务发起一次 RPC;缓存无上限、无 TTL。任意匿名调用者可用随机符号名制造大量反射查询(放大内网调用),且 protojson 解析错误信息原样回显(dynamic.go:95-98)。 |
5.2 正确性与逻辑缺陷
| 级别 | 位置 | 问题 |
|---|---|---|
| 高 | internal/server/server.go:63-71 |
gateway 的 404 与业务 NotFound 无法区分,真实响应被丢弃。recorder.status != http.StatusNotFound 被当作"网关未命中路由"的判据;但 grpc-gateway 对业务 NotFound 也渲染成 HTTP 404(gateway runtime/errors.go:48-49),与路由未命中的 404(runtime/mux.go:464,552)状态码完全相同。因此任何 gateway 方法返回 codes.NotFound("记录不存在"这类高频业务分支)时,已经生成好的 {code,message,...} 响应被丢弃,请求落到 gin 后因无匹配路由,客户端最终收到 gin 的纯文本 404 page not found。 |
| 中 | internal/server/server.go:82-89 |
启动期错误只处理一半。errCh 容量 2、只读一个值就返回:当 HTTP Serve 先失败时,gRPC listener 与 server 既不 Stop 也不 Close(反之亦然);Start 无 Shutdown 兜底。 |
| 中 | cmd/main/main.go:48-53 + internal/server/server.go:92-110 |
优雅关闭的超时被复用且错误被丢弃。Stop(ctx) 内部先用同一个 ctx 等 GracefulStop,超时后 s.GRPC.Stop(),随后 s.http.Shutdown(ctx) 使用的仍是已经过期的 ctx → 立即返回 context deadline exceeded,在途 REST 请求被直接中断;外层 _ = srv.Stop(ctx) 又丢弃了返回值,失败完全无声。 |
| 中 | internal/server/server.go:112-133 |
全量缓冲响应。所有 grpc-gateway 响应先写入 bytes.Buffer(server.go:64),判定不是 404 后才整体 flush(server.go:125-132):响应体在内存中同时存在缓冲与写出两份,列表类接口(mall 商品、order 订单、cms 文章)在大响应/高并发下放大内存占用;flush 复制响应头用 Add 而非 Set(server.go:128);WriteHeader 无重复调用保护(server.go:123)。 |
| 中 | internal/server/server.go:32 |
流式 RPC 无鉴权拦截。鉴权只挂在 grpc.UnaryInterceptor,未注册 StreamInterceptor;isAnonymous 虽放行 /grpc.reflection.,但任何流式方法都不经过拦截器。当前 66 个 proto 文件、35 个服务均无 stream 声明(grep -n "stream " --include=*.proto module 无命中),因此尚无实际暴露;动态 RPC 侧已显式拒绝流式(dynamic.go:79-82),风险落在原生 gRPC 面。 |
| 中 | etc/default_dev.yaml:73 + module/base/fts/internal/logic/provider.go:71 |
配置里的下载地址没有对应路由。聚合配置把上传站点写成 http://127.0.0.1:12001/files,本地存储返回的地址是 Site + "/" + bucket + "/" + subdirpath + "/" + fileName;但 fts 只注册了 `/rest/fts/ping |
| 低 | internal/config/config_test.go:11-36 |
配置测试只做非空校验:校验 Services 非空、两端口不同、密钥长度、5 个专项节存在,但不校验 Services 与注册表一致;pkgs/all 也缺少 ecmall 那样的 internal/service/service_test.go(实测 go test ./pkgs/all/... 输出 internal/service [no test files])。改注册表(加/删服务)在 pkgs/all 没有测试护栏,只能靠人眼对齐 service.go:14-33 与 etc/default_dev.yaml:46-61。 |
| 低 | internal/config/config.go:109-124 + internal/service/service.go:35-46 |
服务列表写错会静默启动空宿主。Enabled 只做"名字匹配/含 all"判断,不校验名字是否存在;Expose 对 0 个服务注册不报错。若 BSM_SERVICES 或 Services 里全是无效名(例如把 mall 写成 mail),进程会正常监听 12000/12001,但除 reflection 外全部 404,且没有任何启动期提示。 |
| 低 | internal/config/config.go:85-89 |
端口漏配会随机绑定。conf.CheckPort 在 Port 为空时随机生成 1024-65535(SDK conf/new.go:90-97);聚合有两个 listener,Port 缺失时各自随机,"两地址必须不同"的校验必然通过 → 服务在随机端口"成功"启动,网关/探针配置全部失效,随机端口还可能恰好被占用而启动失败。 |
5.3 未完成/不一致
| 级别 | 位置 | 问题 |
|---|---|---|
| 高 | pkgs/all/etc/(仅 default_dev.yaml)+ scripts/build-all-linux.sh:7,30 |
聚合入口既没有 prod 配置也不被构建脚本覆盖:构建脚本 MODULE_ROOT="${WORKSPACE_ROOT}/module" 且用 find "${MODULE_ROOT}" 收集模块,pkgs/ 完全不在范围内,pkgs/* 也不会被复制 *_prod.yaml。结合 5.1 的"无 prod 配置",聚合入口当前只有"开发模式 + 手工 go build"这一条路径。 |
| 中 | internal/config/config.go:78 + SDK with/databases.go:13-15 |
Databases 未纳入配置校验。conf.NotNil(Spec.Service, Spec.Cache) 只校验两项;Databases 缺失时 impl.NewImpl()(impl.go:38)内部 panic("No Database Source Found !") —— 崩溃点比配置校验更晚、信息更差;且 impl.NewImpl() 在 server.New/Expose 之前执行(cmd/main/main.go:32-44),此时 Redis/Etcd 已建连,失败回滚缺失(无 defer Close)。 |
| 中 | module/base/passport/internal/logic/verify/jumio_callback.go:13-34 |
被匿名放开的第三方回调是空壳。/passport.Verify/JumioCallback 在聚合白名单里(etc/default_dev.yaml:33),实现只 printer.Info 后返回成功,代码内自带 TODO:「In production, implement proper callback handling logic」,且不校验回调签名。当前无实际危害(不写库),但补实现时必须先加签名校验,否则任何人可直接伪造 KYC 结果。 |
| 低 | pkgs/all vs pkgs/ecmall |
注册表护栏不对等:ecmall 有 internal/service/service_test.go(断言各模块 gRPC/REST 面注册成功)与 config_test.go:15 的 ecmallServices 一致性断言,pkgs/all 两者都没有(只有 5.2 提到的非空校验)。 |
| 低 | internal/server/{server,authorization,dynamic,response}.go |
与 ecmall 的两份拷贝必须手工同步:diff 结果这 4 个文件逐字节相同(唯一差异是 server.go:79-80 的日志字面量 all/ecmall,ecmall);internal/impl/impl.go 仅 import 路径不同;internal/config/config.go 仅注释不同;注册表仅差一行(pkgs/ecmall/internal/service/service.go 无 {"cloud", exposeCloud})。任何单侧修改都会让两个入口的鉴权/分流行为产生差异,目前没有测试或 CI 阻止这种漂移。 |
| 低 | 无 | 聚合入口没有进程级健康检查端点。独立入口都注册了 app.HEAD("/", infra.Health)(module/base/fts/cmd/main/main.go:36、module/base/logs/cmd/main/main.go:39、module/base/mgt/cmd/main/main.go:41),聚合只有三个模块自带的 /rest/*/ping,HEAD / 会落到 gin 404。 |
5.4 健壮性与可维护性
| 级别 | 位置 | 问题 |
|---|---|---|
| 中 | internal/server/server.go:72-78 |
未设置 WriteTimeout(也未设 ReadTimeout),只有 ReadHeaderTimeout/IdleTimeout/MaxHeaderBytes;慢速读取响应可长期占用连接。同时 gin 侧没有请求体大小限制(/rest/fts/uploader 的上限只在 fts 逻辑层),而动态 RPC 侧有 4 MiB 限制(dynamic.go:30,84-92)——两条入口的边界不对称。 |
| 低 | internal/server/server.go:34 |
未按 BSM_RuntimeMode 设置 gin 模式(独立入口会调用 middleware.Mode,SDK middleware/mode.go:9-16)→ 聚合在生产环境仍停留在 gin Debug 模式(除非外部设置 GIN_MODE)。 |
| 低 | internal/server/server.go:34 |
聚合未启用 CORS,而独立 fts/mgt 启用(SDK 实现是 AllowAllOrigins: true)→ 同一接口"独立进程可被浏览器调用、聚合入口不可用"的行为差异。 |
| 低 | internal/server/server.go:79-80 |
用 fmt.Printf 直接打印监听地址,绕过 SDK 的 printer;gin.Logger() 无条件记录全部请求(server.go:35),探针/大流量下噪音明显。 |
| 低 | internal/server/server.go:112-133 |
bufferedResponse 只实现 Header/WriteHeader/Write,没有 http.Flusher/Hijacker/CloseNotifier;当前 unary JSON 路径可用,但任何流式/大文件/SSE 型网关响应都会静默退化。 |
| 低 | internal/server/dynamic.go:34-36 |
方法描述符缓存是 map + sync.RWMutex,无容量上限、无淘汰;键取自请求路径,长期运行下键空间取决于外部输入。 |
| 低 | internal/server/authorization.go:26-39 |
匿名白名单在 newAuthorization 中经 normalizePath 归一化后装入 map,重复项静默去重;条目写错(如把 /rest/logs/ping 写成 rest/logs/ping)不会被发现——normalizePath 会补上前导 / 掩盖这类笔误,但写成 /rest/logs/ping/ 之类则会被静默视为不同路径。 |
6. 风险汇总
| 编号 | 级别 | 问题 | 影响面 |
|---|---|---|---|
| A1 | 高 | 生产/测试配置缺失,构建脚本不覆盖 pkgs/* |
上线路径、部署可靠性 |
| A2 | 高 | session 密钥取未校验的 SecretKey(默认 CHANGE_ME) |
cookie 完整性与伪造 |
| A3 | 高 | JWT 密钥默认值是可直接使用的公开字面量 | 全系统身份伪造 |
| A4 | 高 | gateway 404 与业务 NotFound 不可区分,真实响应被覆盖 |
所有 gateway 方法的错误语义 |
| A5 | 中 | reflection 无开关且匿名可达 | 内部 API 全貌泄露 |
| A6 | 中 | 无 TLS | 传输机密性 |
| A7 | 中 | 客户端 IP 可经 XFF 伪造并透传到内网 metadata | 审计与风控数据可信性 |
| A8 | 中 | 匿名登录入口无任何限流 | 账号安全 |
| A9 | 中 | 匿名口径与模块声明不一致(logs 三条 REST、ads.Fetch/ByPos) | 独立/聚合行为不一致、上线 401 |
| A10 | 中 | 流式 RPC 不受鉴权拦截器覆盖(当前无流式方法) | 未来扩接口时的越权风险 |
| A11 | 中 | Fts 本地存储返回的 /files 地址无对应路由 |
文件下载不可用 |
| A12 | 中 | 优雅关闭超时后 HTTP 立即中断且错误被丢弃 | 关闭期请求可靠性 |
| A13 | 中 | gateway 响应全量缓冲 | 大响应下的内存放大 |
| A14 | 中 | Databases 未校验 + 资源初始化无失败回滚 |
启动期崩溃与连接泄漏 |
| A15 | 中 | Jumio 回调空壳(无签名校验)却已匿名放开 | 补实现后的数据伪造风险 |
| A16 | 低 | 无注册表一致性测试与告警、服务名写错静默空宿主 | 变更回归 |
| A17 | 低 | 端口漏配随机绑定、无 WriteTimeout、gin 模式/CORS 与独立入口不一致、无健康端点、两份拷贝需手工同步 | 运维与可维护性 |
7. 修复建议(务实项)
- 补生产配置与构建覆盖(A1):在
pkgs/all/etc/增加default_prod.yaml(至少替换全部CHANGE_ME、明确BindIP),并把scripts/build-all-linux.sh的MODULE_ROOT扩展到pkgs/(或额外的PKGS_ROOT循环),同时补pkgs/all/etc/default_prod.yaml的拷贝分支。 - 让占位密钥不能启动(A2、A3):在
internal/config/config.go现有的Authorization.Key校验旁,加两条最小校验——Authorization.Key不得等于样例字面量、SecretKey非空且不得等于CHANGE_ME(或直接要求 32 字节)。这是 4 行代码级别的改动,不要引入配置加密框架。 - 修 404 分流(A4):让 gateway 用独立标记区分"路由未命中"。最小做法是自定义
gwRuntime.ServeMux的WithRoutingErrorHandler(或在bufferedResponse上记录"是否由DefaultRoutingErrorHandler写出"),只在codes.NotFound且 路由未命中时才回退 gin;其余情况直接flush。 - 关掉或收紧 reflection(A5):给
Server增加一个Authorization.Reflection bool(默认 false),仅在为 true 时reflection.Register;同时从isAnonymous中删除/grpc.health.前缀,等真有 health 服务时再显式加白名单。注意动态 RPC 依赖反射,关闭前需确认该入口是否保留——若保留,则应改为"仅允许 loopback 调用动态入口"。 - 客户端 IP 与头部清理(A7):在
server.New里engine.SetTrustedProxies(...)指定真实代理网段(无代理时传nil关闭 XFF 取值),并在outgoingMetadata中不要透传客户端自带的x-forwarded-for/x-real-ip(改为服务端重算)。 - 匿名白名单口径对齐(A9):二选一后同步两处——若要 logs 的
create/fetch/total在聚合下也匿名,则加入Authorization.Anonymous;若不打算匿名,则把module/base/logs/internal/routers/register.go:18-29的"匿名组"改名并补上模块自身 JwtAuth。ads.Fetch/ByPos同理(改配置即可,零代码)。 - 文件下载路由(A11):要么在 fts 的路由注册里补上静态目录(本地存储方式),要么把
Fts.Local.Site改成真实存在的下载前缀,二者取其一,避免返回不可访问的 URL。 - 启动与关闭的收尾(A12、A14、A17):
Start里任一Serve返回错误时关闭两个 listener 并Shutdown;Stop里给http.Shutdown单独一个未过期的短超时 ctx,并把main.go:52的错误打印出来;config.New增加Databases非空校验;Port为空时改为报错退出而不是随机端口。 - 入口限流(A8):仅在
httpMiddleware内对匿名路径加一个基于内存的简单计数/令牌桶(按路径 + 客户端 IP),不引入外部依赖;不要为它建框架。 - 加固两份拷贝与测试护栏(A16):给
pkgs/all补一个与pkgs/ecmall/internal/service/service_test.go等价的注册表测试,并在internal/config/config_test.go中断言Services与注册表逐项一致;两份internal/server拷贝的同步问题,用"两处测试用例内容一致"来兜(不加抽取层)。 - 补齐入口可观测与一致性(A13、A17 的其余项):
http.Server补WriteTimeout;engine.SetMode按BSM_RuntimeMode设置;注册HEAD /健康端点;如需 CORS 再显式启用(当前缺失本身不算缺陷,但需与独立入口行为对齐并明确决策)。 - 流式方法的前置约束(A10):在
grpc.NewServer处补grpc.ChainStreamInterceptor,与 unary 共用同一份validate;若短期内不打算支持流式,也在代码中写明"新增流式方法前必须先加拦截器"。
本报告只列与现有实现直接相关的修复项,不引入新的分层或抽象封装。审计中"把两个聚合入口的 server 抽成公共包""给动态 RPC 引入请求 DTO 层""用服务网格解决 mTLS"一类改造不在此列。
8. 整改记录(2026-09-22)
本节记录按本报告结论执行的代码整改。整改遵循最小修正原则:未引入新框架、抽象层、DTO/VO、事件总线,未拆分服务边界,未修改任何
proto/*.proto与生成的pb/*.go;新增/修改注释均为中文;口令类摘要统一使用 bcrypt(验证码等短时效一次性凭证仍按原有 Redis 明文比对链路存储)。校验方式:GOWORK=off go build ./...+GOWORK=off go vet ./...+gofmt -l(仓库根 workspace 模式存在 genproto 拆包的 ambiguous import,属本机既有问题)。
| 编号 | 级别 | 问题 | 处理结果 |
|---|---|---|---|
| A1 | 高 | 生产/测试配置缺失,构建脚本不覆盖 pkgs/* |
已修复:新增 etc/default_prod.yaml、etc/default_test.yaml(字段名对齐 SDK 规范,敏感值用 ${ENV} 占位);scripts/build-all-linux.sh 的扫描根从仅 module/ 扩展为 module/ + pkgs/,并去掉固定 BSM_RuntimeMode=dev |
| A2 | 高 | session 密钥取未校验的 SecretKey(默认 CHANGE_ME) |
已修复:internal/config 增加非空与非公开默认值校验,缺失/为 CHANGE_ME 时启动失败并给出中文提示 |
| A3 | 高 | JWT 密钥默认值是可直接使用的公开字面量 | 已修复:在 internal/config 显式校验并拒绝 SDK 侧公开默认密钥(Cblocksmesh2022C),未通过环境变量提供合法密钥时启动失败(未改 SDK) |
| A4 | 高 | gateway 404 与业务 NotFound 不可区分,真实响应被覆盖 |
已修复:改用 grpc-gateway 的 WithRoutingErrorHandler 标记「路由未命中」(recorder.markRoutingMiss()),仅在这种情况下才回退 Gin 的纯文本 404;业务返回的 NotFound 由 gwRuntime.DefaultRoutingErrorHandler 原样透出 |
未纳入本轮范围
报告中「中」「低」级别的项(分页上限、死代码、README 与实现不符、单测缺失、可维护性等)本轮未处理;如需继续,按各报告第 8 节「修复建议」的顺序推进即可。
本轮整改未修改任何
proto/*.proto与pb/*.go,因此少数需要新增接口字段才能完整实现的项目(已在处理结果中标注)做了安全降级。