Files
full/docs/audit/module-base-sender.md
yanweidong 63aeedc2fe docs: add per-module audit reports (18 modules)
Add static security/quality audit reports for all 18 Go service modules under module/, plus a consolidated index (docs/audit/README.md) with per-module statistics, top risks, cross-module systemic defects and a phased TODO list (T1-T19). No production code is modified.
2026-09-14 22:16:27 +08:00

62 KiB
Raw Blame History

审计报告module/base/sender

1. 模块概览

module/base/sender(绝对路径 D:\work\bsm-infra\full\module\base\sender)是短信/邮件/验证码微服务gRPC + grpc-gateway 双栈输出,业务面只有 3 个接口:

服务 方法 proto 定义 HTTP 路径(生成代码实际值)
sender.Sms Send proto/sms.proto:7 POST /sender.Sms/Send
sender.Sms Verify proto/sms.proto:8 POST /sender.Sms/Verify
sender.Mail Send proto/mail.proto:7 POST /sender.Mail/Send

请求体完全由调用方控制:provider / sign_name / template_code / phone / is_gen_code / paramtersproto/sms.proto:12-19)、provider / template_key / to / is_gen_code / paramtersproto/mail.proto:11-17)。

运行时形态有两种:

  1. 独立进程cmd/main/main.goserver.New(nil) + SDK core/service.New(...),配置取 etc/sender_{dev,test,prod}.yamlgRPC 12208 / Gateway 12207
  2. 聚合进程pkgs/all 通过 moduleService.Expose(...) 挂载(pkgs/all/internal/service/sender.go:10-22),配置取 pkgs/all/etc/default_dev.yamlSender: 段(pkgs/all/etc/default_dev.yaml:98-118)。

