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

717 lines
62 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 审计报告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`)。
运行时形态有两种:
1. **独立进程**`cmd/main/main.go``server.New(nil)` + SDK `core/service.New(...)`,配置取 `etc/sender_{dev,test,prod}.yaml`gRPC 12208 / Gateway 12207
2. **聚合进程**`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`
- **证据**
```go
// 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
}
```
```go
// sms/send.go:114-118 —— 签名与模板完全由请求方指定,直接下发到阿里云
"SignName": tea.String(cast.ToString(args.SignName)),
"TemplateCode": tea.String(cast.ToString(args.TemplateCode)),
```
```yaml
# etc/sender_prod.yaml:13-18 —— 三个接口被配置为匿名
MicroService:
Enable: false
Anonymous:
- sender.Mail.Send
- sender.Sms.Send
- sender.Sms.Verify
```
```go
// internal/server/new.go:21-23 —— 独立进程 gRPC 无任何 UnaryInterceptor
standalone := grpcServ == nil
if standalone {
grpcServ = grpc.NewServer()
```
```go
// 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`)表示连注册都不做。
- **建议**
1. 模块内做兜底鉴权gRPC `UnaryInterceptor` + gateway middleware 校验调用方身份(服务间 mTLS/内部 token`authorization` 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-51``module/base/sender/internal/logic/sms/const.go:7`
- **证据**
```go
// 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` 全文件无频控)。
- **建议**:改为原子自增 + 首次写入设置过期:
```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`
- **证据**
```go
// 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)
```
```go
// 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 做密码重置。
- **建议**
1. 成功即删(`DEL` 放在 `code == in.Code` 分支内),实现一次性语义;失败分支**不要**删 key。
2. 增加失败计数(同一 phone 或同一 phone+code 维度),如 `INCR /SMS/VerifyFail/<phone>`,超过 5 次即删除验证码并锁定 10 分钟;计数器与验证码同 TTL。
3. 校验用 `GETDEL``redis.Client.GetDel`)保证「校验+删除」原子,避免并发重放。
4. 校验接口也纳入问题 1 的鉴权与频控,并对同一手机号做全局失败率限制。
#### 4. 验证码使用 `math/rand` 且以时间戳播种,可预测
- **位置**`module/base/sender/internal/logic/sms/send.go:161-176`
- **证据**
```go
// 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`
- **证据**
```go
// send.go:151-153 —— 直接返回 nil,nil不发任何请求
func TencentSender(args *pb.SmsSendRequest) (map[string]any, error) {
return nil, nil
}
```
```go
// 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`
- **证据**
```go
// 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
}
```
```go
// mail/send.go:31-34 —— SMTP 配置是 map看起来支持多渠道路由但 gmail 会先在这里 404
cfg, ok := config.Spec.SMTP[provider]
if !ok || cfg == nil { return nil, excode.ErrProviderIsNil }
```
```go
// 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`
- **证据**
```go
// 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`
- **证据**
```go
// 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 })
```
```go
// 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`
- **证据**
```go
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`
- **证据**
```go
// 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)
```
```go
// 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`
- **证据**
```go
// 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 里塞数据时才生效,代码层面没有任何维护入口,也无法审计;同时因为丢弃了 errorRedis 不可用时黑名单判断**静默放行**(应为 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`
- **证据**
```go
// 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)
```
```go
// 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` 里带 `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`
- **证据**
```go
// 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)
```
```go
// 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 解引用 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.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`
- **证据**
```yaml
# etc/sender_prod.yaml:36-41 —— 阿里云短信的 Endpoint 是 gmail 的 SMTP 地址(复制粘贴错误)
SMS:
aliyun:
Endpoint: smtp.gmail.com
AccessKeyId: <your-access-key-id>
```
```yaml
# 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`
- **证据**
```go
// verify.go:37-39 —— 校验失败时 err 为 nilHTTP/gRPC 层面都是成功响应
return &pb.SmsReply{
Reply: "false",
}, nil
```
```go
// 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`
- **证据**
```go
// 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`
- **证据**
```go
// 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`
- **证据**
```go
// 每次发送都在读路径上写全局配置
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`
- **证据**
```go
// 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`
- **证据**
```go
// 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_keyTTL 几十秒,或提供失效接口),并在发送侧复用 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`
- **证据**
```go
// 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`
- **证据**
```go
// sms/send.go:95 与 mail/send.go:124 —— 错误被丢弃
jsonBytes, _ := json.Marshal(result)
defer writer.Close() // 与 mail/send.go:146 的显式 Close 形成双重关闭
```
```go
// sms/send.go:155-158 —— regexp 的 error 被丢弃
result, _ := regexp.MatchString(`^(1[3|4|5|6|7|8|9][0-9]\d{4,8})$`, phone)
```
```go
// 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`
- **证据**
```go
// 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全模块 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 网关从未注册业务 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`
- **证据**
```go
// server/new.go:26-30 —— Server.Mux 从未被赋值(同文件全文无 srv.Mux = ...
srv := &Server{ Ctx: context.Background(), Grpc: grpcServ, grpcConns: make(map[string]*grpc.ClientConn) }
```
```go
// 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 也不创建 muxSDK 侧 `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/幂等键/发送流水表)
- **证据**
```go
// sms/send.go:76-93 —— 无幂等键,同一请求重放即再发一条短信
switch strings.ToLower(in.GetProvider()) {
case "aliyun":
...
result, err = AliyunSender(in, smsCode)
```
```go
// 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`
- **证据**
```go
// 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"
```
```go
// 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`
- **证据**
```go
import "html/template"
...
tmpl, err := template.New("page").Parse(tplRecord.Body)
```
- **影响**`html/template` 会对参数做 HTML 实体转义(`&``&amp;``<``&lt;`)。当模板用于纯文本邮件(且报文头也没有声明 `text/html`)时,用户会看到 `&amp;` 之类的乱码内容;反过来若改用 `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`
- **证据**
```go
// 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`
- **证据**
```markdown
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`
- **证据**
```protobuf
// proto/sms.proto:18
map<string,string> paramters=5; // 验证码相关
```
```go
// 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`
- **证据**
```go
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:42``internal/logic/sms/const.go:4`
- **证据**
```go
// 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.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`
- **证据**
```go
// 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`
- **证据**
```ini
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. 推荐优化方案
按「先堵安全、再修正确性、后补可观测与工程化」推进:
1. **访问控制与配额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/日),超限返回明确错误码;配额在发送前占用、失败回滚。
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/SMTP``Endpoint` 白名单校验;口令/AK 全部外置(环境变量/配置中心)并轮换已入库凭据;日志脱敏 + 结构化;删除验证码明文打印。
5. **可观测与运维P2-23/24/25、P3-34**
- 信号处理 + `GracefulStop``grpc_health_v1` + `/healthz`、panic recovery、结构化日志request_id/渠道/耗时/结果、Prometheus 指标(发送量、成功率、各渠道延迟、验证码失败率)、发送流水表支持对账。
6. **工程化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 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-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 个动作:
1.`Sms.Send/Verify``Mail.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.12``tea@v1.5.3` 的实现链(`Default(0,0)``httpClient.Timeout=0`);若线上实际拉取的 SDK 版本不同,超时默认值可能不同。
5. 未做动态内存/fd 泄漏与压测验证SMTP 连接泄漏、goroutine 堆积为静态推断)。
6. `pb/const.pb.go` 中大量与本模块无关的共享消息market/order/cms 等)仅做存在性核对,未逐字段审计。