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.
62 KiB
审计报告: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 / paramters(proto/sms.proto:12-19)、provider / template_key / to / is_gen_code / paramters(proto/mail.proto:11-17)。
运行时形态有两种:
- 独立进程:
cmd/main/main.go→server.New(nil)+ SDKcore/service.New(...),配置取etc/sender_{dev,test,prod}.yaml(gRPC 12208 / Gateway 12207)。 - 聚合进程:
pkgs/all通过moduleService.Expose(...)挂载(pkgs/all/internal/service/sender.go:10-22),配置取pkgs/all/etc/default_dev.yaml的Sender:段(pkgs/all/etc/default_dev.yaml:98-118)。
关键依赖:Redis(验证码/黑名单/计数)、PostgreSQL(邮件模板 sender_template)、阿里云短信 SDK(dysmsapi-20180501/v2)、腾讯云短信 SDK、net/smtp(邮件为手写 SMTP 会话,不走 SDK)。
2. 审计范围与方法
纳入范围(全部通读,共 39 个文件):
- 非 pb Go 文件 18 个:
cmd/cli/main.go、cmd/main/main.go、internal/config/config.go、internal/excode/ex.go、internal/impl/{impl,provider}.go、internal/logic/mail/send.go、internal/logic/sms/{const,send,verify}.go、internal/models/sender_template.go、internal/server/{mail_server,new,sms_server}.go、service/{dependencies,expose}.go、test/grpc/{mail,sms}_test.go。 - pb 生成代码 7 个(只核 HTTP 路由与字段语义,不逐行审):
pb/*.pb.go、pb/*.pb.gw.go、pb/*_grpc.pb.go。 - 配置:
etc/sender_{dev,test,prod}.yaml、etc/supervisor.bsm-apps-sender.conf;proto 3 个;README.md、UPGRADE_SUMMARY.md。 - 必要的外部证据链(只读):
pkgs/all(聚合装配与鉴权中间件)、D:\work\bsm-sdk\core(该模块go.mod:90的replace目标:conf、service、cache/redis、with、database)、Go 标准库net/mail、github.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-39、module/base/sender/internal/logic/mail/send.go:22-39、module/base/sender/etc/sender_prod.yaml:15-18、module/base/sender/internal/server/new.go:23、module/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]
- 影响:
- 独立部署:gRPC(
grpc.NewServer()无拦截器)与 HTTP 网关均无鉴权代码,任何能连到 12207/12208 的人都可以SignName填平台签名、PhoneNumbers填任意手机号、TemplateCode填任意模板,由平台账号付费下发 → 短信轰炸、费用损失,并可用平台签名发钓鱼/诈骗短信(paramters内容也完全可控,见sms/send.go:107-109)。 - 聚合部署:
pkgs/all/etc/default_dev.yaml:24-44的Authorization.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: false(sender_prod.yaml:14)表示连注册都不做。
- 独立部署:gRPC(
- 建议:
- 模块内做兜底鉴权:gRPC
UnaryInterceptor+ gateway middleware 校验调用方身份(服务间 mTLS/内部 token,或authorizationmetadata + 业务侧校验手机号是否属于该 user_id)。 - 从
MicroService.Anonymous中移除 sender 三个方法;Anonymous只保留真正公开的登录类接口。 - 禁止请求方自由指定
sign_name/template_code:改为内部枚举(模板白名单表),或由服务端根据业务场景(登录/注册/改密)决定签名与模板;paramters只允许白名单 key + 长度/字符集校验。
- 模块内做兜底鉴权:gRPC
2. 每日发送上限的计数器从未写入 —— 限额完全失效(无 TTL、非原子)
- 位置:
module/base/sender/internal/logic/sms/send.go:41-51、module/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.Nil,Int()得到0,0 > 5恒为 false →Code.MaxSentLimit(dev/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=300s(etc/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:4与module/base/sender/internal/logic/sms/const.go:5同为/SMS/Code/,module/base/mgt/internal/logic/pub/forget.go:57-73正是读这个 key 做密码重置。
- 可爆破:6 位验证码仅 10^6 组合,
- 建议:
- 成功即删(
DEL放在code == in.Code分支内),实现一次性语义;失败分支不要删 key。 - 增加失败计数(同一 phone 或同一 phone+code 维度),如
INCR /SMS/VerifyFail/<phone>,超过 5 次即删除验证码并锁定 10 分钟;计数器与验证码同 TTL。 - 校验用
GETDEL(redis.Client.GetDel)保证「校验+删除」原子,避免并发重放。 - 校验接口也纳入问题 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/rand:crypto/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.Tencent在internal/impl/provider.go:46-47已真实初始化(NewTencent建了 SDK 客户端),因此只要配置了SMS.tencent,provider=tencent的调用会返回成功(Reply: "null",因为json.Marshal(nil map)→null),而短信根本没有发出:业务侧拿到成功却永远收不到验证码,且无任何日志/埋点暴露。同时 README:9 宣称「多服务商支持:阿里云短信、腾讯云短信」。精确空实现位置:internal/logic/sms/send.go:151-153。 - 建议:要么用
impl.Provider.Tencent真正实现SendSms(sms/v20210111的SendSms,把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-60、internal/logic/mail/send.go:31-34、test/grpc/mail_test.go:37、README.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]*SmtpConf,internal/config/config.go:26,31-38)、withProvider的google分支(impl/provider.go:35-36)、README:18/262-287 的「Gmail 等主流邮箱」以及扩展指引,都指向多渠道;实际只有qq。pkgs/all/etc/default_dev.yaml:99-106的渠道名是default,在switch里同样落到ErrNotProvider。多渠道抽象是假的。 SendMailRequest.IsGenCode(proto/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-164 用 Default(IntValue(runtime.ReadTimeout), IntValue(client.ReadTimeout)) 取值,而模块既没在 runtime 也没在 AliYunClient.Config(internal/impl/provider.go:68-71 只设 Endpoint/Credential)设置超时 → tea@v1.5.3/dara/core.go:354 得到 httpClient.Timeout = 0,即 Go 语义下的「无超时」。该 SDK 另提供 CallApiWithCtx(darabonba-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
- 影响:
- 连接泄漏:成功路径既不
client.Quit()也不conn.Close(),TCP/TLS 连接只能等 GC/finalizer 回收;每封成功邮件泄漏 1 个 fd(另有defer writer.Close()与显式writer.Close()的双重关闭)。在被批量调用时迅速耗尽本地端口/fd,表现为「突然发不出邮件」。 - 无超时:
tls.Dial使用默认 dialer(无超时),SMTP 端静默挂起会永久阻塞 gRPC 协程,且ctx参数在mail.Send中完全未使用。 - 同步发送: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, err;gRPC 默认不 recover handler panic,internal/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-147、module/base/sender/internal/impl/impl.go:26、module/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:18(printer.Info("... Databases: %v", cfg))、with/redis.go:14(printer.Info("... Cache: %s", cfg)),而 DSN 形如 etc/sender_dev.yaml:7,10:password=CHANGE_ME、redis://null:CHANGE_ME@...。另外 internal/impl/provider.go:60-61 把 AK/SK 明文塞进凭据对象,配置文件中 etc/sender_dev.yaml:40 还提交了真实 AccessKeyId(LTAI5tKEmKuuoixE4iw8NZbX)与真实企业邮箱账号(: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-39、internal/logic/sms/const.go:6、internal/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.BlackListFilter(internal/config/config.go:55,dev/prod/test 都配了 - 127.0.0.1)在整个模块中无任何引用。
- 影响:黑名单只有在有人手工往 Redis set 里塞数据时才生效,代码层面没有任何维护入口,也无法审计;同时因为丢弃了 error,Redis 不可用时黑名单判断静默放行(应为 fail-closed 或至少告警)。README:12 宣称「黑名单过滤:支持手机号黑名单机制」,实际是一个空壳。
- 建议:补齐黑名单管理能力(配置加载
BlackListFilter到 Redis、或提供内部管理 RPC),检查失败按策略处理(Redis 错误时拒绝发送并告警),把SIsMember(...).Result()的错误显式判断。
12. 验证码写入用 SetNX 但忽略返回值;用户传入的 paramters["code"] 会覆盖刚生成的验证码
- 位置:
module/base/sender/internal/logic/sms/send.go:65、internal/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 }
- 影响:
SetNX失败(同一手机号 300s 内二次发送)时,Redis 里仍是旧验证码,而用户手机收到的是新验证码 → 用户永远无法通过校验,且没有任何错误返回;同时err被忽略,Redis 故障也无声。- 调用方只要在
paramters里带code(send.go:66-72的非生成模式也需要它),就会覆盖模板里的验证码 → 下发的验证码与 Redis 中存储的验证码不一致(用户校验必失败),并且这个覆盖值完全由请求方决定(可用来在平台签名短信里塞任意内容,配合问题 1 构成钓鱼)。
- 建议:写入改为
Set(key, code, expire)(或先GETDEL/DEL再Set)并检查返回错误;把生成码与业务参数显式隔离:模板参数只从白名单 key 取值,code由服务端唯一注入,禁止被paramters覆盖;is_gen_code=false时也要明确「谁负责校验」或直接禁止该模式。
13. 配置缺失即 panic:Code 段/Redis 未配置时空指针崩溃(无配置校验、无 recover)
- 位置:
module/base/sender/internal/config/config.go:60-77、internal/logic/sms/send.go:45-57、internal/impl/impl.go:22-24 - 证据:
// config/config.go:65-70 —— 只校验 Service/Cache,Code、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返回 nil,见with/redis.go:9-17)时,首个请求就会 nil 解引用 panic;gRPC 无 recover(见问题 9)→ 进程退出。配置错误被推迟到运行时才以进程崩溃的形式暴露。 - 建议:在
config.New中用conf.NotNil(Spec.Code, Spec.SMS, Spec.SMTP)做启动期校验并为Length/Expire/MaxSentLimit设定安全默认值与边界(如 Expire>0、MaxSentLimit>0);Redis/DB 客户端在NewImpl中判空并 fail-fast(带明确错误信息),而不是留到请求路径。
14. 生产/测试配置不可用:SMS.aliyun.Endpoint 填成了 smtp.gmail.com,DB/Redis 指向本地且口令为 CHANGE_ME
- 位置:
module/base/sender/etc/sender_prod.yaml:38,7,10、module/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直接 panic,见with/databases.go:13-15)。配置与 README 的示例(README:88-92)也不一致。生产配置与开发配置几乎相同,说明该文件没有被真实部署验证过。 - 建议:prod 配置改为真实 Endpoint(
dysmsapi.aliyuncs.com)+ 密钥外置(环境变量/配置中心),数据库/缓存指向真实地址;在启动期对Endpoint做白名单校验(如必须匹配*.aliyuncs.com),避免此类拼写错误静默上线。
15. 接口返回语义与真实状态不一致:Verify 用 Reply:"false" + err=nil;Send 用 "null"/厂商业务错误当成功
- 位置:
module/base/sender/internal/logic/sms/verify.go:37-39、internal/logic/sms/send.go:91-99、internal/logic/sms/send.go:151-153 - 证据:
// verify.go:37-39 —— 校验失败时 err 为 nil,HTTP/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_CONTROL、isv.AMOUNT_NOT_ENOUGH)与null(腾讯空实现)都返回成成功。响应体直接回传云厂商原始 JSON,还会向调用方暴露账号/模板/请求 ID 等信息。 - 建议:
Verify失败返回明确错误码(如excode.ErrCode或ErrExpired),成功返回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-76;TencentCloud.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-18、module/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 options(internal/impl/impl.go:26),SDK 默认 IsAutoMigrate: false(D:\work\bsm-sdk\core\database\sql\postgresql.go:17),而 new.go:43-44 仅在 options.IsAutoMigrate 为真时执行 AutoMigrate;同时命名策略是 SingularTable: true(postgresql.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.MemorySerice(go-cache)已初始化却从未使用(见问题 21),缓存条件其实已经具备。 - 建议:用
MemorySerice/Redis 对模板做带版本的缓存(key = template_key,TTL 几十秒,或提供失效接口),并在发送侧复用 SMTP 连接(或至少限制并发连接数)。
21. 死代码与空壳实现较多,容易误导后续开发
- 位置:
internal/impl/provider.go:52-54、internal/impl/provider.go:20-25、internal/server/new.go:17,29、internal/impl/impl.go:16,22、internal/config/config.go:53-55、internal/excode/ex.go:8,11,14 - 证据:
// provider.go:52-54 —— 永远返回 nil 的构造函数
func NewSMTP(conf *config.SmtpConf) *smtp.Client {
return nil
}
其余:ProviderClient.Google/QQ(provider.go:22-23)永远是 nil,withProvider 的 google 分支(provider.go:35-36)赋 nil;Server.grpcConns「连接池」声明并初始化但从未使用(server/new.go:17,29);MemorySerice(impl.go:16,22,方法名本身是 Service 的拼写错误)从未使用;Code.CokeyKey/GenerateCode/BlackListFilter(config.go:53-55)无引用;ErrAppName/ErrMustWhiteList/ErrExpired(ex.go:8,11,14)无引用。
- 影响:空实现与死配置让阅读者(以及 README 的扩展指引)以为多渠道/白名单/内存缓存已经可用,实际都没有落地;
NewSMTP返回 nil 若被启用还会立刻 nil 解引用。 - 建议:删除或补齐:
NewSMTP及Google/QQ字段直接删除(邮件走SMTPSender统一实现);grpcConns删除;未使用的配置项与错误码删除或补上实现与测试。
22. 错误被吞掉或错误映射失真
- 位置:
internal/logic/sms/send.go:95,156、internal/logic/sms/send.go:37、internal/logic/mail/send.go:124,146-150、internal/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 的真实故障被掩盖成语义错误的返回值(「模板不存在」「发送成功」),现场排查困难;
SetNX、SIsMember().Val()、json.Marshal、regexp的忽略错误在问题 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-48、module/base/sender/internal/logic/sms/send.go:96、module/base/sender/internal/logic/mail/send.go:84,92,100、README.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-115 的 srv.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(全模块 grephealth无匹配),聚合侧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 网关从未注册业务 handler(Mux 恒为 nil)
- 位置:
module/base/sender/internal/server/new.go:13-30、module/base/sender/cmd/main/main.go:29,40、module/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(...)到 mux;cmd/main独立启动路径既不注册 handler 也不创建 mux,SDK 侧http.ListenAndServe(addr, mux)拿到的是 nil*runtime.ServeMux,任何 HTTP 请求都会走到 nil 接收者解引用(grpc-gateway/v2@v2.30.0/runtime/mux.go:418访问s.unescapingMode)→ 请求级 panic/连接被断开,README与test/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-100、module/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-129、internal/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/FromName(README: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)、Date、Message-ID;发送前用mail.ParseAddress的Address字段做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 实体转义(&→&、<→<)。当模板用于纯文本邮件(且报文头也没有声明text/html)时,用户会看到&之类的乱码内容;反过来若改用text/template则失去 HTML 转义保护。模板类型与转义策略没有建模。 - 建议:为模板增加类型/内容类型字段,按类型选择
html/template或text/template,并把Content-Type与之一致;对 HTML 模板保留转义(当前html/template对参数转义是正确做法)。
28. 渠道抽象缺失:switch 硬编码,README 的扩展指引与实现不符
- 位置:
internal/logic/sms/send.go:76-89、internal/logic/mail/send.go:55-60、README.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)/注册表抽象,渠道名与实现散落在switch、withProvider的switch、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-392、UPGRADE_SUMMARY.md:60-68 - 证据:
README.md:149-150
- **gRPC服务**:`localhost:12201`
- **HTTP Gateway**:`localhost:12202`
实际端口是 12208/12207(etc/sender_prod.yaml:2,23);README:155 写的 POST /v1/sms/send,实际路由是 /sender.Sms/Send(pb/sms.pb.gw.go:216、pb/mail.pb.gw.go:152);README:363-365 说 Code.MaxSentLimit 默认 10,配置里是 5(etc/sender_prod.yaml:46);README: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:18、proto/mail.proto:16、internal/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:32),Subjet(应为 Subject)已固化进数据库列名;impl.MemorySerice(internal/impl/impl.go:16)同样拼错。
- 影响:协议字段拼写错误一旦上线难以单方面修正(需兼容期双字段);列名拼写错误在库里永久存在。
- 建议:
Subjet与MemorySerice在本次无外部兼容负担时直接更正;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}允许总长 7–11 位(如1301234),非法号码也能通过校验并真实提交到云厂商(每条都可能计费/失败计数),浪费额度与配额;不支持国际号码(+86/00前缀)与PhoneNumbers多号码批量。 - 建议:收紧为
^1[3-9]\d{9}$(必要时再单独支持 E.164 国际格式),并把号码规范化(去空格/+86)后再送厂商。
32. 限流窗口使用进程本地时区
- 位置:
module/base/sender/internal/logic/sms/send.go:42、internal/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 DSNetc/sender_prod.yaml:7)不一致,会造成「凌晨 8 小时窗口重叠/提前重置」的计数错乱(在问题 2 修复后才会显现)。同理,Code.Expire用time.Second * Expire尚可,但没有任何时间来源统一约定。 - 建议:统一显式加载
time.LoadLocation("Asia/Shanghai")并用time.Now().In(loc);或直接以滑动窗口(INCR+EXPIRE 24h)替代「自然日」键,避免时区语义。
33. 模板参数无白名单/长度限制,且把云厂商原始响应回传调用方
- 位置:
module/base/sender/internal/logic/sms/send.go:107-109、internal/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 的放大面);响应体直接回传厂商 JSON(Reply: 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_logfile无stdout_logfile_maxbytes/轮转配置,日志无限增长(配合问题 10 的明文验证码日志更严重);无stopasgroup/killasgroup与优雅停止信号约定(见问题 23)。 - 建议:改用非特权用户运行、配置日志轮转、显式声明
stopsignal=TERM与优雅停止超时。
4. 推荐优化方案
按「先堵安全、再修正确性、后补可观测与工程化」推进:
- 访问控制与配额(P0-1/2/16)
- 模块内加鉴权拦截器(gRPC
UnaryInterceptor+ gateway middleware):校验内部服务凭据或用户 JWT,并校验「手机号/邮箱是否属于该用户」;MicroService.Anonymous与pkgs/all的Authorization.Anonymous中移除 sender 三个方法。 - 签名/模板白名单化:
sign_name/template_code/template_key由服务端按业务场景决定,paramters走 key 白名单 + 长度校验。 - 配额与频控落地:
INCR+EXPIRE原子计数(手机号/日、手机号/小时、手机号/60s、账号/日、IP/日),超限返回明确错误码;配额在发送前占用、失败回滚。
- 模块内加鉴权拦截器(gRPC
- 验证码安全(P0-3/4、P1-12)
crypto/rand生成;Set覆盖写并检查错误;code参数服务端独占注入,禁止被paramters覆盖。- 校验用
GETDEL一次性消费;失败计数 + 锁定;失败不删 key;返回明确错误码而非"false"+nil。
- 外部调用健壮性(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 操作加超时)。
- 短信:
- 配置与密钥(P1-10/13/14)
- 启动期校验
Code/SMS/SMTP,Endpoint白名单校验;口令/AK 全部外置(环境变量/配置中心)并轮换已入库凭据;日志脱敏 + 结构化;删除验证码明文打印。
- 启动期校验
- 可观测与运维(P2-23/24/25、P3-34)
- 信号处理 +
GracefulStop、grpc_health_v1+/healthz、panic recovery、结构化日志(request_id/渠道/耗时/结果)、Prometheus 指标(发送量、成功率、各渠道延迟、验证码失败率)、发送流水表支持对账。
- 信号处理 +
- 工程化(P2-19/20/29、P3-29/30)
- 迁移改为显式命令;模板缓存;补单元测试(
GenValidateCode/VerifyPhone/ValidateEmail/模板渲染/限额与验证码状态机)+ 接口 mock;更正拼写;README 与实现对齐(端口、路径、渠道、默认限额)。
- 迁移改为显式命令;模板缓存;补单元测试(
5. TODO 清单
- P0-1 给
Sms.Send/Verify、Mail.Send加身份与归属校验,并从两份Anonymous列表移除这三个方法|验收:未带凭据调用返回鉴权错误;带 A 用户凭据给非 A 的手机号/邮箱发送被拒绝|涉及:module/base/sender/internal/logic/sms/send.go:26、module/base/sender/internal/logic/mail/send.go:22、module/base/sender/etc/sender_prod.yaml:15、pkgs/all/etc/default_dev.yaml:24 - P0-2 用
INCR+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:55、module/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:89、module/base/sender/internal/server/new.go:23 - P1-10 清理敏感日志(验证码/模板参数/DSN 口令)并改结构化日志|验收:grep 日志输出无验证码、无
password=、无 AK/SK;启动日志口令为掩码|涉及:module/base/sender/internal/logic/sms/send.go:96、module/base/sender/internal/impl/impl.go:26 - P1-11 黑名单机制落地:配置/管理入口写入 Redis set,检查失败按 fail-closed 处理|验收:把号码加入黑名单后发送返回
ErrInBlackList;Redis 故障时拒绝发送并告警|涉及:module/base/sender/internal/logic/sms/send.go:37、module/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 error,Send 解析并映射厂商结果|验收:错误验证码返回错误码;业务失败不被当成成功|涉及:
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 内退出且无中断中的发送;
/healthz200|涉及: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-ID,
Rcpt用解析后地址)|验收:中文主题与正文在 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:150、UPGRADE_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 个动作:
- 给
Sms.Send/Verify、Mail.Send加身份+归属校验并从Anonymous列表移除,同时把「签名/模板/参数」改为服务端白名单。 - 用
INCR+EXPIRE真正落地手机号/账号/IP 配额与频控(当前LimitCacheKey只有读、没有写)。 - 重写验证码生命周期:
crypto/rand生成、Set覆盖并检查错误、GETDEL一次性消费、失败计数与锁定、失败不删 key,并为验证码/口令做日志脱敏。
- 给
- 未能覆盖/无法验证的部分:
- 未实跑服务:本机无 Redis/PostgreSQL/短信账号,全部结论来自静态阅读;
gofmt -l .与go vet ./...均无输出(exit 0),未执行-tags integration的集成测试(其指向真实 IP/域名并会发真实短信邮件)。 api.apinb.com网关(etcd 匿名列表的实际消费方)不在本工作区,「Anonymous 生效后即无鉴权放行」为推测;已在报告中标注。- 腾讯云 SMS 空实现的实际影响范围取决于部署是否真的配置了
SMS.tencent(仓库内三份 yaml 均未配置,仅代码路径存在)。 - 阿里云超时结论基于本机 module cache 中
darabonba-openapi/v2@v2.1.12与tea@v1.5.3的实现链(Default(0,0)→httpClient.Timeout=0);若线上实际拉取的 SDK 版本不同,超时默认值可能不同。 - 未做动态内存/fd 泄漏与压测验证(SMTP 连接泄漏、goroutine 堆积为静态推断)。
pb/const.pb.go中大量与本模块无关的共享消息(market/order/cms 等)仅做存在性核对,未逐字段审计。
- 未实跑服务:本机无 Redis/PostgreSQL/短信账号,全部结论来自静态阅读;