fix(meeting): Task 16 修复 code-reviewer 审计 P0 四项 + 落盘审查报告
code-reviewer 子代理(docs/reviews/2026-04-23-phase2e-2-code-review.md)对 Phase 2e-2 整体做全栈审计,暴露 P0 × 4 / P1 × 8 / P2 × 8 / Nit × 11。本次先修 P0: - P0-1 / P0-2 媒体资源归属越权: 新增 assertOwnsResource(roomCode, userID, kind, id) helper,用 resourceTrackKey Redis Set SIsMember 校验 transport/producer/consumer 归属;统一接入 OnTransportConnect / OnProduceStart / OnConsumeStart / OnConsumeResume / OnProducerClose 五个信令入口, 一次封死同类横向越权漏洞(Redis 查询失败时 fail-closed,避免抖动开口子)。 新增 service.ErrResourceNotOwned 错误常量。 - P0-3 CreateRoom Router 失败吞错: 旧实现 mediaErr != nil 仅 logs.Warn 继续返回成功 → zombie 房间 + 入会必败 + 一人一会议名额被占。改为 fail-closed 补偿:LeaveRoom + MarkEnded(system_error) + 返回新 service.ErrMediaServiceUnavailable,controller.handleError 映射为 500。 - P0-4 会议密码明文走 URL: join.vue onNext 旧逻辑把密码拼 ?password=xxx,浏览器历史 / DevTools 留痕。 新增 meetingStore.draftJoinPayload 内存态草稿,join.vue 只跳转 ?mode=join&code=xxx,preview.vue onLoad 读取后立即 draftJoinPayload = null 清空, 邀请链接里的 password 参数一律忽略。 验证: - cd backend/go-service && go build ./... 通过 - cd frontend && npm run build:h5 通过 - 仅剩 1 个预先存在 unusedparams warning,与本次改动无关 P1 / P2 / Nit 清单保留在审查报告,按顺序继续处理。 Made-with: Cursor
This commit is contained in:
@@ -49,6 +49,9 @@ func (ctl *MeetingController) handleError(c *gin.Context, err error, fallbackMsg
|
||||
utils.ResponseNotFound(c, err.Error())
|
||||
case errors.Is(err, service.ErrNotMeetingHost):
|
||||
utils.ResponseForbidden(c, err.Error())
|
||||
case errors.Is(err, service.ErrMediaServiceUnavailable):
|
||||
// P0-3:媒体服务不可用应作为 5xx 返回,让前端走"稍后重试"提示,而非按"用户输入错误"处理
|
||||
utils.ResponseError(c, err.Error())
|
||||
case errors.Is(err, service.ErrMeetingEnded),
|
||||
errors.Is(err, service.ErrMeetingFull),
|
||||
errors.Is(err, service.ErrMeetingPasswordReq),
|
||||
|
||||
@@ -39,6 +39,12 @@ var (
|
||||
ErrKickSelfForbidden = errors.New("不能踢出自己")
|
||||
ErrTransferToSelf = errors.New("不能将主持人转让给自己")
|
||||
ErrTransferTargetInvalid = errors.New("目标用户不在会议中")
|
||||
// ErrResourceNotOwned 媒体资源归属校验失败:用户试图操作不属于自己的 transport/producer/consumer
|
||||
// 发生场景:Web 端用户抓到他人 producerID 后调 meeting.producer.close、或跨用户挂 consumer 等横向越权尝试
|
||||
ErrResourceNotOwned = errors.New("媒体资源归属校验失败,禁止操作他人资源")
|
||||
// ErrMediaServiceUnavailable 媒体服务当前不可用(Router 创建失败 / Node 宕机等)
|
||||
// 用于 CreateRoom / JoinRoom 的补偿路径,将前台错误与"用户输入错误"区分开
|
||||
ErrMediaServiceUnavailable = errors.New("媒体服务暂时不可用,请稍后重试")
|
||||
)
|
||||
|
||||
// Redis key 前缀(设计文档 §5.4 - Redis 数据结构)
|
||||
@@ -272,11 +278,26 @@ func (s *MeetingService) CreateRoom(ctx context.Context, hostID int64, req *dto.
|
||||
}
|
||||
|
||||
// Task 7 起 CreateRouter 对接真实 mediasoup;Task 8 起仅在会议首次创建时调一次(房间级资源)
|
||||
// P0-3 修复(审计报告):
|
||||
// 旧版在 Router 创建失败时仅 `logs.Warn` 继续返回成功,导致 DB 存在 active 房间但 mediasoup 端无 Router,
|
||||
// 所有入会者后续 transport.create 必失败;同时占用"一人一会议"名额,用户无法新建。
|
||||
// 现改为 fail-closed:Router 失败 → 补偿 LeaveRoom + MarkEnded(system_error) → 返回 ErrMediaServiceUnavailable 让前端提示重试。
|
||||
routerID, mediaErr := s.mediaOrchestrator.CreateRouter(ctx, code)
|
||||
if mediaErr != nil {
|
||||
logs.Warn(ctx, funcName, "mediasoup Router 创建失败",
|
||||
zap.String("room_code", code), zap.Error(mediaErr))
|
||||
routerID = ""
|
||||
logs.Warn(ctx, funcName, "mediasoup Router 创建失败,执行补偿回滚",
|
||||
zap.String("room_code", code), zap.Int64("host_id", hostID), zap.Error(mediaErr))
|
||||
|
||||
if _, leaveErr := s.participantDAO.LeaveRoom(ctx, room.ID, hostID, constants.MeetingLeftReasonSelf); leaveErr != nil {
|
||||
logs.Warn(ctx, funcName, "补偿阶段 LeaveRoom 失败(已记录,清理任务会兜底)",
|
||||
zap.Int64("room_id", room.ID), zap.Int64("host_id", hostID), zap.Error(leaveErr))
|
||||
}
|
||||
if _, markErr := s.roomDAO.MarkEnded(ctx, room.ID, constants.MeetingEndedReasonSystemError, time.Now()); markErr != nil {
|
||||
logs.Warn(ctx, funcName, "补偿阶段 MarkEnded 失败(已记录,清理任务会兜底)",
|
||||
zap.Int64("room_id", room.ID), zap.Error(markErr))
|
||||
}
|
||||
|
||||
err = ErrMediaServiceUnavailable
|
||||
return nil, nil, "", err
|
||||
}
|
||||
|
||||
logs.Info(ctx, funcName, "会议创建成功",
|
||||
|
||||
@@ -91,6 +91,35 @@ func (s *MeetingSignalService) untrackResource(ctx context.Context, roomCode str
|
||||
_ = s.redis.SRem(ctx, key, member).Err()
|
||||
}
|
||||
|
||||
// assertOwnsResource 校验指定资源(kind: transport/producer/consumer)是否由 userID 在该 roomCode 中创建
|
||||
// 背景:WS 信令入口原本只校验"用户是否在会议中",但客户端上报的 transport_id / producer_id / consumer_id 完全可伪造,
|
||||
// 造成任意成员可关闭他人 producer、把 consumer 挂到他人 recv transport 等横向越权(P0-1 / P0-2 审计)
|
||||
// 判据:trackResource 记录的 Redis Set 是最权威的归属来源(kind:id 写入 / untrack 时移除)
|
||||
// 返回:
|
||||
// - nil:归属合法
|
||||
// - ErrResourceNotOwned:Set 里不存在该元素(越权尝试)
|
||||
// - Redis 错误时同样返回 ErrResourceNotOwned 并记录 Warn,按"fail-closed"保守拒绝,避免服务抖动打开权限口子
|
||||
func (s *MeetingSignalService) assertOwnsResource(ctx context.Context, roomCode string, userID int64, kind, id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("%s_id 不能为空", kind)
|
||||
}
|
||||
key := resourceTrackKey(roomCode, userID)
|
||||
member := kind + ":" + id
|
||||
ok, err := s.redis.SIsMember(ctx, key, member).Result()
|
||||
if err != nil {
|
||||
logs.Warn(ctx, "service.meeting_signal_service.assertOwnsResource", "查询资源归属失败",
|
||||
zap.String("key", key), zap.String("member", member), zap.Error(err))
|
||||
return ErrResourceNotOwned
|
||||
}
|
||||
if !ok {
|
||||
logs.Warn(ctx, "service.meeting_signal_service.assertOwnsResource", "资源归属不匹配,拒绝越权操作",
|
||||
zap.String("room_code", roomCode), zap.Int64("user_id", userID),
|
||||
zap.String("kind", kind), zap.String("id", id))
|
||||
return ErrResourceNotOwned
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateMemberState 将某用户的音视频开关状态持久化到 Redis Hash
|
||||
// 非 nil 的字段才写入;audio/video 任意一个 nil 都不碰它,避免误覆盖另一维状态
|
||||
// 容错:任何一步失败仅 Warn 日志,不阻断业务流程(前端已发 state.changed 作为权威广播)
|
||||
@@ -477,12 +506,12 @@ type TransportConnectPayload struct {
|
||||
|
||||
// OnTransportConnect 处理 meeting.transport.connect 事件
|
||||
func (s *MeetingSignalService) OnTransportConnect(ctx context.Context, userID int64, payload *TransportConnectPayload) error {
|
||||
if payload.TransportID == "" {
|
||||
return fmt.Errorf("transport_id 不能为空")
|
||||
}
|
||||
if _, err := s.loadRoomAndParticipant(ctx, payload.RoomCode, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.assertOwnsResource(ctx, payload.RoomCode, userID, "transport", payload.TransportID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.mediaOrchestrator.ConnectTransport(ctx, payload.TransportID, payload.DtlsParameters)
|
||||
}
|
||||
|
||||
@@ -505,13 +534,13 @@ func (s *MeetingSignalService) OnProduceStart(ctx context.Context, userID int64,
|
||||
if payload.Kind != "audio" && payload.Kind != "video" {
|
||||
return nil, fmt.Errorf("kind 非法,必须是 audio 或 video")
|
||||
}
|
||||
if payload.TransportID == "" {
|
||||
return nil, fmt.Errorf("transport_id 不能为空")
|
||||
}
|
||||
room, err := s.loadRoomAndParticipant(ctx, payload.RoomCode, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.assertOwnsResource(ctx, payload.RoomCode, userID, "transport", payload.TransportID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
producerID, err := s.mediaOrchestrator.CreateProducer(ctx, &CreateProducerReq{
|
||||
RoomCode: payload.RoomCode,
|
||||
UserID: userID,
|
||||
@@ -543,13 +572,18 @@ type ConsumeStartPayload struct {
|
||||
}
|
||||
|
||||
// OnConsumeStart 处理 meeting.consume.start 事件
|
||||
// P0-2 修复:新增 transport_id 归属校验,拒绝把 consumer 挂到他人的 recv transport
|
||||
// (producer_id 归属天然不需要校验:消费他人 producer 正是订阅逻辑本身,Node 侧会验证 producer 是否存在)
|
||||
func (s *MeetingSignalService) OnConsumeStart(ctx context.Context, userID int64, payload *ConsumeStartPayload) (*ConsumerInfo, error) {
|
||||
if payload.TransportID == "" || payload.ProducerID == "" {
|
||||
return nil, fmt.Errorf("transport_id 与 producer_id 均不能为空")
|
||||
if payload.ProducerID == "" {
|
||||
return nil, fmt.Errorf("producer_id 不能为空")
|
||||
}
|
||||
if _, err := s.loadRoomAndParticipant(ctx, payload.RoomCode, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.assertOwnsResource(ctx, payload.RoomCode, userID, "transport", payload.TransportID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := s.mediaOrchestrator.CreateConsumer(ctx, &CreateConsumerReq{
|
||||
RoomCode: payload.RoomCode,
|
||||
UserID: userID,
|
||||
@@ -576,12 +610,12 @@ type ConsumeResumePayload struct {
|
||||
// 权限:仅当 userID 是会议活跃成员且 consumerID 归属该用户时允许
|
||||
// 幂等:Node 对已 active Consumer 再次 resume 不报错;Consumer 不存在则 ACK 返回友好错误
|
||||
func (s *MeetingSignalService) OnConsumeResume(ctx context.Context, userID int64, payload *ConsumeResumePayload) error {
|
||||
if payload.ConsumerID == "" {
|
||||
return fmt.Errorf("consumer_id 不能为空")
|
||||
}
|
||||
if _, err := s.loadRoomAndParticipant(ctx, payload.RoomCode, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.assertOwnsResource(ctx, payload.RoomCode, userID, "consumer", payload.ConsumerID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.mediaOrchestrator.ResumeConsumer(ctx, payload.ConsumerID); err != nil {
|
||||
logs.Warn(ctx, "service.meeting_signal_service.OnConsumeResume", "恢复 Consumer 失败",
|
||||
zap.String("room_code", payload.RoomCode),
|
||||
@@ -601,14 +635,15 @@ type ProducerClosePayload struct {
|
||||
|
||||
// OnProducerClose 处理 meeting.producer.close 事件
|
||||
// 成功后广播给房间内其他成员(与 mediasoup 的 producerclose 级联动作平级)
|
||||
// P0-1 修复:新增 producer 归属校验,拒绝一名参会人关闭他人的 producer(横向越权,审计 P0-1)
|
||||
func (s *MeetingSignalService) OnProducerClose(ctx context.Context, userID int64, payload *ProducerClosePayload) error {
|
||||
if payload.ProducerID == "" {
|
||||
return fmt.Errorf("producer_id 不能为空")
|
||||
}
|
||||
room, err := s.loadRoomAndParticipant(ctx, payload.RoomCode, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.assertOwnsResource(ctx, payload.RoomCode, userID, "producer", payload.ProducerID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.mediaOrchestrator.CloseProducer(ctx, payload.ProducerID); err != nil {
|
||||
logs.Warn(ctx, "service.meeting_signal_service.OnProducerClose", "关闭 Producer 失败",
|
||||
zap.String("producer_id", payload.ProducerID), zap.Error(err))
|
||||
|
||||
Reference in New Issue
Block a user