veza/veza-backend-api/internal/handlers/live_stream_callback.go
senke 24b29d229d fix(v0.12.6.1): remediate 2 CRITICAL + 10 HIGH + 1 MEDIUM pentest findings
Security fixes implemented:

CRITICAL:
- CRIT-001: IDOR on chat rooms — added IsRoomMember check before
  returning room data or message history (returns 404, not 403)
- CRIT-002: play_count/like_count exposed publicly — changed JSON
  tags to "-" so they are never serialized in API responses

HIGH:
- HIGH-001: TOCTOU race on marketplace downloads — transaction +
  SELECT FOR UPDATE on GetDownloadURL
- HIGH-002: HS256 in production docker-compose — replaced JWT_SECRET
  with JWT_PRIVATE_KEY_PATH / JWT_PUBLIC_KEY_PATH (RS256)
- HIGH-003: context.Background() bypass in user repository — full
  context propagation from handlers → services → repository (29 files)
- HIGH-004: Race condition on promo codes — SELECT FOR UPDATE
- HIGH-005: Race condition on exclusive licenses — SELECT FOR UPDATE
- HIGH-006: Rate limiter IP spoofing — SetTrustedProxies(nil) default
- HIGH-007: RGPD hard delete incomplete — added cleanup for sessions,
  settings, follows, notifications, audit_logs anonymization
- HIGH-008: RTMP callback auth weak — fail-closed when unconfigured,
  header-only (no query param), constant-time compare
- HIGH-009: Co-listening host hijack — UpdateHostState now takes *Conn
  and verifies IsHost before processing
- HIGH-010: Moderator self-strike — added issuedBy != userID check

MEDIUM:
- MEDIUM-001: Recovery codes used math/rand — replaced with crypto/rand
- MEDIUM-005: Stream token forgeable — resolved by HIGH-002 (RS256)

Updated REMEDIATION_MATRIX: 14 findings marked  CORRIGÉ.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 05:40:53 +01:00

120 lines
4.4 KiB
Go

package handlers
import (
"crypto/subtle"
"net/http"
"os"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/services"
)
// LiveStreamCallbackHandler handles Nginx-RTMP on_publish / on_publish_done callbacks (v0.10.6 F471)
type LiveStreamCallbackHandler struct {
service *services.LiveStreamService
logger *zap.Logger
}
// NewLiveStreamCallbackHandler creates a new callback handler
func NewLiveStreamCallbackHandler(service *services.LiveStreamService, logger *zap.Logger) *LiveStreamCallbackHandler {
return &LiveStreamCallbackHandler{service: service, logger: logger}
}
// validateCallbackSecret returns true if the request is authorized
// SECURITY(HIGH-008): Fail-closed when unconfigured, header-only, constant-time compare
func validateCallbackSecret(c *gin.Context) bool {
expect := os.Getenv("RTMP_CALLBACK_SECRET")
if expect == "" {
return false // SECURITY(HIGH-008): fail-closed — reject when secret not configured
}
got := c.GetHeader("X-RTMP-Callback-Secret")
// SECURITY(HIGH-008): removed query param fallback — secret must be in header only
return subtle.ConstantTimeCompare([]byte(got), []byte(expect)) == 1
}
// HandlePublish is called by Nginx-RTMP on_publish. Params: name=stream_key
// On success: SetIsLive(true), UpdateStreamURL with HLS playlist URL
func (h *LiveStreamCallbackHandler) HandlePublish(c *gin.Context) {
if !validateCallbackSecret(c) {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "invalid callback secret"))
return
}
streamKey := c.Query("name")
if streamKey == "" {
streamKey = c.PostForm("name")
}
if streamKey == "" {
RespondWithAppError(c, apperrors.NewValidationError("missing stream key (name)"))
return
}
stream, err := h.service.GetByStreamKey(c.Request.Context(), streamKey)
if err != nil {
h.logger.Warn("Live publish: invalid stream key", zap.String("key", streamKey), zap.Error(err))
RespondWithAppError(c, apperrors.NewNotFoundError("invalid stream key"))
return
}
// Nginx-RTMP writes to /tmp/hls/{stream_key}/ so we use stream_key in the URL
baseURL := os.Getenv("STREAM_HLS_BASE_URL")
if baseURL == "" {
baseURL = "http://localhost:18083/live"
}
streamURL := baseURL + "/" + stream.StreamKey + "/playlist.m3u8"
if err := h.service.SetIsLive(c.Request.Context(), stream.ID, true); err != nil {
h.logger.Error("Live publish: SetIsLive failed", zap.Error(err))
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to set stream live", err))
return
}
if err := h.service.UpdateStreamURL(c.Request.Context(), stream.ID, streamURL); err != nil {
h.logger.Error("Live publish: UpdateStreamURL failed", zap.Error(err))
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to update stream URL", err))
return
}
if err := h.service.UpdateViewerCount(c.Request.Context(), stream.ID, 0); err != nil {
_ = err // non-fatal
}
h.logger.Info("Live stream started", zap.String("stream_id", stream.ID.String()), zap.String("stream_key", streamKey))
c.AbortWithStatus(http.StatusOK)
}
// HandlePublishDone is called by Nginx-RTMP on_publish_done. Params: name=stream_key
func (h *LiveStreamCallbackHandler) HandlePublishDone(c *gin.Context) {
if !validateCallbackSecret(c) {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "invalid callback secret"))
return
}
streamKey := c.Query("name")
if streamKey == "" {
streamKey = c.PostForm("name")
}
if streamKey == "" {
RespondWithAppError(c, apperrors.NewValidationError("missing stream key (name)"))
return
}
stream, err := h.service.GetByStreamKey(c.Request.Context(), streamKey)
if err != nil {
h.logger.Warn("Live publish_done: stream not found", zap.String("key", streamKey))
c.AbortWithStatus(http.StatusOK) // Nginx expects 2xx even if we don't know the stream
return
}
if err := h.service.SetIsLive(c.Request.Context(), stream.ID, false); err != nil {
h.logger.Error("Live publish_done: SetIsLive failed", zap.Error(err))
}
if err := h.service.UpdateStreamURL(c.Request.Context(), stream.ID, ""); err != nil {
h.logger.Error("Live publish_done: UpdateStreamURL failed", zap.Error(err))
}
if err := h.service.UpdateViewerCount(c.Request.Context(), stream.ID, -stream.ViewerCount); err != nil {
_ = err // reset to 0
}
h.logger.Info("Live stream ended", zap.String("stream_id", stream.ID.String()))
c.AbortWithStatus(http.StatusOK)
}