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.
717 lines
62 KiB
Markdown
717 lines
62 KiB
Markdown
# 审计报告: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 里塞数据时才生效,代码层面没有任何维护入口,也无法审计;同时因为丢弃了 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`
|
||
- **证据**:
|
||
```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/Cache,Code、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 解引用 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`
|
||
- **证据**:
|
||
```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 为 nil,HTTP/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_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`
|
||
- **证据**:
|
||
```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 也不创建 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/幂等键/发送流水表)
|
||
- **证据**:
|
||
```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 实体转义(`&` → `&`、`<` → `<`)。当模板用于纯文本邮件(且报文头也没有声明 `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`
|
||
- **证据**:
|
||
```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}` 允许总长 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`
|
||
- **证据**:
|
||
```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 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 内退出且无中断中的发送;`/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 等)仅做存在性核对,未逐字段审计。
|