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.
This commit is contained in:
2026-09-14 22:16:27 +08:00
parent fc1a64f9dd
commit 63aeedc2fe
19 changed files with 12724 additions and 0 deletions

View File

@@ -0,0 +1,716 @@
# 审计报告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 等)仅做存在性核对,未逐字段审计。