我不会起名字322 · 后端 / 算法 / 数据库
文章目录
- [req.Seqslen-1:空切片防御缺口与 RPC 二次校验](#req.Seqs[len-1]:空切片防御缺口与 RPC 二次校验)
-
- 那一行到底长什么样
- 参数校验为什么放过去了
- [空数组经过 API 校验和 RPC 二次校验](#空数组经过 API 校验和 RPC 二次校验)
- [如果业务函数发生 panic:中间件如何处理](#如果业务函数发生 panic:中间件如何处理)
- [你以为的 500 不是 HTTP 500](#你以为的 500 不是 HTTP 500)
- [对照实验:空数组和 null](#对照实验:空数组和 null)
- 同一文件里的第二个问题:没有上限的循环
- 未读数为什么会变成负数
- 修法三档
- 小结
req.Seqslen-1:空切片防御缺口与 RPC 二次校验
internal/rpc/msg/as_read.go:112 的 hasReadSeq := req.Seqs[len(req.Seqs)-1] 对空切片会越界。直接调用该业务函数或绕过 RPC 校验时,如果权限与查库条件也满足,就可能触发这个缺口;RPC 中间件只会捕获其内层同一 goroutine 的 panic。
默认 HTTP→RPC 链路需要分两段看:JSON 中的 "seqs": [] 是非 nil 空切片,可以通过 API 侧只检查 nil 的 Check();经过 protobuf 传输后,空 repeated 字段反序列化为 nil,预计会被 RPC 服务端再次调用的 Check() 拦截。这个判断来自静态源码分析,具体网络响应及默认链路是否触发 panic 尚未实测。
那一行到底长什么样
先把现场贴出来。as_read.go:100-115 是完整的入口,注释是作者自己写的警告:
go
// internal/rpc/msg/as_read.go:100-115
// MarkMsgsAsRead 把一组消息标记为已读。
//
// 注意:这里直接取 req.Seqs 的最后一个元素作为新的已读 seq,
// 调用方必须保证 Seqs 非空,否则会数组越界 panic。
func (m *msgServer) MarkMsgsAsRead(ctx context.Context, req *msg.MarkMsgsAsReadReq) (*msg.MarkMsgsAsReadResp, error) {
if err := authverify.CheckAccess(ctx, req.UserID); err != nil {
return nil, err
}
maxSeq, err := m.MsgDatabase.GetMaxSeq(ctx, req.ConversationID)
if err != nil {
return nil, err
}
hasReadSeq := req.Seqs[len(req.Seqs)-1]
if hasReadSeq > maxSeq {
return nil, errs.ErrArgs.WrapMsg("hasReadSeq must not be bigger than maxSeq")
}
顺序很关键:进入业务函数后依次执行 CheckAccess(:105)→ GetMaxSeq(:108)→ 索引(:112)→ GetConversation(:116)。只有绕过前置校验、且权限检查与查询均成功的空切片调用才会到达越界位置;不能把这种代价直接归给默认 HTTP 空数组请求。
我把 Seqs 的语义也核对了一遍:它本该是"这次要标记为已读的消息 seq 列表",hasReadSeq 取最后一个元素是假设调用方已经排序。这个假设在 MarkConversationAsRead 里是成立的(那边是自己构造的连续序列),但在 MarkMsgsAsRead 里,Seqs 直接来自客户端 JSON。信任边界画错了。
参数校验为什么放过去了
这是我认为整件事里最值得复述的一段。MarkMsgsAsReadReq.Check() 在 protocol 的 msg/msg.go 里,全文只有十几行:
go
// protocol@v0.0.73-alpha.19 msg/msg.go:109-125
func (x *MarkMsgsAsReadReq) Check() error {
if x.ConversationID == "" {
return errors.New("conversationID is empty")
}
if x.Seqs == nil {
return errors.New("seqs is empty")
}
if x.UserID == "" {
return errors.New("userID is empty")
}
for _, seq := range x.Seqs {
if seq == 0 {
return errors.New("seqs has 0 value is invalid")
}
}
return nil
}
x.Seqs == nil 和 len(x.Seqs) == 0 在 Go 里是两件事。前者拦住 nil,包括新请求中字段缺席或为 null 的情况;后者还拦住非 nil 的空切片。这个循环 for _, seq := range x.Seqs 在空切片上转 0 次,也不报错。
什么样的请求能造出"非 nil 的空切片"?下表只讨论 JSON 绑定后 API 侧第一次 Check() 对 Seqs 的检查,假设其他必填字段均有效;后续 RPC 侧还有二次校验:
| 请求体 | 反序列化后 Seqs |
== nil? |
len() |
能否通过 Check |
|---|---|---|---|---|
{} |
nil | 是 | 0 | 否,被拦 |
{"seqs": null} |
nil | 是 | 0 | 否,被拦 |
{"seqs": []} |
[]int64{} |
否 | 0 | 是,放行 |
{"seqs": [0]} |
[0] |
否 | 1 | 否,被 0 值检查拦 |
{"seqs": [3,5]} |
[3,5] |
否 | 2 | 是,正常 |
注意第 3 行和第 4 行的对比:带了 0 会被拦,什么都不带反而是最危险的。这种"越空越安全"的直觉在这里正好是反的。
encoding/json 的行为是确定的:目标字段类型是 []int64(切片),JSON 里的 [] 会分配一个长度 0 的切片,指针非 nil;而字段整个缺席或显式 null 时,切片保持 nil。这不是某个库的怪癖,是 encoding/json 的既有语义。
空数组经过 API 校验和 RPC 二次校验
参数校验确实执行了,只是没拦住。调用链在 a2r.Call:
go
// tools@v0.0.50-alpha.126 a2r/api2rpc.go:25,44-46
func Call[A, B, C any](c *gin.Context, rpc func(client C, ctx context.Context, req *A, options ...grpc.CallOption) (*B, error), client C, opts ...*Option[A, B]) {
// ...
req, err := ParseRequestNotCheck[A](c) // :26 ------ 只做 JSON 绑定
// ...
if err := checker.Validate(req); err != nil { // :36
apiresp.GinError(c, err) // :37
return
}
三个环节都要看清:
ParseRequestNotCheck(api2rpc.go:61-67)只调c.ShouldBindWith(&req, jsonBind),绑定失败才返回 400 语义的错误。它不校验业务字段 ,名字里的NotCheck就是这个意思。checker.Validate(checker/check.go:9-19)会调req.Check()------校验跑了,第 3 行那个{"seqs":[]}从它手里过去了。rpc(...)发出 gRPC 请求。protobuf 对空 repeated int64 不编码该字段,服务端解码后是 nil;pkg/common/startrpc/start.go:82-87安装的GrpcServerRequestValidate再调用checker.Validate,同一个Check()因 nil 拒绝它。
因此 API 侧判断条件确实不足以检查空切片,但默认 RPC 链路还有第二次校验,不能据此推断业务函数发生越界。上述序列化及拦截是静态分析,完整请求和响应仍待实测。
HTTP 入口在 internal/api/router.go:287:
go
// internal/api/router.go:287-288
msgGroup.POST("/mark_msgs_as_read", m.MarkMsgsAsRead)
msgGroup.POST("/mark_conversation_as_read", m.MarkConversationAsRead)
internal/api/msg.go:183-185 的 handler 调用 a2r.Call(c, msg.MsgClient.MarkMsgsAsRead, m.Client);合法 token 和 conversationID 不能让空数组绕过后续 RPC 校验。
如果业务函数发生 panic:中间件如何处理
下面讨论的是业务函数确实被调用且发生 panic 的条件路径,不是默认 HTTP 空数组请求已经触发的证据。
pkg/common/startrpc/start.go:82-88 装配了 gRPC 的服务端中间件链,先添加的在外层:
go
// pkg/common/startrpc/start.go:83-87
options = append(options,
grpcsrv.GrpcServerMetadataContext(),
grpcsrv.GrpcServerErrorConvert(),
grpcsrv.GrpcServerLogger(),
grpcsrv.GrpcServerRequestValidate(),
grpcsrv.GrpcServerPanicCapture(),
)
GrpcServerPanicCapture 在最内层,紧贴业务 handler,所以它是最先拿到 panic 的那一个。tools/mw/grpc/server/panic.go:11-27 的完整实现:
go
// tools@v0.0.50-alpha.126 mw/grpc/server/panic.go:11-27
func GrpcServerPanicCapture() grpc.ServerOption {
return grpc.ChainUnaryInterceptor(func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) {
hasPanic := true
defer func() {
if !hasPanic {
return
}
if r := recover(); r != nil {
err = errs.ErrPanic(r)
log.ZPanic(ctx, "rpc server panic", err, "method", info.FullMethod, "req", req)
}
}()
resp, err = handler(ctx, req)
hasPanic = false
return
})
}
recover() 之后 err = errs.ErrPanic(r),panic 变成返回值。errs/panic.go:8-10 把任意 panic 值翻译成 code 500:
go
// tools@v0.0.50-alpha.126 errs/panic.go:8-10
func ErrPanic(r any) error {
return ErrPanicMsg(r, ServerInternalError, "panic error", 9)
}
ServerInternalError 在 errs/predefine.go:5 定义为 500。所以 panic 的最终形态是一个 code=500 的 CodeError ,不是一个死掉的进程。外层的 GrpcServerErrorConvert(mw/grpc/server/error.go:24-33)再把它转成 gRPC status:
go
// tools@v0.0.50-alpha.126 mw/grpc/server/error.go:42-43
func codeErrorToGrpcError(ctx context.Context, codeErr errs.CodeError) error {
grpcStatus := status.New(codes.Code(codeErr.Code()), codeErr.Msg())
codes.Code(500) 落在 gRPC 的私有区间(合法 codes 只到 16),但 status.New 不校验,照样构造成功并透传。客户端 mw/grpc/client/error.go:45-54 那边有个反向保护:if sta.Code() < 100 { return errs.ErrInternalServer.WrapMsg(...) }------500 大于 100,所以不会被压成 13(Internal),而是原样还原成 errs.NewCodeError(500, "panic error")。这个 < 100 的判断就是为了让私有码通过。
对于该拦截器内层同一 goroutine 的业务 panic,预期经过 panic → recover → code 500 → code 500 ,并记录 ZPanic。它不覆盖其他 goroutine 或外层中间件的所有 panic;具体响应仍需实测。
你以为的 500 不是 HTTP 500
这一段很容易踩坑,我自己一开始也写错了。
API 层拿到 err 之后走 apiresp.GinError(a2r/api2rpc.go:44-48),最终落到 apiresp/gin.go:13-16:
go
// tools@v0.0.50-alpha.126 apiresp/gin.go:13-16
func ginJson(c *gin.Context, resp *ApiResponse) {
c.Set(ginApiResponseKey, resp)
c.JSON(http.StatusOK, resp)
}
经过这个统一 JSON 响应函数的业务错误使用 HTTP 200;其他错误路径不一定如此。ApiResponse 的字段是 errCode / errMsg / errDlt / data(apiresp/resp.go:12-17)。若业务 panic 经上述中间件转为错误并走到此响应函数,响应可能类似下面的示意,详情以实测为准:
json
{
"errCode": 500,
"errMsg": "panic error",
"errDlt": "runtime error: index out of range [-1]",
"data": null
}
对比一下 gin 自己那层的 panic 处理,tools/mw/gin.go:108-110:
go
// tools@v0.0.50-alpha.126 mw/gin.go:108-110
func GinPanicErr(c *gin.Context, err any) {
log.ZPanic(c, "GinPanicErr panic", errs.ErrPanic(err))
c.AbortWithStatus(http.StatusInternalServerError)
}
这条 Gin panic 路径返回 HTTP 500。假设 as_read 的业务 panic 在 im-msg 被对应拦截器捕获,才可能走前面的统一业务错误响应;默认空数组请求目前没有证明会到达该位置。两件事必须分开说:
| 层 | 谁 panic | HTTP 状态码 | 错误码在哪 | 进程是否存活 |
|---|---|---|---|---|
| gin 中间件 | API 进程内 | 500 | 无 body | API 进程存活 |
| 假设业务 panic 被 RPC 拦截器捕获并经统一响应返回 | RPC 进程内 | 200 | body.errCode 预计为 500 | 该 panic 不会继续传播 |
| 若没 panic 兜底 | RPC 进程内 | ------ | ------ | RPC 进程退出 |
第三行假定没有其他 recover:未捕获的 goroutine panic 会终止进程。排查时应区分业务码与 HTTP 状态码,统一业务响应的 HTTP 200 不会计入仅看 HTTP 5xx 的告警。
监控应检查实际部署的指标与标签。仓库 pkg/common/prommetrics/api.go 定义的是 api_count,包含 code、path、method 标签;监控监听配置见 config/im-api.yml。例如在实际 Prometheus 查询界面按路径、方法和 code 查询 sum by (code, path, method) (rate(api_count[5m])),指标前缀、标签值和查询服务地址以部署为准。也可以结合日志:
bash
grep -c 'rpc server panic' logs/im-msg*.log
grep 'rpc server panic' logs/im-msg*.log | grep -o 'method=[^ ]*' | sort | uniq -c | sort -rn
第二条命令依赖实际日志格式包含 method=;还需结合堆栈、请求上下文和校验路径确认具体原因,方法名本身不能证明空数组触发越界。
对照实验:空数组和 null
下面是待执行的对照实验,不是已经复现 panic 的记录。先按实际部署确认 API 地址、管理员凭据和用户;仓库示例配置的 API 端口为 10002:
bash
TOKEN=$(curl -s -X POST 'http://127.0.0.1:10002/auth/get_user_token' \
-H 'Content-Type: application/json' -H 'operationID: read-token-probe' -H "token: $ADMIN_TOKEN" \
-d '{"platformID":1,"userID":"panic-probe"}' \
| python -c 'import sys,json;print(json.load(sys.stdin)["data"]["token"])')
curl -s -X POST 'http://127.0.0.1:10002/msg/mark_msgs_as_read' \
-H 'Content-Type: application/json' -H 'operationID: read-empty-probe' -H "token: $TOKEN" \
-d '{"userID":"panic-probe","conversationID":"si_panic-probe_admin","seqs":[]}'
做对照实验时把 "seqs":[] 换成 "seqs":null,记录 HTTP 状态、响应体和两侧日志。在鉴权及其他前置条件满足时,静态推断 null 在 API 校验被拒,[] 在 RPC 二次校验被拒;具体错误码和响应尚未实测,不能预先宣称 [] 返回 500。
conversationID 应使用实际初始化数据中的有效值;无效输入或权限、查库失败可能影响观察。业务函数内 GetMaxSeq 在索引前,GetConversation 在索引后,因此不能用后者解释为何到不了索引。
还需验证乱序请求的业务语义。这里取 Seqs 的最后一个元素作为候选已读进度,而不是取最大值:例如 [10, 3] 的候选值是 3。只有请求通过权限、会话和序号等前置检查,且当前已读进度小于 3 时,才可能推进到 3;若当前进度不小于 3,则不会按这个分支推进。是否应按最大值处理,以及对单条消息已读状态的最终影响,还需结合完整处理链和实际请求验证。
同一文件里的第二个问题:没有上限的循环
MarkConversationAsRead 是隔壁那个接口,as_read.go:152 起。它没有越界风险,但有另一种问题。单聊分支:
go
// internal/rpc/msg/as_read.go:167-177
if conversation.ConversationType == constant.SingleChatType {
for i := hasReadSeq + 1; i <= req.HasReadSeq; i++ {
seqs = append(seqs, i)
}
// 客户端显式传入的 seq 也要标成已读(可能不在连续区间内)。
for _, val := range req.Seqs {
if !datautil.Contain(val, seqs...) {
seqs = append(seqs, val)
}
}
hasReadSeq 来自 Redis(:160 的 GetHasReadSeq),req.HasReadSeq 来自请求体。两者之间差多少,循环就跑多少次,seqs 就长多大。而 MarkConversationAsReadReq.Check()(msg/msg.go:127-142)只要求 x.HasReadSeq >= 1,没有任何上界。
如果某个用户在一个有 5 万条消息的会话里从 seq 0 拉到 seq 50000,这个 for 会 append 5 万个 int64(元素数据约 400 KB,不含切片扩容等开销),然后传给 MarkSingleChatMsgsAsRead(:180)。这是 O(n) 的内存与后续处理开销;并发或极端区间可能耗尽资源,实际写入量和延迟需要测量。
更麻烦的是 datautil.Contain(val, seqs...) 在第二个循环里------它在 seqs 长度为 n 时线性扫一遍,外层再跑一次,于是这一段是 O(n·m)。注释说这个循环是为了处理"不连续的 seq",也就是正常路径下 m 很小;但异常输入下 m 也可以很大。
对照 MarkMsgsAsRead,两个接口的问题形状完全不同:
| 接口 | 触发条件 | 后果 | 是否进程崩溃 |
|---|---|---|---|
mark_msgs_as_read |
业务函数收到空切片且前置条件满足 | 索引越界;默认 HTTP→RPC 空数组预计先被校验拒绝 | RPC 内同 goroutine panic 可被对应拦截器捕获 |
mark_conversation_as_read |
hasReadSeq 与 HasReadSeq 差距巨大 |
内存及处理开销增大 | 极端情况下可能耗尽资源 |
get_conversations(下一个问题) |
已读 seq 大于 maxSeq | 算出负数;前端表现待验证 | 不能仅据此推断 |
未读数为什么会变成负数
顺手把第三个接口也查了,因为它是同一条链的下游。internal/rpc/conversation/conversation.go:191-197:
go
// internal/rpc/conversation/conversation.go:191-197
var unreadTotal int64
conversation_unreadCount := make(map[string]int64)
for conversationID, maxSeq := range maxSeqs {
unreadCount := maxSeq - hasReadSeqs[conversationID]
conversation_unreadCount[conversationID] = unreadCount
unreadTotal += unreadCount
}
unreadCount = maxSeq - hasReadSeq,没有 max(0, ...),也没有 min(hasReadSeqs, maxSeq)。正常情况下 hasReadSeq 不会超过 maxSeq(MarkMsgsAsRead 在 :113 校验了这一点),但有两处会绕过:
MarkConversationAsRead的单聊分支不校验req.HasReadSeq是否超过 maxSeq------它只比较 "是否比当前 hasReadSeq 大"(:184),然后直接SetHasReadSeq。客户端报一个大数,已读 seq 就被推到一个不存在的位置。- 如果其他路径使 maxSeq 回退而历史 hasReadSeq 不变,也可能形成同样的关系;删除或清理是否产生这种效果需要另行验证。
在 hasReadSeq > maxSeq 期间,这段计算会得到负数并拉低 unreadTotal;当 maxSeq 追上时可能恢复。前端是否展示负数或隐藏红点取决于客户端实现,尚未验证。
这三个问题串起来看,有一个共同的形状:入参的语义边界("空"、"有序"、"不超过最大值")被当成了调用方的义务,而服务端只做了部分检查。 而这三个"部分检查"恰好漏掉的都是最容易被构造的那一类输入。
修法三档
第一档(1 行,止血):把 == nil 改成 len() == 0。
go
// protocol msg/msg.go:112-114
if len(x.Seqs) == 0 {
return errors.New("seqs is empty")
}
代价是改 protocol 仓库并升级需要该修复的依赖方。收益是在 API 侧就拒绝空数组,也让同一校验对进程内非 nil 空切片生效;默认 RPC 链路原本预计会在二次校验拒绝它。注意这里是 protocol 模块,属于跨仓库改动。
第二档(3 行,不依赖 protocol 发版):在业务入口自己挡一下。
go
// internal/rpc/msg/as_read.go:104 之后
func (m *msgServer) MarkMsgsAsRead(ctx context.Context, req *msg.MarkMsgsAsReadReq) (*msg.MarkMsgsAsReadResp, error) {
if len(req.Seqs) == 0 {
return nil, errs.ErrArgs.WrapMsg("seqs is empty")
}
if err := authverify.CheckAccess(ctx, req.UserID); err != nil {
我把这 3 行放在 CheckAccess 之前 ------越界和鉴权无关,没必要为了一个必然失败的请求先查权限再查库。这一档代价最小、见效最快,缺点是它只保护这一个入口,MarkConversationAsRead 那类问题还在。
第三档(结构性,代价最大):把"取最后一个元素"这个假设改成显式契约。
hasReadSeq := req.Seqs[len(req.Seqs)-1] 隐含了"已排序且非空"两个假设。更稳的写法是先算:
go
maxSeqInReq := int64(0)
for _, s := range req.Seqs {
if s > maxSeqInReq {
maxSeqInReq = s
}
}
在前面保留非空校验后,这样不依赖顺序,也避免空切片索引。代价是多一次 O(n) 遍历,而且改了"取最后一个"的语义------如果某个客户端依赖了"最后一个才是目标"这个行为,改动会变成兼容性问题。所以这一档要配一次客户端行为确认,不能直接改。
顺带说一句 MarkConversationAsRead 的循环:合理的修法是给区间加上限(比如单次最多 5000 条),超了走分批或者直接只写 HasReadSeq 而不逐条标。逐条标已读这个设计本身在小会话里没问题,在大会话里就是自找的。
小结
三句话:
x.Seqs == nil拦不住 JSON 解码得到的非 nil 空切片。 默认 HTTP→RPC 路径还会经历 protobuf 编解码与 RPC 二次校验,静态推断在那里拒绝空数组;网络响应尚未实测。- 业务函数直接索引仍有防御缺口。 绕过前置校验且满足权限、查库条件时可能越界;RPC 拦截器只能捕获内层同一 goroutine 的 panic,统一业务响应与 HTTP 状态也要分开看。
- 已读区间长度、请求顺序及已读序号上界值得分别核查。 大区间有资源风险,已读超过最大序号时可能算出负未读数,实际客户端表现需验证。
判据是对照完整链路的实际响应和日志,区分 API 校验、RPC 二次校验与业务函数直接调用;不要将未经复现的 500 当作默认链路结论。也可以用 grep -rn '== nil' $(go env GOMODCACHE)/github.com/openimsdk/protocol@*/msg/*.go 寻找值得进一步检查的校验。