关键依赖Redis验证码/黑名单/计数、PostgreSQL邮件模板 sender_template)、阿里云短信 SDKdysmsapi-20180501/v2)、腾讯云短信 SDK、net/smtp(邮件为手写 SMTP 会话,不走 SDK

2. 审计范围与方法

纳入范围(全部通读,共 39 个文件):

  • 非 pb Go 文件 18 个:cmd/cli/main.gocmd/main/main.gointernal/config/config.gointernal/excode/ex.gointernal/impl/{impl,provider}.gointernal/logic/mail/send.gointernal/logic/sms/{const,send,verify}.gointernal/models/sender_template.gointernal/server/{mail_server,new,sms_server}.goservice/{dependencies,expose}.gotest/grpc/{mail,sms}_test.go
  • pb 生成代码 7 个(只核 HTTP 路由与字段语义,不逐行审):pb/*.pb.gopb/*.pb.gw.gopb/*_grpc.pb.go
  • 配置:etc/sender_{dev,test,prod}.yamletc/supervisor.bsm-apps-sender.confproto 3 个;README.mdUPGRADE_SUMMARY.md
  • 必要的外部证据链(只读):pkgs/all(聚合装配与鉴权中间件)、D:\work\bsm-sdk\core(该模块 go.mod:90replace 目标:confservicecache/rediswithdatabase、Go 标准库 net/mailgithub.com/alibabacloud-go/dara

方法read/grep/glob 静态通读 + 全仓库交叉引用检索(确认计数器、黑名单、验证码 key 的所有读写点)+ 静态检查:

  • gofmt -l .无输出exit 0(格式干净)。
  • go vet ./...无输出exit 0(无可疑构造告警;rand.Seed 未被告警)。
  • 未做动态验证:本机无 Redis/PG/短信账号,未实跑服务,也未执行集成测试(需 -tags integration 且指向真实线上地址,会发真实短信/邮件)。

未纳入api.apinb.com 网关、bsm-apps 网关侧鉴权实现(不在本工作区),因此网关对 etcd 匿名列表的实际执行效果标注为「推测」。

3. 问题清单

P0

1. 短信/邮件发送接口无任何鉴权与调用方归属校验,且配置把发送接口显式声明为匿名

  • 位置module/base/sender/internal/logic/sms/send.go:26-39module/base/sender/internal/logic/mail/send.go:22-39module/base/sender/etc/sender_prod.yaml:15-18module/base/sender/internal/server/new.go:23module/base/sender/cmd/main/main.go:29,40
  • 证据
// sms/send.go:26-31  —— 入参只有手机号校验,没有任何身份/权限/来源校验
func Send(ctx context.Context, in *pb.SmsSendRequest) (reply *pb.SmsReply, err error) {
	var smsCode string
	if in.GetPhone() == "" || !VerifyPhone(in.GetPhone()) {
		return nil, excode.ErrPhone
	}
// sms/send.go:114-118  —— 签名与模板完全由请求方指定,直接下发到阿里云
"SignName": tea.String(cast.ToString(args.SignName)),
"TemplateCode": tea.String(cast.ToString(args.TemplateCode)),
# etc/sender_prod.yaml:13-18  —— 三个接口被配置为匿名
MicroService:
  Enable: false
  Anonymous:
    - sender.Mail.Send
    - sender.Sms.Send
    - sender.Sms.Verify
// internal/server/new.go:21-23  —— 独立进程 gRPC 无任何 UnaryInterceptor
standalone := grpcServ == nil
if standalone {
	grpcServ = grpc.NewServer()
// internal/logic/mail/send.go:26-31  —— 邮件同样无鉴权,收件人与模板可选任意值
if in.GetTemplateKey() == "" || provider == "" || in.GetTo() == "" {
	return nil, errcode.ErrInvalidArgument
}
cfg, ok := config.Spec.SMTP[provider]
  • 影响
    • 独立部署gRPCgrpc.NewServer() 无拦截器)与 HTTP 网关均无鉴权代码,任何能连到 12207/12208 的人都可以 SignName 填平台签名、PhoneNumbers 填任意手机号、TemplateCode 填任意模板,由平台账号付费下发 → 短信轰炸、费用损失,并可用平台签名发钓鱼/诈骗短信(paramters 内容也完全可控,见 sms/send.go:107-109)。
    • 聚合部署pkgs/all/etc/default_dev.yaml:24-44Authorization.Anonymous 未包含 sender 三个方法,因此需要 JWT——但 pkgs/all/internal/server/authorization.go:42-53 只校验 token 有效性,不校验角色、租户,也不校验手机号是否属于该用户所以任意注册用户passport 注册即可拿到 JWT都能给任意手机号发短信/给任意邮箱发任意模板邮件。
    • etc 中 Anonymous: sender.Sms.Send/Verify/Mail.Send 表明部署意图本身就是不留鉴权;api.apinb.com 网关会读取该 etcd 匿名列表决定是否放行(推测:该网关实现不在本工作区,D:\work\bsm-sdk\core\service\register.go:95-121 只负责把列表写进 etcd。当前配置 Enable: falsesender_prod.yaml:14)表示连注册都不做。
  • 建议
    1. 模块内做兜底鉴权gRPC UnaryInterceptor + gateway middleware 校验调用方身份(服务间 mTLS/内部 tokenauthorization metadata + 业务侧校验手机号是否属于该 user_id
    2. MicroService.Anonymous移除 sender 三个方法;Anonymous 只保留真正公开的登录类接口。
    3. 禁止请求方自由指定 sign_name / template_code:改为内部枚举(模板白名单表),或由服务端根据业务场景(登录/注册/改密)决定签名与模板;paramters 只允许白名单 key + 长度/字符集校验。

2. 每日发送上限的计数器从未写入 —— 限额完全失效(无 TTL、非原子

  • 位置module/base/sender/internal/logic/sms/send.go:41-51module/base/sender/internal/logic/sms/const.go:7
  • 证据
// sms/send.go:42-51  —— 只读计数,全仓库无任何 INCR/SET 写入该 key
limitKey := LimitCacheKey + time.Now().Format(FormatDay) + in.GetPhone()
twice, err := impl.RedisService.Client.Get(impl.RedisService.Ctx, limitKey).Int()
if err != nil && !errors.Is(err, redis.Nil) {
	return nil, errcode.ErrRedis
}
if twice > config.Spec.Code.MaxSentLimit {
	return nil, excode.ErrSentLimit
}

全仓库检索确认该 key 只有这一个读取点(无写入、无 TTL、无 INCR

module\base\sender\internal\logic\sms\const.go:7: LimitCacheKey = "/SMS/LimitCacheKey/"
module\base\sender\internal\logic\sms\send.go:42: limitKey := LimitCacheKey + time.Now().Format(FormatDay) + in.GetPhone()
  • 影响Get 对不存在的 key 返回 redis.NilInt() 得到 00 > 5 恒为 false → Code.MaxSentLimitdev/prod/test 均为 5永远不会生效,同一手机号可被无限次发送;叠加问题 1无鉴权/任意用户可调用),攻击者可对任意号码做无限短信轰炸,直接产生费用。邮件侧连这段死代码都没有(mail/send.go 全文件无频控)。
  • 建议:改为原子自增 + 首次写入设置过期:
n, err := cli.Incr(ctx, limitKey).Result()
if n == 1 { cli.Expire(ctx, limitKey, 24*time.Hour) }
if n > max { ... }

并把「检查-自增-发送」改为「先占配额再发送发送失败回滚DECR」。同时补分钟级/小时级频控、同手机号 60s 重发间隔、IP/账号维度频控、图形验证码前置。

3. Sms.Verify 无尝试次数限制,且「一次性失效」逻辑写反(成功不删、失败才删)

  • 位置module/base/sender/internal/logic/sms/verify.go:12-39
  • 证据
// verify.go:27-35  —— 校验通过后直接返回,不删除 key不通过反而删除 key
if code == in.Code {
	return &pb.SmsReply{Reply: "true"}, nil
}
//verify pass ; delete the requestId
impl.RedisService.Client.Del(impl.RedisService.Ctx, key)
// verify.go:12-19  —— 除空值外无任何次数/频率限制,也无失败计数
if in.GetCode() == "" { return nil, excode.ErrCode }
if in.GetPhone() == "" { return nil, excode.ErrPhone }
  • 影响
    • 可爆破6 位验证码仅 10^6 组合,Verify 无失败计数、无锁定、无验证码图片,单个验证码有效期 Code.Expire=300setc/sender_prod.yaml:45),以 Redis GET 的速率完全可以在窗口内穷举 → 结合任意注册用户即可调用的现状(问题 1可用于账号接管该 key 与 mgt 忘记密码流程共用,见下)。
    • 可重放:成功后 key 不删除,同一验证码在 300s 内可被无限次重复使用(一次性语义缺失)。
    • 可被 DoS代码注释写「verify pass ; delete」实际在校验失败分支删除,任何知道手机号的人只要调一次 Verify 传错码,就能把该手机号的验证码从 Redis 中删除,使合法用户/其他业务(如 mgt 忘记密码)永远无法完成校验——且该 key 前缀是共享的:module/base/mgt/internal/types/vars.go:4module/base/sender/internal/logic/sms/const.go:5 同为 /SMS/Code/module/base/mgt/internal/logic/pub/forget.go:57-73 正是读这个 key 做密码重置。
  • 建议
    1. 成功即删(DEL 放在 code == in.Code 分支内),实现一次性语义;失败分支不要删 key。
    2. 增加失败计数(同一 phone 或同一 phone+code 维度),如 INCR /SMS/VerifyFail/<phone>,超过 5 次即删除验证码并锁定 10 分钟;计数器与验证码同 TTL。
    3. 校验用 GETDELredis.Client.GetDel)保证「校验+删除」原子,避免并发重放。
    4. 校验接口也纳入问题 1 的鉴权与频控,并对同一手机号做全局失败率限制。

4. 验证码使用 math/rand 且以时间戳播种,可预测

  • 位置module/base/sender/internal/logic/sms/send.go:161-176
  • 证据
// send.go:166-173
l := 10
numeric := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
rand.Seed(time.Now().UnixNano())
... fmt.Fprintf(&sb, "%d", numeric[rand.Intn(l)])
  • 影响math/rand 是可确定性伪随机源(非密码学安全),且用 time.Now().UnixNano() 播种使种子与「发送时刻」强相关。攻击者若能触发一次发送(问题 1 允许任意触发)并知道大致时间,就能在纳秒级候选窗口内穷举 rand.Seed 复现同一序列,直接算出验证码 → 验证码不再提供任何安全强度(尤其当验证码用于登录/改密时构成账号接管)。此外 rand.Seed 修改全局源,在并发请求下还会与其他使用全局 math/rand 的代码相互干扰。
  • 建议:改用 crypto/randcrypto/rand.Int(rand.Reader, big.NewInt(10)) 逐位生成,或一次性生成 [0,10^n) 均匀随机数并零填充;禁止使用 math/rand 的全局源,也禁止 time 相关播种。

P1

5. 腾讯云 TencentSender 是空实现,却返回 (nil, nil) 表示成功

  • 位置module/base/sender/internal/logic/sms/send.go:151-153(调用点 send.go:82-86
  • 证据
// send.go:151-153  —— 直接返回 nil,nil不发任何请求
func TencentSender(args *pb.SmsSendRequest) (map[string]any, error) {
	return nil, nil
}
// send.go:82-86  —— 只要 Provider.Tencent 非 nil 就会走空实现err==nil
case "tencent":
	if impl.Provider.Tencent == nil { return nil, excode.ErrProviderIsNil }
	result, err = TencentSender(in)
  • 影响impl.Provider.Tencentinternal/impl/provider.go:46-47 已真实初始化(NewTencent 建了 SDK 客户端),因此只要配置了 SMS.tencentprovider=tencent 的调用会返回成功Reply: "null",因为 json.Marshal(nil map)null),而短信根本没有发出:业务侧拿到成功却永远收不到验证码,且无任何日志/埋点暴露。同时 README:9 宣称「多服务商支持:阿里云短信、腾讯云短信」。精确空实现位置:internal/logic/sms/send.go:151-153
  • 建议:要么用 impl.Provider.Tencent 真正实现 SendSmssms/v20210111SendSms,把 SignName/TemplateCode/TemplateParamSet/PhoneNumberSet 传进去,并保留 SerialNo 做幂等),要么删除 tencent 分支 + NewTencent + 配置项,并同步修正 README。在未实现期间必须返回 excode.ErrNotProvider,禁止静默成功。

6. 邮件实际只实现了 qq 一个渠道但配置结构、README 与测试都宣称支持多渠道;且 is_gen_code 被完全忽略

  • 位置module/base/sender/internal/logic/mail/send.go:55-60internal/logic/mail/send.go:31-34test/grpc/mail_test.go:37README.md:18,262-287
  • 证据
// mail/send.go:55-60  —— 只有 qq 分支QQ() 本身其实与 qq 无关(通用 SMTP
switch provider {
case "qq":
	err = QQ(cfg, tmpl, in.GetTo(), tplRecord.Subjet, in.GetParamters())
default:
	return nil, excode.ErrNotProvider
}
// mail/send.go:31-34  —— SMTP 配置是 map看起来支持多渠道路由但 gmail 会先在这里 404
cfg, ok := config.Spec.SMTP[provider]
if !ok || cfg == nil { return nil, excode.ErrProviderIsNil }
// test/grpc/mail_test.go:31-38  —— 官方测试用例自己用的是 gmail必然失败
req := &pb.SendMailRequest{ TemplateKey: "default", To: "271055687@qq.com", Paramters: ...,
	Provider: "gmail" }
  • 影响
    • 配置(SMTP: map[string]*SmtpConfinternal/config/config.go:26,31-38)、withProvidergoogle 分支(impl/provider.go:35-36、README:18/262-287 的「Gmail 等主流邮箱」以及扩展指引,都指向多渠道;实际只有 qqpkgs/all/etc/default_dev.yaml:99-106 的渠道名是 default,在 switch 里同样落到 ErrNotProvider。多渠道抽象是假的。
    • SendMailRequest.IsGenCodeproto/mail.proto:15)在 mail.Send 全文件中从未被读取:既不生成验证码,也不写 Redis。调用方按 protobuf 注释「是否生成验证码」传 is_gen_code=true 时会以为验证码已生成并入库,实际邮件模板里的 {{.code}} 会是空值,且模块内也不存在任何 Mail.Verify 接口可以校验邮件验证码。
  • 建议:把 QQ() 改名为 SMTPSender() 并去掉 switch(直接按 provider 取配置),或按真实渠道补齐实现;is_gen_code 要么实现(生成 + KeyPrefix 落 Redis + 后续校验),要么从 proto 与文档中删除该字段。

7. 阿里云短信调用无超时、未传递 ctx可无限阻塞 gRPC 处理协程

  • 位置module/base/sender/internal/logic/sms/send.go:123-126,148
  • 证据
// send.go:123-126,148  —— runtime 全空(未设 ReadTimeout/ConnectTimeout且用的是无 ctx 的 CallApi
runtime := &dara.RuntimeOptions{}
request := &AliYunClient.OpenApiRequest{ Query: AliYunUtil.Query(params) }
...
return impl.Provider.Aliyun.CallApi(clientParams, request, runtime)

证据链SDK 侧 darabonba-openapi/v2@v2.1.12/client/client.go:163-164Default(IntValue(runtime.ReadTimeout), IntValue(client.ReadTimeout)) 取值,而模块既没在 runtime 也没在 AliYunClient.Configinternal/impl/provider.go:68-71 只设 Endpoint/Credential)设置超时 → tea@v1.5.3/dara/core.go:354 得到 httpClient.Timeout = 0,即 Go 语义下的「无超时」。该 SDK 另提供 CallApiWithCtxdarabonba-openapi/v2@v2.1.12/client/client_context_func.go:1330),模块未使用。

  • 影响:阿里云侧变慢/挂死时HTTP 请求永不超时gRPC handler 协程(含 ctx 已取消的请求)会一直占用内存与连接;ctx 未下传意味着调用方断连/超时也无法取消,容易被放大成协程堆积 → 服务级联不可用。同时无重试、无熔断、无降级。
  • 建议runtime := &dara.RuntimeOptions{ConnectTimeout: dara.Int(3000), ReadTimeout: dara.Int(5000), Autoretry: dara.Bool(true), MaxAttempts: dara.Int(2)},改用 CallApiWithCtx(ctx, ...),并用 context.WithTimeout 包住整个 handler对超时/限流类错误做业务化错误码与告警。

8. SMTP 会话:成功路径不关闭连接(每封邮件泄漏一个 TLS 连接)、无 dial 超时、同步阻塞无重试

  • 位置module/base/sender/internal/logic/mail/send.go:76-86,140-151
  • 证据
// mail/send.go:77-82  —— tls.Dial 无 Dialer/超时参数,可长期阻塞
conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", cfg.Endpoint, cfg.Port), &tls.Config{
	ServerName: cfg.Endpoint, MinVersion: tls.VersionTLS12, InsecureSkipVerify: false })
// mail/send.go:140-151  —— 写入完成后直接 return nil没有 client.Quit(),也没有 conn.Close()
if _, err := writer.Write([]byte(mailBody)); err != nil { client.Close(); ...; return err }
if err := writer.Close(); err != nil { client.Close(); ...; return err }
return nil
  • 影响
    1. 连接泄漏:成功路径既不 client.Quit() 也不 conn.Close()TCP/TLS 连接只能等 GC/finalizer 回收;每封成功邮件泄漏 1 个 fd另有 defer writer.Close() 与显式 writer.Close() 的双重关闭)。在被批量调用时迅速耗尽本地端口/fd表现为「突然发不出邮件」。
    2. 无超时tls.Dial 使用默认 dialer无超时SMTP 端静默挂起会永久阻塞 gRPC 协程,且 ctx 参数在 mail.Send 中完全未使用。
    3. 同步发送handler 内直接等待外部 SMTP 往返(含握手+认证+多轮命令+DATA无队列、无并发上限、无重试与退避高峰时 goroutine 堆积。
  • 建议defer func(){ client.Quit(); conn.Close() }() 或统一 defer client.Close()dial 用 net.Dialer{Timeout: 5*time.Second} + tls.DialWithDialer,并给整个会话加 conn.SetDeadline;发送改投内部队列(异步 worker + 指数退避重试 + 失败落库handler 只入队。

9. SMTP 握手失败时对 nil 客户端调用 Close()panic 崩溃进程

  • 位置module/base/sender/internal/logic/mail/send.go:89-94
  • 证据
client, err := smtp.NewClient(conn, cfg.Endpoint)
if err != nil {
	client.Close()   // smtp.NewClient 失败时返回 nil,err → nil 解引用
	fmt.Println("SMTP客户端初始化失败: ", err)
	return err
}

net/smtp.NewClient 在 greeting 读失败时 return nil, errgRPC 默认不 recover handler panicinternal/server/new.go:23 也未加 recovery 拦截器panic 会终止整个进程。)

  • 影响SMTP 服务端 greeting 异常(网络抖动、网关拦截、端口写错、.Endpoint 配成 smtp.gmail.com 的 465/587 不匹配等)即可让 Mail.Send 触发 nil panic → 整个 sender在聚合部署下是整个 pkgs/all 进程,因为共享同一 gRPC server崩溃重启属于可被外部依赖状态触发的可用性事故。
  • 建议if err != nil { _ = conn.Close(); return err };同时给 gRPC server 加 grpc.ChainUnaryInterceptor(recoveryInterceptor),并在所有出错的 Close() 调用前判空。

10. 敏感信息明文进入日志验证码明文打印、DB/Redis 连接口令随启动日志输出

  • 位置module/base/sender/internal/logic/sms/send.go:96,146-147module/base/sender/internal/impl/impl.go:26module/base/sender/etc/sender_dev.yaml:7,10,32,41
  • 证据
// sms/send.go:95-96  —— 返回体与 stdout 都包含模板参数(含验证码)
jsonBytes, _ := json.Marshal(result)
fmt.Println("短信发送结果:", string(jsonBytes))
// sms/send.go:146-147  —— 参数里 TemplateParam 就是 {"code": 验证码},明文打到 stdout
fmt.Println("请求参数params为", params)
// internal/impl/impl.go:24-26  —— 传入的是带口令的完整 DSN
RedisService = with.RedisCache(config.Spec.Cache)
DBService = with.Databases(config.Spec.Databases, nil)

SDK 会把 DSN 原文打印出来:with/databases.go:18printer.Info("... Databases: %v", cfg))、with/redis.go:14printer.Info("... Cache: %s", cfg)),而 DSN 形如 etc/sender_dev.yaml:7,10password=CHANGE_MEredis://null:CHANGE_ME@...。另外 internal/impl/provider.go:60-61 把 AK/SK 明文塞进凭据对象,配置文件中 etc/sender_dev.yaml:40 还提交了真实 AccessKeyIdLTAI5tKEmKuuoixE4iw8NZbX)与真实企业邮箱账号(:31,33)。

  • 影响:验证码明文进日志违反「验证码不得落日志」的基本要求(日志被读=验证码被读,直接绕过问题 3 的所有加固DB/Redis 口令、SMTP 口令、AK/SK 随启动日志与提交进仓库的配置文件扩散,属于凭据泄露面。日志还使用 fmt.Println 无结构、无级别、无请求 ID无法审计追踪supervisor 只做 stdout 重定向,见 etc/supervisor.bsm-apps-sender.conf:8)。
  • 建议:删除所有打印验证码/模板参数的语句,改为结构化日志(slog/项目 logger并统一脱敏手机号掩码、验证码/口令/AK 一律不打);启动日志屏蔽 DSN 口令(with 层做 *** 替换);配置文件只留占位符,真实口令走环境变量/配置中心,并轮换已提交的 AccessKeyId 与邮箱授权码。

11. 黑名单机制实际无效:无人写入、检查失败静默放行、相关配置项完全未使用

  • 位置module/base/sender/internal/logic/sms/send.go:37-39internal/logic/sms/const.go:6internal/config/config.go:55
  • 证据
// sms/send.go:37-39  —— SIsMember 的 err 被丢弃(.Val() 只取布尔Redis 异常时 fail-open
if impl.RedisService.Client.SIsMember(impl.RedisService.Ctx, BlackListCacheKey, in.GetPhone()).Val() {
	return nil, excode.ErrInBlackList
}

全仓库检索 /SMS/BlackList/ 只有这一个读取点,没有任何写入/管理接口Code.BlackListFilterinternal/config/config.go:55dev/prod/test 都配了 - 127.0.0.1)在整个模块中无任何引用。

  • 影响:黑名单只有在有人手工往 Redis set 里塞数据时才生效,代码层面没有任何维护入口,也无法审计;同时因为丢弃了 errorRedis 不可用时黑名单判断静默放行(应为 fail-closed 或至少告警。README:12 宣称「黑名单过滤:支持手机号黑名单机制」,实际是一个空壳。
  • 建议:补齐黑名单管理能力(配置加载 BlackListFilter 到 Redis、或提供内部管理 RPC检查失败按策略处理Redis 错误时拒绝发送并告警),把 SIsMember(...).Result() 的错误显式判断。

12. 验证码写入用 SetNX 但忽略返回值;用户传入的 paramters["code"] 会覆盖刚生成的验证码

  • 位置module/base/sender/internal/logic/sms/send.go:65internal/logic/sms/send.go:102-109
  • 证据
// sms/send.go:62-65  —— 生成后 SetNX返回值与 err 都没看;已存在时写入失败但短信照发
smsCode = GenValidateCode(config.Spec.Code.Length)
expire := time.Second * time.Duration(config.Spec.Code.Expire)
impl.RedisService.Client.SetNX(impl.RedisService.Ctx, key, smsCode, expire)
// sms/send.go:104-109  —— 先放生成的 code再被请求参数覆盖
var templateParam = map[string]any{"code": code}
for key, val := range args.Paramters { templateParam[key] = val }
  • 影响
    1. SetNX 失败(同一手机号 300s 内二次发送Redis 里仍是旧验证码,而用户手机收到的是新验证码 → 用户永远无法通过校验,且没有任何错误返回;同时 err 被忽略Redis 故障也无声。
    2. 调用方只要在 paramters 里带 codesend.go:66-72 的非生成模式也需要它),就会覆盖模板里的验证码 → 下发的验证码与 Redis 中存储的验证码不一致(用户校验必失败),并且这个覆盖值完全由请求方决定(可用来在平台签名短信里塞任意内容,配合问题 1 构成钓鱼)。
  • 建议:写入改为 Set(key, code, expire)(或先 GETDEL/DELSet)并检查返回错误;把生成码与业务参数显式隔离:模板参数只从白名单 key 取值,code 由服务端唯一注入,禁止被 paramters 覆盖;is_gen_code=false 时也要明确「谁负责校验」或直接禁止该模式。

13. 配置缺失即 panicCode 段/Redis 未配置时空指针崩溃(无配置校验、无 recover

  • 位置module/base/sender/internal/config/config.go:60-77internal/logic/sms/send.go:45-57internal/impl/impl.go:22-24
  • 证据
// config/config.go:65-70  —— 只校验 Service/CacheCode、SMS、SMTP 都没校验
Spec.Port = conf.CheckPort(Spec.Port)
Spec.BindIP = conf.CheckIP(Spec.BindIP)
conf.NotNil(Spec.Service, Spec.Cache)
// sms/send.go:49,57  —— Code 为 nil 时直接解引用 panic
if twice > config.Spec.Code.MaxSentLimit {
if config.Spec.Code.Length < 4 || config.Spec.Code.Length > 10 {
  • 影响Code: 段缺失(或 Cache: 为空导致 with.RedisCache 返回 nilwith/redis.go:9-17)时,首个请求就会 nil 解引用 panicgRPC 无 recover见问题 9→ 进程退出。配置错误被推迟到运行时才以进程崩溃的形式暴露。
  • 建议:在 config.New 中用 conf.NotNil(Spec.Code, Spec.SMS, Spec.SMTP) 做启动期校验并为 Length/Expire/MaxSentLimit 设定安全默认值与边界(如 Expire>0、MaxSentLimit>0Redis/DB 客户端在 NewImpl 中判空并 fail-fast带明确错误信息而不是留到请求路径。

14. 生产/测试配置不可用:SMS.aliyun.Endpoint 填成了 smtp.gmail.comDB/Redis 指向本地且口令为 CHANGE_ME

  • 位置module/base/sender/etc/sender_prod.yaml:38,7,10module/base/sender/etc/sender_test.yaml:37,7,10
  • 证据
# etc/sender_prod.yaml:36-41  —— 阿里云短信的 Endpoint 是 gmail 的 SMTP 地址(复制粘贴错误)
SMS:
  aliyun:
    Endpoint: smtp.gmail.com
    AccessKeyId: <your-access-key-id>
# etc/sender_prod.yaml:4-10  —— 生产库/缓存仍是本地 CHANGE_ME + bsm_dev
Databases:
  Driver: postgres
  Source:
    - host=127.0.0.1 user=postgres password=CHANGE_ME dbname=bsm_dev port=5432 sslmode=disable
Cache: redis://null:CHANGE_ME@127.0.0.1:6379/
  • 影响prod/test 配置一旦被使用,阿里云短信请求会打到 smtp.gmail.com(签名/鉴权全部失败短信全量不可用DB/Redis 连接必然失败(with.Databases 直接 panicwith/databases.go:13-15)。配置与 README 的示例README:88-92也不一致。生产配置与开发配置几乎相同说明该文件没有被真实部署验证过。
  • 建议prod 配置改为真实 Endpointdysmsapi.aliyuncs.com+ 密钥外置(环境变量/配置中心),数据库/缓存指向真实地址;在启动期对 Endpoint 做白名单校验(如必须匹配 *.aliyuncs.com),避免此类拼写错误静默上线。

15. 接口返回语义与真实状态不一致:VerifyReply:"false" + err=nilSend"null"/厂商业务错误当成功

  • 位置module/base/sender/internal/logic/sms/verify.go:37-39internal/logic/sms/send.go:91-99internal/logic/sms/send.go:151-153
  • 证据
// verify.go:37-39  —— 校验失败时 err 为 nilHTTP/gRPC 层面都是成功响应
return &pb.SmsReply{
	Reply: "false",
}, nil
// sms/send.go:95-99  —— 只要 SDK 没返回 error 就当作成功reply 是原始响应体
jsonBytes, _ := json.Marshal(result)
fmt.Println("短信发送结果:", string(jsonBytes))
return &pb.SmsReply{ Reply: string(jsonBytes) }, nil
  • 影响:调用方只要按惯例判断 err == nil(或 HTTP 200就会把「验证码错误」当成「验证通过」这是典型的越权/接管风险点;Send 侧则可能把阿里云的业务错误(如 isv.BUSINESS_LIMIT_CONTROLisv.AMOUNT_NOT_ENOUGH)与 null(腾讯空实现)都返回成成功。响应体直接回传云厂商原始 JSON还会向调用方暴露账号/模板/请求 ID 等信息。
  • 建议Verify 失败返回明确错误码(如 excode.ErrCodeErrExpired),成功返回 true 或结构化结果;Send 应解析厂商返回并映射为统一结果结构(ok/msg_id/code),业务失败必须返回非 nil error不要把厂商原始报文透传给调用方。

16. 邮件发送无频控、无配额、无收件人限制

  • 位置module/base/sender/internal/logic/mail/send.go:22-45(全文件无任何频控/黑名单/配额逻辑,对比 sms/send.go:37-51
  • 证据
// mail/send.go:26-31  —— 仅校验非空与 SMTP 配置存在,之后直接查模板并发送
if in.GetTemplateKey() == "" || provider == "" || in.GetTo() == "" {
	return nil, errcode.ErrInvalidArgument
}
cfg, ok := config.Spec.SMTP[provider]
  • 影响:任意可调用方(问题 1都能以平台发件人身份向任意邮箱发任意模板邮件无频控、无黑名单、无每日配额、无收件人域名限制 → 邮件轰炸,并直接损害企业邮箱域名的发信声誉(进入黑名单/SPF-DKIM 信誉下降),恢复成本极高。
  • 建议:与短信同样引入「配额 + 频控 + 黑名单 + 收件人白名单/域名策略」,并对同一 to 做 60s 重发间隔与幂等键去重。

P2

17. Provider 初始化失败直接 panic,无降级、无错误返回

  • 位置module/base/sender/internal/impl/provider.go:63-66,73-76,85-89
  • 证据
// provider.go:63-66
akCredential, err := credentials.NewCredential(config)
if err != nil {
	panic(err)
}

(同类:dysmsapi.NewClient 失败 provider.go:73-76TencentCloud.NewClient 失败 provider.go:85-89。)

  • 影响:任一云凭据缺失/格式错误(例如 AccessKeyId: <your-access-key-id> 占位符,见问题 14都会让服务在启动期直接 panic 退出,而不是「仅该渠道不可用」;TencentSender 是空实现却仍强制初始化腾讯客户端,扩大了崩溃面。缺少可观测的启动诊断。
  • 建议withProvider() 返回 error 并在 NewImpl 中汇总上报;单渠道初始化失败只禁用该渠道(记录告警),或明确 fail-fast 但给出可读的配置错误信息。

18. 请求路径中改写全局配置 config.Spec.Code.Length(数据竞争)

  • 位置module/base/sender/internal/logic/sms/send.go:56-59
  • 证据
// 每次发送都在读路径上写全局配置
if config.Spec.Code.Length < 4 || config.Spec.Code.Length > 10 {
	config.Spec.Code.Length = 6
}
  • 影响:并发请求下对全局配置做无锁写,go test -race 会直接报 data race配置语义被运行时篡改行为不可预期也掩盖了问题 13 的配置错误)。
  • 建议:改为局部变量取默认值(length := config.Spec.Code.Length; if length < 4 || length > 10 { length = 6 }),配置只读;默认值与边界校验放启动期。

19. 表结构迁移未生效:sender_template 需手工建表,否则邮件全部失败(迁移与启动耦合)

  • 位置module/base/sender/internal/models/sender_template.go:16-18module/base/sender/internal/impl/impl.go:26
  • 证据
// models/sender_template.go:16-18  —— 注册自动迁移
func init() { database.AppendMigrate(&SenderTemplate{}) }

但迁移入口条件不满足:impl.NewImpl() 调用 with.Databases(config.Spec.Databases, nil)nil optionsinternal/impl/impl.go:26SDK 默认 IsAutoMigrate: falseD:\work\bsm-sdk\core\database\sql\postgresql.go:17),而 new.go:43-44 仅在 options.IsAutoMigrate 为真时执行 AutoMigrate;同时命名策略是 SingularTable: truepostgresql.go:39-41),实际表名为 sender_template

  • 影响:自动迁移永远不会执行,部署时若没有手工建 sender_template 表,mail.Send 会在 impl.DBService.Where(...).First() 处报错并被统一转换成 ErrNotFound(1404,"template")mail/send.go:43-46),表现为「所有模板都不存在」,排查成本高;AppendMigrate 成为误导性死代码。
  • 建议:把建模迁移从进程启动解耦,改为显式迁移命令/脚本(cmd/cli 或 CI 步骤)并在文档中写明表结构;若坚持自动迁移,则显式传 &types.SqlOptions{IsAutoMigrate: true} 并明确告警。

20. 邮件每次请求都查库 + 重新解析模板,且每次发送新建 TLS 连接(无缓存、无连接复用)

  • 位置module/base/sender/internal/logic/mail/send.go:42-52,76-86
  • 证据
// mail/send.go:43-52  —— 每个请求一次 DB 查询 + 一次模板 Parse无任何缓存
err = impl.DBService.Where("key=?", in.GetTemplateKey()).First(&tplRecord).Error
...
tmpl, err := template.New("page").Parse(tplRecord.Body)
  • 影响模板是低频变更数据却按请求查库并重新解析HTML 模板解析是 CPU 密集操作),属明确的性能浪费;加之每次发送都新建 TLS 连接(无连接池/无长连接复用),单封邮件成本远高于必要值。impl.MemorySericego-cache已初始化却从未使用见问题 21缓存条件其实已经具备。
  • 建议:用 MemorySerice/Redis 对模板做带版本的缓存key = template_keyTTL 几十秒,或提供失效接口),并在发送侧复用 SMTP 连接(或至少限制并发连接数)。

21. 死代码与空壳实现较多,容易误导后续开发

  • 位置internal/impl/provider.go:52-54internal/impl/provider.go:20-25internal/server/new.go:17,29internal/impl/impl.go:16,22internal/config/config.go:53-55internal/excode/ex.go:8,11,14
  • 证据
// provider.go:52-54  —— 永远返回 nil 的构造函数
func NewSMTP(conf *config.SmtpConf) *smtp.Client {
	return nil
}

其余:ProviderClient.Google/QQprovider.go:22-23)永远是 nilwithProvidergoogle 分支(provider.go:35-36)赋 nilServer.grpcConns「连接池」声明并初始化但从未使用(server/new.go:17,29MemorySericeimpl.go:16,22,方法名本身是 Service 的拼写错误)从未使用;Code.CokeyKey/GenerateCode/BlackListFilterconfig.go:53-55)无引用;ErrAppName/ErrMustWhiteList/ErrExpiredex.go:8,11,14)无引用。

  • 影响:空实现与死配置让阅读者(以及 README 的扩展指引)以为多渠道/白名单/内存缓存已经可用,实际都没有落地;NewSMTP 返回 nil 若被启用还会立刻 nil 解引用。
  • 建议:删除或补齐:NewSMTPGoogle/QQ 字段直接删除(邮件走 SMTPSender 统一实现);grpcConns 删除;未使用的配置项与错误码删除或补上实现与测试。

22. 错误被吞掉或错误映射失真

  • 位置internal/logic/sms/send.go:95,156internal/logic/sms/send.go:37internal/logic/mail/send.go:124,146-150internal/logic/mail/send.go:43-46
  • 证据
// sms/send.go:95 与 mail/send.go:124 —— 错误被丢弃
jsonBytes, _ := json.Marshal(result)
defer writer.Close()   // 与 mail/send.go:146 的显式 Close 形成双重关闭
// sms/send.go:155-158  —— regexp 的 error 被丢弃
result, _ := regexp.MatchString(`^(1[3|4|5|6|7|8|9][0-9]\d{4,8})$`, phone)
// mail/send.go:43-46  —— DB 连接错误也被统一报成「模板不存在」
err = impl.DBService.Where("key=?", in.GetTemplateKey()).First(&tplRecord).Error
if err != nil { return nil, errcode.ErrNotFound(1404, "template") }
  • 影响Redis/DB/SMTP 的真实故障被掩盖成语义错误的返回值(「模板不存在」「发送成功」),现场排查困难;SetNXSIsMember().Val()json.Marshalregexp 的忽略错误在问题 11/12 中已有实际后果;writer.Close() 二次调用掩盖了第一次的错误。
  • 建议:统一 if err != nil { return ... } 处理并包装错误上下文区分配置缺失1404与基础设施错误5xx/ErrDB);去掉重复的 Close

23. 缺可观测性与优雅退出:无结构化日志、无 recover、无健康检查、defer srv.Stop() 不可达

  • 位置module/base/sender/cmd/main/main.go:44-48module/base/sender/internal/logic/sms/send.go:96module/base/sender/internal/logic/mail/send.go:84,92,100README.md:31,352,375-392
  • 证据
// cmd/main/main.go:44-48  —— defer 注册在 Start() 之前,但 Start() 内部是 select{} 永不返回
defer srv.Stop()
srv.Start()

SDK D:\work\bsm-sdk\core\service\service.go:113-115srv.Start()select {} 阻塞;cmd/main 也未注册 SIGTERM/SIGINT 处理,对比 pkgs/all/cmd/main/main.go:46-53。)

  • 影响:进程收到 supervisor 的 TERM 时不会优雅关闭(etc/supervisor.bsm-apps-sender.conf 只有 autorestart=true),进行中的发送会被硬中断;无 /health 端点、无 gRPC health service全模块 grep health 无匹配),聚合侧 pkgs/all/internal/server/authorization.go:98 虽把 /grpc.health. 设为匿名,但服务端从未注册健康服务;日志是 fmt.Println 纯文本,无级别/请求 ID/耗时/渠道/结果指标线上无法定位「哪次发送失败」。README:375-392 宣称的健康检查/Prometheus/ELK/APM 均无实现。
  • 建议:注册信号处理 + GracefulStop(参考 pkgs/all),加 grpc_health_v1 与 HTTP /healthz,引入结构化日志与基础指标(发送量/成功率/各渠道耗时/验证码校验失败数panic recovery 拦截器。

24. 独立进程的 HTTP 网关从未注册业务 handlerMux 恒为 nil

  • 位置module/base/sender/internal/server/new.go:13-30module/base/sender/cmd/main/main.go:29,40module/base/sender/service/expose.go:22-36
  • 证据
// server/new.go:26-30  —— Server.Mux 从未被赋值(同文件全文无 srv.Mux = ...
srv := &Server{ Ctx: context.Background(), Grpc: grpcServ, grpcConns: make(map[string]*grpc.ClientConn) }
// cmd/main/main.go:40  —— 把 nil 的 Mux 交给 SDK 网关
GatewayMux:  s.Mux,
  • 影响:只有 service.Expose()(由 pkgs/all 调用,pkgs/all/internal/service/sender.go:11-21)才会 pb.RegisterSmsHandlerServer(...) 到 muxcmd/main 独立启动路径既不注册 handler 也不创建 muxSDK 侧 http.ListenAndServe(addr, mux) 拿到的是 nil *runtime.ServeMux,任何 HTTP 请求都会走到 nil 接收者解引用(grpc-gateway/v2@v2.30.0/runtime/mux.go:418 访问 s.unescapingMode)→ 请求级 panic/连接被断开,READMEtest/http/*.http 里那些 api.apinb.com/sender.Sms/Send 的 HTTP 用法在独立部署下不可用。
  • 建议:在 server.New 中初始化 Mux: gwRuntime.NewServeMux(),或在 cmd/main 中改为调用 service.Expose(...)(与 pkgs/all 一致);补一个独立启动的冒烟测试。(注:同样的 Mux 未初始化模式在 ads/cms/passport/... 等多个模块的 cmd/main 中重复出现,属系统性模板缺陷。)

25. 幂等性缺失:重复发送无去重,且无发送记录用于对账

  • 位置module/base/sender/internal/logic/sms/send.go:26-100module/base/sender/internal/logic/mail/send.go:22-69(全模块无 request_id/幂等键/发送流水表)
  • 证据
// sms/send.go:76-93  —— 无幂等键,同一请求重放即再发一条短信
switch strings.ToLower(in.GetProvider()) {
case "aliyun":
	...
	result, err = AliyunSender(in, smsCode)
// mail/send.go:66-68  —— 成功只回 OK不落任何发送记录
return &pb.SendMailReply{ Data: vars.OK }, nil
  • 影响调用方超时重试、gRPC 重试、用户连点都会重复发送(短信按条计费);发送结果不落库,问题 15把失败当成功发生后无法对账与补偿也无法审计「谁在什么时候给哪个号码发了什么」。
  • 建议:引入调用方传入的 request_id(或对 phone+template+分钟窗口 做 Redis 幂等键),并对每次发送落一条流水(渠道/模板/收件方哈希/厂商 msg_id/状态/错误),作为对账与风控数据源。

26. 邮件报文头不规范:无 Content-Type/字符集、Subject/FromName 未按 RFC 2047 编码、Rcpt 使用未归一化地址

  • 位置module/base/sender/internal/logic/mail/send.go:127-129internal/logic/mail/send.go:111
  • 证据
// mail/send.go:127-129  —— 手工拼接头部,无 Content-Type/charset、无 Date/Message-ID中文主题不编码
mailBody := "From: " + cfg.FromName + "<" + cfg.FromAddress + ">\n"
mailBody += "To: " + to + "\n"
mailBody += "Subject: " + subject + "\n\n"
// mail/send.go:111  —— 直接用未解析的原始串做信封收件人
err = client.Rcpt(to)
  • 影响:不含 Content-Type: text/html; charset=UTF-8 时,正文按 US-ASCII 解释,中文内容在多数客户端乱码;中文 Subject/FromNameREADME:102 示例就是「系统通知」)未做 RFC 2047 编码同样乱码,且 FromName<addr> 缺空格不符合 RFC 5322 语法,部分 MTA 会拒收;缺 Date/Message-ID 会提高被判垃圾邮件的概率。Rcpt(to) 使用原始输入而非 mail.ParseAddress 解析出的地址,形如 "Name <a@b.com>" 的合法输入会导致信封命令非法而投递失败。(说明:to 本身经 mail.ParseAddress 校验CRLF 注入会被 net/mail 的「expected single address」检查拒绝故此处不构成头部注入。
  • 建议:改用 mail.Address + mime.QEncoding/mime.WordEncoder 生成头部,补 Content-Type(含 charset 与 Content-Transfer-Encoding)、DateMessage-ID;发送前用 mail.ParseAddressAddress 字段做 Rcpt

27. 邮件模板统一用 html/template,纯文本邮件会被 HTML 转义污染

  • 位置module/base/sender/internal/logic/mail/send.go:8,49
  • 证据
import "html/template"
...
tmpl, err := template.New("page").Parse(tplRecord.Body)
  • 影响html/template 会对参数做 HTML 实体转义(&&amp;<&lt;)。当模板用于纯文本邮件(且报文头也没有声明 text/html)时,用户会看到 &amp; 之类的乱码内容;反过来若改用 text/template 则失去 HTML 转义保护。模板类型与转义策略没有建模。
  • 建议:为模板增加类型/内容类型字段,按类型选择 html/templatetext/template,并把 Content-Type 与之一致;对 HTML 模板保留转义(当前 html/template 对参数转义是正确做法)。

28. 渠道抽象缺失:switch 硬编码README 的扩展指引与实现不符

  • 位置internal/logic/sms/send.go:76-89internal/logic/mail/send.go:55-60README.md:234-287
  • 证据
// mail/send.go:55-60  —— QQ() 名为 QQ实为通用 SMTP新增渠道要改 switch
case "qq":
	err = QQ(cfg, tmpl, in.GetTo(), tplRecord.Subjet, in.GetParamters())
default:
	return nil, excode.ErrNotProvider
  • 影响README:246-259 教开发者在 send.go 的 switch 里加分支并「实现新服务商的发送函数」但没有任何接口interface/注册表抽象,渠道名与实现散落在 switchwithProviderswitch、yaml key 三处,容易漏改(本次 gmail/tencent 就是这么漏的。SMS 侧同样如此(sms/send.go:76-89)。
  • 建议:定义 SmsProvider / MailProvider 接口与注册表(map[string]Provider + Register(name, impl)配置驱动装配README 的扩展指引同步改为「实现接口并注册」。

P3

29. 文档与实现严重不一致README/UPGRADE_SUMMARY 描述了不存在的能力与文件)

  • 位置README.md:9,18,149-151,155,170,205-206,234-287,352,363-365,375-392UPGRADE_SUMMARY.md:60-68
  • 证据
README.md:149-150
- **gRPC服务**`localhost:12201`
- **HTTP Gateway**`localhost:12202`

实际端口是 12208/12207etc/sender_prod.yaml:2,23README:155 写的 POST /v1/sms/send,实际路由是 /sender.Sms/Sendpb/sms.pb.gw.go:216pb/mail.pb.gw.go:152README:363-365 说 Code.MaxSentLimit 默认 10配置里是 5etc/sender_prod.yaml:46README:205-206 的 scripts/swagger/ 目录不存在README/Makefile/Dockerfile/docker-compose.yml/CHANGELOG.md 均不存在(UPGRADE_SUMMARY.md:62-68 却声称「新增文件」README:352/375-392 宣称的 /health、Prometheus、ELK、APM、覆盖率 >80% 都无实现(全模块无 health 注册、无指标导出、无 CI 配置)。

  • 影响:运维按文档配置端口/路径会直接失败;「新增文件」的虚假记录会误导后续维护者以为容器化/构建体系已就绪。
  • 建议:以实际实现为准重写 README端口、路径、渠道、限额默认值、扩展步骤删除或补齐 UPGRADE_SUMMARY 中声称的文件;补充可执行的 curl/.http 示例(test/http/*.http 目前指向 api.apinb.com 生产域名)。

30. 命名拼写错误(对外协议字段与数据库字段)

  • 位置proto/sms.proto:18proto/mail.proto:16internal/models/sender_template.go:12
  • 证据
// proto/sms.proto:18
map<string,string> paramters=5; // 验证码相关
// internal/models/sender_template.go:12
Subjet string `gorm:"type:varchar(255);not null;default:'';comment:邮件主题"`

paramters(应为 parameters)已固化进 protobuf 字段名与 JSON 名(pb/sms.pb.go:32Subjet(应为 Subject)已固化进数据库列名;impl.MemorySericeinternal/impl/impl.go:16)同样拼错。

  • 影响:协议字段拼写错误一旦上线难以单方面修正(需兼容期双字段);列名拼写错误在库里永久存在。
  • 建议SubjetMemorySerice 在本次无外部兼容负担时直接更正;paramters 通过新增 parameters 字段 + 双读兼容再下线旧字段,避免破坏现有调用方。

31. 手机号校验正则过于宽松

  • 位置module/base/sender/internal/logic/sms/send.go:155-159
  • 证据
result, _ := regexp.MatchString(`^(1[3|4|5|6|7|8|9][0-9]\d{4,8})$`, phone)
  • 影响:字符类 [3|4|...] 里含无意义的 |\d{4,8} 允许总长 711 位(如 1301234),非法号码也能通过校验并真实提交到云厂商(每条都可能计费/失败计数),浪费额度与配额;不支持国际号码(+86/00 前缀)与 PhoneNumbers 多号码批量。
  • 建议:收紧为 ^1[3-9]\d{9}$(必要时再单独支持 E.164 国际格式),并把号码规范化(去空格/+86)后再送厂商。

32. 限流窗口使用进程本地时区

  • 位置module/base/sender/internal/logic/sms/send.go:42internal/logic/sms/const.go:4
  • 证据
// sms/send.go:42  + const.go:4  FormatDay = "2006-01-02"
limitKey := LimitCacheKey + time.Now().Format(FormatDay) + in.GetPhone()
  • 影响:日期分界取决于容器本地时区(生产镜像常为 UTC与业务时区Asia/Shanghai,见 DB DSN etc/sender_prod.yaml:7)不一致,会造成「凌晨 8 小时窗口重叠/提前重置」的计数错乱(在问题 2 修复后才会显现)。同理,Code.Expiretime.Second * Expire 尚可,但没有任何时间来源统一约定。
  • 建议:统一显式加载 time.LoadLocation("Asia/Shanghai") 并用 time.Now().In(loc);或直接以滑动窗口(INCR + EXPIRE 24h)替代「自然日」键,避免时区语义。

33. 模板参数无白名单/长度限制,且把云厂商原始响应回传调用方

  • 位置module/base/sender/internal/logic/sms/send.go:107-109internal/logic/sms/send.go:95-99
  • 证据
// sms/send.go:107-109  —— 任意 key/value 直接进入厂商模板参数
for key, val := range args.Paramters {
	templateParam[key] = val
}
  • 影响TemplateParam 的 key/value 完全不受控(可注入超长内容、任意占位符名),在 is_gen_code=false 场景下短信正文内容由调用方决定(问题 1/12 的放大面);响应体直接回传厂商 JSONReply: string(jsonBytes)),对外暴露账号与模板信息。
  • 建议:对 paramters 做 key 白名单 + value 长度/字符集校验(并过滤换行等控制字符以符合短信规范);响应只返回必要的 msg_id/状态。

34. 部署配置supervisor 以 root 运行、无日志轮转、无优雅停止

  • 位置etc/supervisor.bsm-apps-sender.conf:1-8
  • 证据
command=/data/app/bsm-apps-sender
user=root
redirect_stderr=true
stdout_logfile=/data/app/logs/apps-sender.log
  • 影响:以 root 运行放大了任何代码执行/文件写入类漏洞的后果;stdout_logfilestdout_logfile_maxbytes/轮转配置,日志无限增长(配合问题 10 的明文验证码日志更严重);无 stopasgroup/killasgroup 与优雅停止信号约定(见问题 23
  • 建议:改用非特权用户运行、配置日志轮转、显式声明 stopsignal=TERM 与优雅停止超时。

4. 推荐优化方案

按「先堵安全、再修正确性、后补可观测与工程化」推进:

  1. 访问控制与配额P0-1/2/16
    • 模块内加鉴权拦截器gRPC UnaryInterceptor + gateway middleware校验内部服务凭据或用户 JWT并校验「手机号/邮箱是否属于该用户」;MicroService.Anonymouspkgs/allAuthorization.Anonymous 中移除 sender 三个方法。
    • 签名/模板白名单化:sign_name/template_code/template_key 由服务端按业务场景决定,paramters 走 key 白名单 + 长度校验。
    • 配额与频控落地:INCR + EXPIRE 原子计数(手机号/日、手机号/小时、手机号/60s、账号/日、IP/日),超限返回明确错误码;配额在发送前占用、失败回滚。
  2. 验证码安全P0-3/4、P1-12
    • crypto/rand 生成;Set 覆盖写并检查错误;code 参数服务端独占注入,禁止被 paramters 覆盖。
    • 校验用 GETDEL 一次性消费;失败计数 + 锁定;失败不删 key返回明确错误码而非 "false"+nil
  3. 外部调用健壮性P1-5/7/8/9
    • 短信:CallApiWithCtx + 显式 ConnectTimeout/ReadTimeout + 重试/熔断 + 渠道接口化(补齐或删除 tencent
    • 邮件:defer client.Close()+Quit()、dial/会话超时、mail.Address/RFC 2047/MIME 头、失败路径判空、异步队列 + 退避重试。
    • 全链路 ctx 贯穿(含 Redis用请求 ctx 而不是 impl.RedisService.Ctx,并给 Redis 操作加超时)。
  4. 配置与密钥P1-10/13/14
    • 启动期校验 Code/SMS/SMTPEndpoint 白名单校验;口令/AK 全部外置(环境变量/配置中心)并轮换已入库凭据;日志脱敏 + 结构化;删除验证码明文打印。
  5. 可观测与运维P2-23/24/25、P3-34
    • 信号处理 + GracefulStopgrpc_health_v1 + /healthz、panic recovery、结构化日志request_id/渠道/耗时/结果、Prometheus 指标(发送量、成功率、各渠道延迟、验证码失败率)、发送流水表支持对账。
  6. 工程化P2-19/20/29、P3-29/30
    • 迁移改为显式命令;模板缓存;补单元测试(GenValidateCode/VerifyPhone/ValidateEmail/模板渲染/限额与验证码状态机)+ 接口 mock更正拼写README 与实现对齐(端口、路径、渠道、默认限额)。

5. TODO 清单

  • P0-1Sms.Send/VerifyMail.Send 加身份与归属校验,并从两份 Anonymous 列表移除这三个方法|验收:未带凭据调用返回鉴权错误;带 A 用户凭据给非 A 的手机号/邮箱发送被拒绝|涉及:module/base/sender/internal/logic/sms/send.go:26module/base/sender/internal/logic/mail/send.go:22module/base/sender/etc/sender_prod.yaml:15pkgs/all/etc/default_dev.yaml:24
  • P0-2INCR+EXPIRE 实现手机号日/时/分钟配额与账号/IP 频控,配额先占后退|验收:单测覆盖第 6 次发送被拒、计数键有 TTL、发送失败后计数回滚涉及module/base/sender/internal/logic/sms/send.go:41
  • P0-2b 邮件侧补齐发送配额与频控、收件人策略|验收:同收件人 60s 内二次发送被拒;日配额生效|涉及:module/base/sender/internal/logic/mail/send.go:22
  • P0-3 Verify 改为 GETDEL 一次性消费,新增失败计数与锁定,失败分支不删 key验收正确验证码二次校验失败连续 5 次错误后验证码失效并锁定;错误码非 nil涉及module/base/sender/internal/logic/sms/verify.go:28
  • P0-4 验证码改用 crypto/rand|验收:单测统计 10 万次生成分布无重复模式,代码中不存在 math/rand|涉及:module/base/sender/internal/logic/sms/send.go:162
  • P1-5 实现腾讯云 SendSms 或删除 tencent 分支并同步文档,禁止 (nil,nil) 假成功|验收:provider=tencent 要么真实下发并返回 msg_id要么返回 ErrNotProvider|涉及:module/base/sender/internal/logic/sms/send.go:151
  • P1-6 邮件渠道改为按 provider 直取配置(去掉 switch),并实现或删除 is_gen_code|验收:新增渠道只需加配置;is_gen_code=true 时验证码落 Redis 且可校验(或有明确返回)|涉及:module/base/sender/internal/logic/mail/send.go:55module/base/sender/proto/mail.proto:15
  • P1-7 短信调用改 CallApiWithCtx 并设置 Connect/Read 超时与重试|验收:代码中无 &dara.RuntimeOptions{} 空结构;单测用假 endpoint 验证 5s 内返回超时错误|涉及:module/base/sender/internal/logic/sms/send.go:123
  • P1-8 修复 SMTP 连接泄漏、加 dial/会话超时、增加异步队列与重试|验收:连续发 1000 封后 netstat 无 ESTABLISHED 残留SMTP 挂起时 10s 内返回错误|涉及:module/base/sender/internal/logic/mail/send.go:76
  • P1-9 修掉 client.Close() nil panic 并加 gRPC recovery 拦截器验收SMTP greeting 失败时返回错误且进程不退出|涉及:module/base/sender/internal/logic/mail/send.go:89module/base/sender/internal/server/new.go:23
  • P1-10 清理敏感日志(验证码/模板参数/DSN 口令并改结构化日志验收grep 日志输出无验证码、无 password=、无 AK/SK启动日志口令为掩码涉及module/base/sender/internal/logic/sms/send.go:96module/base/sender/internal/impl/impl.go:26
  • P1-11 黑名单机制落地:配置/管理入口写入 Redis set检查失败按 fail-closed 处理|验收:把号码加入黑名单后发送返回 ErrInBlackListRedis 故障时拒绝发送并告警|涉及:module/base/sender/internal/logic/sms/send.go:37module/base/sender/internal/config/config.go:55
  • P1-12 验证码写入改 Set 并检查错误;code 由服务端独占注入,禁止被 paramters 覆盖验收300s 内二次发送后 Redis 中的码与用户收到的码一致;paramters.code 无法覆盖|涉及:module/base/sender/internal/logic/sms/send.go:65
  • P1-13 启动期校验 Code/SMS/SMTP 并给边界默认值Redis/DB 客户端判空 fail-fast验收缺失 Code 段时启动报明确错误而非请求期 panic涉及module/base/sender/internal/config/config.go:70
  • P1-14 修正 prod/test 配置Endpoint、真实 DB/Redis、密钥外置验收prod 配置通过启动校验并能真实下发一条短信|涉及:module/base/sender/etc/sender_prod.yaml:38
  • P1-15 统一返回语义:失败返回非 nil errorSend 解析并映射厂商结果|验收:错误验证码返回错误码;业务失败不被当成成功|涉及:module/base/sender/internal/logic/sms/verify.go:37
  • P2-17 Provider 初始化失败降级而非 panic验收单一渠道凭据错误时服务仍可启动其他渠道可用涉及module/base/sender/internal/impl/provider.go:63
  • P2-18 去掉请求路径中的全局配置写入|验收:go test -race 无告警|涉及:module/base/sender/internal/logic/sms/send.go:57
  • P2-19 迁移改为显式命令并补建表脚本/文档|验收:新环境按文档建表后邮件发送成功;不再依赖请求期隐式建表|涉及:module/base/sender/internal/models/sender_template.go:16
  • P2-20 模板加缓存、SMTP 连接复用|验收:模板命中缓存时无 DB 查询;连接数有上限|涉及:module/base/sender/internal/logic/mail/send.go:43
  • P2-21 清理死代码(NewSMTP/Google/QQ/grpcConns/MemorySerice/未用配置与错误码)|验收:go vet 干净且无未引用导出符号|涉及:module/base/sender/internal/impl/provider.go:52
  • P2-22 补齐错误处理(json.Marshal/regexp/Redis 错误/DB 错误映射/双重 Close验收基础设施故障返回 5xx 类错误而非 1404 或假成功|涉及:module/base/sender/internal/logic/mail/send.go:43
  • P2-23 加信号处理+GracefulStop、health 端点、panic recovery、结构化日志与指标验收TERM 后 10s 内退出且无中断中的发送;/healthz 200涉及module/base/sender/cmd/main/main.go:45
  • P2-24 初始化 Server.Mux 或让独立入口走 service.Expose|验收:独立启动后 POST /sender.Sms/Verify 返回业务错误而非 panic/断连|涉及:module/base/sender/internal/server/new.go:26
  • P2-25 引入幂等键与发送流水表|验收:同 request_id 重放不产生第二条短信;可按 request_id 查询发送结果|涉及:module/base/sender/internal/logic/sms/send.go:76
  • P2-26 邮件头规范化MIME/charset/RFC2047/Date/Message-IDRcpt 用解析后地址)|验收:中文主题与正文在 QQ/Gmail 客户端显示正常;"Name <a@b.com>" 输入可正常投递|涉及:module/base/sender/internal/logic/mail/send.go:127
  • P2-28 渠道接口化 + 注册表README 扩展指引同步|验收:新增渠道不改 switch,仅新增实现并注册|涉及:module/base/sender/internal/logic/mail/send.go:55
  • P3-29 README/UPGRADE_SUMMARY 与实现对齐|验收:文档中的端口、路径、渠道、默认限额、文件清单逐条可验证|涉及:README.md:150UPGRADE_SUMMARY.md:62
  • P3-31 收紧手机号正则并做号码规范化|验收:1301234 被拒,+86 138... 可正常发送|涉及:module/base/sender/internal/logic/sms/send.go:156
  • P3-32 限流窗口时区显式化|验收:跨时区部署下计数窗口按 Asia/Shanghai 重置|涉及:module/base/sender/internal/logic/sms/send.go:42
  • P3-34 supervisor 改非 root + 日志轮转|验收:进程非 root 运行,日志按大小轮转|涉及:module/base/sender/etc/supervisor.bsm-apps-sender.conf:6

6. 审计摘要(供汇总使用)

  • 问题数P0=4 P1=12 P2=12 P3=6合计 34
  • 最高风险(一句话):短信/邮件发送接口无鉴权且日限额计数器从未写入、验证码可无限次爆破并可被他人一条错误请求删除,任何能访问该服务(或任意注册用户)的人都可对任意手机号无限发送平台签名短信,直接造成短信轰炸、费用损失与钓鱼风险。
  • 最优先 3 个动作:
    1. Sms.Send/VerifyMail.Send 加身份+归属校验并从 Anonymous 列表移除,同时把「签名/模板/参数」改为服务端白名单。
    2. INCR+EXPIRE 真正落地手机号/账号/IP 配额与频控(当前 LimitCacheKey 只有读、没有写)。
    3. 重写验证码生命周期:crypto/rand 生成、Set 覆盖并检查错误、GETDEL 一次性消费、失败计数与锁定、失败不删 key并为验证码/口令做日志脱敏。
  • 未能覆盖/无法验证的部分:
    1. 未实跑服务:本机无 Redis/PostgreSQL/短信账号,全部结论来自静态阅读;gofmt -l .go vet ./... 均无输出exit 0未执行 -tags integration 的集成测试(其指向真实 IP/域名并会发真实短信邮件)。
    2. api.apinb.com 网关etcd 匿名列表的实际消费方不在本工作区「Anonymous 生效后即无鉴权放行」为推测;已在报告中标注。
    3. 腾讯云 SMS 空实现的实际影响范围取决于部署是否真的配置了 SMS.tencent(仓库内三份 yaml 均未配置,仅代码路径存在)。
    4. 阿里云超时结论基于本机 module cache 中 darabonba-openapi/v2@v2.1.12tea@v1.5.3 的实现链(Default(0,0)httpClient.Timeout=0);若线上实际拉取的 SDK 版本不同,超时默认值可能不同。
    5. 未做动态内存/fd 泄漏与压测验证SMTP 连接泄漏、goroutine 堆积为静态推断)。
    6. pb/const.pb.go 中大量与本模块无关的共享消息market/order/cms 等)仅做存在性核对,未逐字段审计。