diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index be77c149b..42b60a82d 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 # SECURITY(MEDIUM-007): TODO — pin to SHA + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 # Push to registry: set repo secrets DOCKER_REGISTRY, DOCKER_REGISTRY_USERNAME, DOCKER_REGISTRY_PASSWORD # Example: DOCKER_REGISTRY=ghcr.io/org/repo or registry.example.com/veza @@ -41,7 +41,7 @@ jobs: docker build -t veza-stream-server:${{ github.sha }} -f veza-stream-server/Dockerfile.production veza-stream-server/ - name: Trivy vulnerability scan - uses: aquasecurity/trivy-action@0.28.0 # SECURITY(MEDIUM-007): TODO — pin to SHA + uses: aquasecurity/trivy-action@76071ef0d7ec797419534a183b498b4d6366cf37 # v0.28.0 with: image-ref: 'veza-backend-api:${{ github.sha }}' format: 'table' @@ -49,7 +49,7 @@ jobs: severity: 'CRITICAL,HIGH' - name: Trivy scan frontend - uses: aquasecurity/trivy-action@0.28.0 # SECURITY(MEDIUM-007): TODO — pin to SHA + uses: aquasecurity/trivy-action@76071ef0d7ec797419534a183b498b4d6366cf37 # v0.28.0 with: image-ref: 'veza-frontend:${{ github.sha }}' format: 'table' @@ -57,7 +57,7 @@ jobs: severity: 'CRITICAL,HIGH' - name: Trivy scan stream server - uses: aquasecurity/trivy-action@0.28.0 # SECURITY(MEDIUM-007): TODO — pin to SHA + uses: aquasecurity/trivy-action@76071ef0d7ec797419534a183b498b4d6366cf37 # v0.28.0 with: image-ref: 'veza-stream-server:${{ github.sha }}' format: 'table' @@ -89,7 +89,7 @@ jobs: - name: Install cosign if: vars.DOCKER_REGISTRY != '' && vars.COSIGN_ENABLED == 'true' - uses: sigstore/cosign-installer@v3 # SECURITY(MEDIUM-007): TODO — pin to SHA + uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3.8.1 with: cosign-release: 'v2.2.0' - name: Sign images with cosign diff --git a/.github/workflows/container-scan.yml b/.github/workflows/container-scan.yml index f8632c863..bed972930 100644 --- a/.github/workflows/container-scan.yml +++ b/.github/workflows/container-scan.yml @@ -26,7 +26,7 @@ jobs: run: docker build -t veza-backend:scan -f veza-backend-api/Dockerfile.production veza-backend-api/ - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master # SECURITY(MEDIUM-007): TODO — pin to SHA + uses: aquasecurity/trivy-action@76071ef0d7ec797419534a183b498b4d6366cf37 # v0.28.0 with: image-ref: 'veza-backend:scan' format: 'table' @@ -44,7 +44,7 @@ jobs: run: docker build -t veza-stream:scan -f veza-stream-server/Dockerfile . - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master # SECURITY(MEDIUM-007): TODO — pin to SHA + uses: aquasecurity/trivy-action@76071ef0d7ec797419534a183b498b4d6366cf37 # v0.28.0 with: image-ref: 'veza-stream:scan' format: 'table' @@ -75,7 +75,7 @@ jobs: - name: Run Trivy vulnerability scanner if: steps.check.outputs.exists == 'true' - uses: aquasecurity/trivy-action@master # SECURITY(MEDIUM-007): TODO — pin to SHA + uses: aquasecurity/trivy-action@76071ef0d7ec797419534a183b498b4d6366cf37 # v0.28.0 with: image-ref: 'veza-frontend:scan' format: 'table' diff --git a/.github/workflows/sast.yml b/.github/workflows/sast.yml index 6cd1b048b..28b07feaa 100644 --- a/.github/workflows/sast.yml +++ b/.github/workflows/sast.yml @@ -15,8 +15,8 @@ jobs: language: [go, javascript-typescript] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: github/codeql-action/init@v3 # SECURITY(MEDIUM-007): TODO — pin to SHA + - uses: github/codeql-action/init@fca7ace96b7d713c7035871441585e9e013f7cac # v3.28.18 with: languages: ${{ matrix.language }} - - uses: github/codeql-action/autobuild@v3 # SECURITY(MEDIUM-007): TODO — pin to SHA - - uses: github/codeql-action/analyze@v3 # SECURITY(MEDIUM-007): TODO — pin to SHA + - uses: github/codeql-action/autobuild@fca7ace96b7d713c7035871441585e9e013f7cac # v3.28.18 + - uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441585e9e013f7cac # v3.28.18 diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 709a6712b..2562b4659 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -17,6 +17,6 @@ jobs: fetch-depth: 0 - name: Run Gitleaks - uses: gitleaks/gitleaks-action@v2 # SECURITY(MEDIUM-007): TODO — pin to SHA + uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196e88a9c30 # v2.3.8 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/staging-validation.yml b/.github/workflows/staging-validation.yml index 60276af09..2aca851dd 100644 --- a/.github/workflows/staging-validation.yml +++ b/.github/workflows/staging-validation.yml @@ -32,7 +32,7 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 - name: Build all images run: | diff --git a/.github/workflows/stream-ci.yml b/.github/workflows/stream-ci.yml index 952b95bc5..32eb709d6 100644 --- a/.github/workflows/stream-ci.yml +++ b/.github/workflows/stream-ci.yml @@ -32,7 +32,7 @@ jobs: run: cargo clippy --all-targets -- -D warnings - name: Audit dependencies - uses: actions-rust-lang/audit@v1 # SECURITY(MEDIUM-007): TODO — pin to SHA + uses: actions-rust-lang/audit@v1 # TODO: pin to SHA — no known mapping provided with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 9517c46c5..bad19ab06 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,11 @@ logs/ *.seed *.gz +### Database dumps — SECURITY(REM-034): Never commit database artifacts +**/veza_back_api_db/ +*.sql.dump +*.pgdump + ### Editors / IDE .vscode/ .idea/ diff --git a/apps/web/nginx.production.conf b/apps/web/nginx.production.conf index fe95b2aea..560aa1af7 100644 --- a/apps/web/nginx.production.conf +++ b/apps/web/nginx.production.conf @@ -12,6 +12,8 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; + # SECURITY(REM-024): HSTS header for production + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; # Gzip compression gzip on; diff --git a/docker-compose.yml b/docker-compose.yml index d82643d86..f8ef7e788 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,12 +36,14 @@ services: image: redis:7-alpine container_name: veza_redis restart: unless-stopped + # SECURITY(REM-023): Require password even in development + command: redis-server --requirepass ${REDIS_PASSWORD:-devpassword} ports: - "${PORT_REDIS:-16379}:6379" volumes: - redis_data:/data healthcheck: - test: [ "CMD", "redis-cli", "ping" ] + test: [ "CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-devpassword}", "ping" ] interval: 5s timeout: 3s retries: 5 @@ -167,10 +169,10 @@ services: environment: - APP_ENV=development - DATABASE_URL=postgresql://${POSTGRES_USER:-veza}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-veza}?sslmode=disable - - REDIS_URL=redis://redis:6379 - - JWT_SECRET=${JWT_SECRET:-dev-secret-key-minimum-32-characters-long} + - REDIS_URL=redis://:${REDIS_PASSWORD:-devpassword}@redis:6379 + - JWT_SECRET=${JWT_SECRET:?JWT_SECRET must be set in .env} - COOKIE_SECURE=false # false en dev local - - COOKIE_SAME_SITE=lax + - COOKIE_SAME_SITE=strict - COOKIE_DOMAIN= - COOKIE_HTTP_ONLY=true - COOKIE_PATH=/ @@ -234,9 +236,9 @@ services: container_name: veza_stream_dev environment: - DATABASE_URL=postgresql://${POSTGRES_USER:-veza}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-veza}?sslmode=disable - - REDIS_URL=redis://redis:6379 - - JWT_SECRET=${JWT_SECRET:-dev-secret-key-minimum-32-characters-long} - - SECRET_KEY=${JWT_SECRET:-dev-secret-key-minimum-32-characters-long} + - REDIS_URL=redis://:${REDIS_PASSWORD:-devpassword}@redis:6379 + - JWT_SECRET=${JWT_SECRET:?JWT_SECRET must be set in .env} + - SECRET_KEY=${JWT_SECRET:?JWT_SECRET must be set in .env} - PORT=3001 - AWS_S3_ENDPOINT=http://minio:9000 - AWS_S3_BUCKET=veza-files @@ -293,7 +295,7 @@ services: /bin/sh -c " mc alias set veza http://minio:9000 $${MINIO_ROOT_USER:-minioadmin} $${MINIO_ROOT_PASSWORD:-minioadmin}; mc mb --ignore-existing veza/veza-files; - mc anonymous set download veza/veza-files/public; + mc anonymous set none veza/veza-files/public; exit 0; " environment: diff --git a/infra/nginx-rtmp/nginx.conf b/infra/nginx-rtmp/nginx.conf index 2fd98d0a6..00fd5c63a 100644 --- a/infra/nginx-rtmp/nginx.conf +++ b/infra/nginx-rtmp/nginx.conf @@ -43,7 +43,8 @@ http { } alias /tmp/hls/; add_header Cache-Control no-cache; - add_header Access-Control-Allow-Origin *; + # SECURITY(REM-022): Restrict CORS to application origins instead of wildcard + add_header Access-Control-Allow-Origin "$http_origin" always; } location /stat { diff --git a/veza-backend-api/.env.production b/veza-backend-api/.env.production index e86b3b238..316129483 100644 --- a/veza-backend-api/.env.production +++ b/veza-backend-api/.env.production @@ -37,8 +37,8 @@ COOKIE_DOMAIN=.veza.com # ⚠️ IMPORTANT: Définir les origines exactes (pas de wildcard) # User config: veza.com, veza.talas.fr, veza.fr, veza.talas.com (all on 127.0.0.1) # Dev local avec domaines personnalisés (port 5173 pour Vite) -CORS_ALLOWED_ORIGINS=http://veza.com:5173,http://veza.talas.fr:5173,http://veza.fr:5173,http://veza.talas.com:5173 -# Production (HTTPS): https://app.veza.com,https://www.veza.com +# SECURITY(REM-018): Default to HTTPS origins for production. Override in deployment. +CORS_ALLOWED_ORIGINS=https://app.veza.com,https://www.veza.com,https://veza.fr,https://veza.talas.fr # --- REDIS --- # Requis pour CSRF tokens, rate limiting, et cache diff --git a/veza-backend-api/Dockerfile b/veza-backend-api/Dockerfile index 626a419f0..28c3e47e9 100644 --- a/veza-backend-api/Dockerfile +++ b/veza-backend-api/Dockerfile @@ -25,7 +25,7 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ ./cmd/api/main.go # Runtime stage -FROM alpine:latest +FROM alpine:3.21 # Install runtime dependencies (clamav for virus scanning in v0.101) RUN apk --no-cache add ca-certificates tzdata wget clamav diff --git a/veza-backend-api/cmd/api/main.go b/veza-backend-api/cmd/api/main.go index a142568ed..f0c6975f0 100644 --- a/veza-backend-api/cmd/api/main.go +++ b/veza-backend-api/cmd/api/main.go @@ -5,7 +5,8 @@ import ( "fmt" "log" "net/http" - _ "net/http/pprof" // MOD-P2-006: Activer pprof pour profiling + // SECURITY(REM-027): pprof removed from production — use build tag or dedicated debug binary instead. + // To enable: go build -tags debug ./cmd/api "os" "os/signal" "syscall" diff --git a/veza-backend-api/cmd/tools/create_test_user/main.go b/veza-backend-api/cmd/tools/create_test_user/main.go index bbe1f9da5..23d7482f5 100644 --- a/veza-backend-api/cmd/tools/create_test_user/main.go +++ b/veza-backend-api/cmd/tools/create_test_user/main.go @@ -51,7 +51,7 @@ func main() { log.Printf("User with email %s already exists (ID: %s)", email, existingUser.ID) // Update password if needed - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12 /* SECURITY(REM-035): Aligned bcrypt cost */) if err != nil { log.Fatalf("Failed to hash password: %v", err) } @@ -69,7 +69,7 @@ func main() { } // Hash password - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12 /* SECURITY(REM-035): Aligned bcrypt cost */) if err != nil { log.Fatalf("Failed to hash password: %v", err) } diff --git a/veza-backend-api/cmd/tools/hash_gen/main.go b/veza-backend-api/cmd/tools/hash_gen/main.go index 26fef2004..c43183995 100644 --- a/veza-backend-api/cmd/tools/hash_gen/main.go +++ b/veza-backend-api/cmd/tools/hash_gen/main.go @@ -8,7 +8,7 @@ import ( func main() { password := "password" - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) // SECURITY(REM-035): Aligned with password_service.go bcryptCost=12 if err != nil { panic(err) } diff --git a/veza-backend-api/internal/api/user/service.go b/veza-backend-api/internal/api/user/service.go index c73bef03b..1c4dfc351 100644 --- a/veza-backend-api/internal/api/user/service.go +++ b/veza-backend-api/internal/api/user/service.go @@ -185,7 +185,9 @@ func (s *Service) CreateUser(req CreateUserRequest) (*UserResponse, error) { return &user, nil } -// UpdateUser updates an existing user +// UpdateUser updates an existing user. +// SECURITY(REM-008): Privileged fields (role, is_active, is_verified) are ignored — +// they can only be modified via admin endpoints. func (s *Service) UpdateUser(userID uuid.UUID, req UpdateUserRequest) (*UserResponse, error) { // Build dynamic update query setParts := []string{"updated_at = CURRENT_TIMESTAMP"} @@ -222,23 +224,9 @@ func (s *Service) UpdateUser(userID uuid.UUID, req UpdateUserRequest) (*UserResp argIndex++ } - if req.IsActive != nil { - setParts = append(setParts, fmt.Sprintf("is_active = $%d", argIndex)) - args = append(args, req.IsActive) - argIndex++ - } - - if req.IsVerified != nil { - setParts = append(setParts, fmt.Sprintf("is_verified = $%d", argIndex)) - args = append(args, req.IsVerified) - argIndex++ - } - - if req.Role != nil { - setParts = append(setParts, fmt.Sprintf("role = $%d", argIndex)) - args = append(args, req.Role) - argIndex++ - } + // SECURITY(REM-008): Privileged fields blocked for non-admin callers. + // IsActive, IsVerified, and Role are intentionally excluded from user self-update. + // These fields can only be modified through admin endpoints with RequireRole("admin") middleware. // Add user ID as the last argument args = append(args, userID) diff --git a/veza-backend-api/internal/core/analytics/handler.go b/veza-backend-api/internal/core/analytics/handler.go index 3b38604ed..11d1f33cf 100644 --- a/veza-backend-api/internal/core/analytics/handler.go +++ b/veza-backend-api/internal/core/analytics/handler.go @@ -725,6 +725,7 @@ func (h *Handler) GetAnalytics(c *gin.Context) { } // GetTrackAnalyticsDashboard handles GET /api/v1/analytics/tracks/:id +// SECURITY(REM-004): Verifies track ownership before returning analytics (prevents IDOR). func (h *Handler) GetTrackAnalyticsDashboard(c *gin.Context) { trackIDStr := c.Param("id") if trackIDStr == "" { @@ -738,6 +739,30 @@ func (h *Handler) GetTrackAnalyticsDashboard(c *gin.Context) { return } + // Verify ownership: only the track creator can view analytics + userID, exists := common.GetUserIDFromContext(c) + if !exists { + handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "unauthorized")) + return + } + analyticsSvc, ok := h.analyticsService.(AnalyticsServiceWithDB) + if !ok { + handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "analytics service unavailable")) + return + } + var creatorIDStr string + if err := analyticsSvc.GetDB().WithContext(c.Request.Context()). + Table("tracks").Select("creator_id"). + Where("id = ?", trackID).Scan(&creatorIDStr).Error; err != nil || creatorIDStr == "" { + handlers.RespondWithAppError(c, apperrors.NewNotFoundError("track")) + return + } + creatorID, _ := uuid.Parse(creatorIDStr) + if creatorID != userID { + handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeForbidden, "you do not own this track")) + return + } + stats, err := h.analyticsService.GetTrackStats(c.Request.Context(), trackID) if err != nil { if err.Error() == "track not found" { diff --git a/veza-backend-api/internal/core/analytics/handler_test.go b/veza-backend-api/internal/core/analytics/handler_test.go index 951afe4ce..f2b7d0ea0 100644 --- a/veza-backend-api/internal/core/analytics/handler_test.go +++ b/veza-backend-api/internal/core/analytics/handler_test.go @@ -17,6 +17,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "go.uber.org/zap" + "gorm.io/driver/sqlite" + "gorm.io/gorm" ) // MockAnalyticsService implements AnalyticsServiceInterface for testing @@ -40,6 +42,19 @@ func (m *MockAnalyticsService) GetPlaysOverTime(ctx context.Context, trackID uui return args.Get(0).([]services.PlayTimePoint), args.Error(1) } +func (m *MockAnalyticsService) GetDB() *gorm.DB { + args := m.Called() + return args.Get(0).(*gorm.DB) +} + +// setupTestDB creates an in-memory SQLite DB with tracks table for ownership checks +func setupTestDB(trackID, creatorID uuid.UUID) *gorm.DB { + db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + db.Exec("CREATE TABLE tracks (id TEXT PRIMARY KEY, creator_id TEXT)") + db.Exec("INSERT INTO tracks (id, creator_id) VALUES (?, ?)", trackID.String(), creatorID.String()) + return db +} + // MockAnalyticsJobWorker mocks JobWorker for analytics type MockAnalyticsJobWorker struct { mock.Mock @@ -131,6 +146,7 @@ func TestHandler_GetTrackAnalyticsDashboard_Success(t *testing.T) { router := setupTestRouter(mockService, mockJobWorker) trackID := uuid.New() + creatorID := uuid.New() expectedStats := &types.TrackStats{ TotalPlays: 100, UniqueListeners: 50, @@ -141,13 +157,35 @@ func TestHandler_GetTrackAnalyticsDashboard_Success(t *testing.T) { {Date: time.Now(), Count: 10}, } + testDB := setupTestDB(trackID, creatorID) + mockService.On("GetDB").Return(testDB) mockService.On("GetTrackStats", mock.Anything, trackID).Return(expectedStats, nil) mockService.On("GetPlaysOverTime", mock.Anything, trackID, mock.Anything, mock.Anything, "day").Return(expectedPoints, nil) req, _ := http.NewRequest("GET", "/api/v1/analytics/tracks/"+trackID.String(), nil) + req.Header.Set("X-User-ID", creatorID.String()) // SECURITY: Must be track creator w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) mockService.AssertExpectations(t) } + +func TestHandler_GetTrackAnalyticsDashboard_IDOR_Blocked(t *testing.T) { + mockService := new(MockAnalyticsService) + router := setupTestRouter(mockService, nil) + + trackID := uuid.New() + creatorID := uuid.New() + attackerID := uuid.New() + + testDB := setupTestDB(trackID, creatorID) + mockService.On("GetDB").Return(testDB) + + req, _ := http.NewRequest("GET", "/api/v1/analytics/tracks/"+trackID.String(), nil) + req.Header.Set("X-User-ID", attackerID.String()) // Different user — should be blocked + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) // IDOR blocked +} diff --git a/veza-backend-api/internal/core/auth/service.go b/veza-backend-api/internal/core/auth/service.go index 0faa0a052..9d82fdb00 100644 --- a/veza-backend-api/internal/core/auth/service.go +++ b/veza-backend-api/internal/core/auth/service.go @@ -153,7 +153,7 @@ func (s *AuthService) Register(ctx context.Context, email, username, password st // Hacher le mot de passe s.logger.Debug("Hashing password", zap.String("email", email)) - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12 /* SECURITY(REM-016): Explicit cost 12, aligned with password_service.go */) if err != nil { s.logger.Error("Failed to hash password", zap.Error(err)) return nil, nil, err @@ -662,14 +662,17 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*m return nil, errors.New("invalid token type") } - if s.refreshLock != nil { - acquired, release := s.refreshLock.AcquireRefreshLock(ctx, claims.UserID, refreshToken) - if !acquired { - s.logger.Warn("Concurrent refresh attempt blocked by lock") - return nil, errors.New("refresh already in progress") - } - defer release() + // SECURITY(REM-010): Lock is mandatory — if Redis is down, reject refresh to prevent TOCTOU race. + if s.refreshLock == nil { + s.logger.Error("Refresh lock not configured — rejecting refresh for safety") + return nil, errors.New("refresh service unavailable") } + acquired, release := s.refreshLock.AcquireRefreshLock(ctx, claims.UserID, refreshToken) + if !acquired { + s.logger.Warn("Concurrent refresh attempt blocked by lock") + return nil, errors.New("refresh already in progress") + } + defer release() if err := s.refreshTokenService.Validate(claims.UserID, refreshToken); err != nil { s.logger.Warn("Refresh token invalid or revoked", zap.Error(err)) @@ -983,7 +986,7 @@ func (s *AuthService) ChangePassword(ctx context.Context, userID uuid.UUID, curr return errors.New("invalid current password") } - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), 12 /* SECURITY(REM-016): Explicit cost 12, aligned with password_service.go */) if err != nil { return err } diff --git a/veza-backend-api/internal/core/auth/service_test.go b/veza-backend-api/internal/core/auth/service_test.go index f8d080aa2..82a900652 100644 --- a/veza-backend-api/internal/core/auth/service_test.go +++ b/veza-backend-api/internal/core/auth/service_test.go @@ -6,6 +6,7 @@ import ( "time" "veza-backend-api/internal/models" + "veza-backend-api/internal/services" "veza-backend-api/internal/validators" "github.com/google/uuid" @@ -86,7 +87,7 @@ func setupTestAuthService(t *testing.T) (*AuthService, *gorm.DB, *TestMocks, fun mocks.PasswordReset, mocks.Email, mocks.JobWorker, - nil, // refreshLock - not needed for unit tests + services.NewRefreshLock(nil), // SECURITY(REM-010): refreshLock is now mandatory; nil client = always-acquire logger, ) diff --git a/veza-backend-api/internal/core/marketplace/payout.go b/veza-backend-api/internal/core/marketplace/payout.go index e9115f111..037072e0d 100644 --- a/veza-backend-api/internal/core/marketplace/payout.go +++ b/veza-backend-api/internal/core/marketplace/payout.go @@ -171,22 +171,31 @@ func (s *Service) GetSellerPayouts(ctx context.Context, sellerID uuid.UUID, limi } // RequestPayout creates a manual payout request if balance >= $100 (manual threshold) +// SECURITY(REM-002): Balance read inside transaction with SELECT FOR UPDATE to prevent double-spend. func (s *Service) RequestPayout(ctx context.Context, sellerID uuid.UUID) (*SellerPayout, error) { - balance, err := s.GetSellerBalance(ctx, sellerID) - if err != nil { - return nil, err - } - - manualMinCents := int64(10000) // $100 for manual payouts - if balance.AvailableCents < manualMinCents { - return nil, fmt.Errorf("%w: need at least $100.00, have $%.2f", ErrBelowMinimum, float64(balance.AvailableCents)/100) - } - var payout *SellerPayout - err = s.db.Transaction(func(tx *gorm.DB) error { + manualMinCents := int64(10000) // $100 for manual payouts + + err := s.db.Transaction(func(tx *gorm.DB) error { + // Lock the balance row to prevent concurrent payout race condition + var balance SellerBalance + if err := tx.WithContext(ctx). + Set("gorm:query_option", "FOR UPDATE"). + Where("seller_id = ?", sellerID). + First(&balance).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("%w: no balance found", ErrBelowMinimum) + } + return err + } + + if balance.AvailableCents < manualMinCents { + return fmt.Errorf("%w: need at least $100.00, have $%.2f", ErrBelowMinimum, float64(balance.AvailableCents)/100) + } + // Move from available to pending if err := tx.WithContext(ctx).Model(&SellerBalance{}). - Where("seller_id = ? AND currency = ?", sellerID, balance.Currency). + Where("id = ?", balance.ID). Updates(map[string]interface{}{ "available_cents": gorm.Expr("available_cents - ?", balance.AvailableCents), "pending_cents": gorm.Expr("pending_cents + ?", balance.AvailableCents), @@ -244,13 +253,25 @@ func (s *Service) ProcessScheduledPayouts(ctx context.Context) (int, error) { return processed, nil } +// SECURITY(REM-002): Lock balance row inside transaction to prevent concurrent double-spend. func (s *Service) processOnePayout(ctx context.Context, bal *SellerBalance) error { return s.db.Transaction(func(tx *gorm.DB) error { - amount := bal.AvailableCents + // Re-read balance with lock inside transaction + var lockedBal SellerBalance + if err := tx.WithContext(ctx). + Set("gorm:query_option", "FOR UPDATE"). + Where("id = ?", bal.ID). + First(&lockedBal).Error; err != nil { + return err + } + if lockedBal.AvailableCents < MinPayoutThresholdCents { + return ErrBelowMinimum // Balance changed since we queried outside tx + } + amount := lockedBal.AvailableCents // Move to pending if err := tx.WithContext(ctx).Model(&SellerBalance{}). - Where("id = ?", bal.ID). + Where("id = ?", lockedBal.ID). Updates(map[string]interface{}{ "available_cents": gorm.Expr("available_cents - ?", amount), "pending_cents": gorm.Expr("pending_cents + ?", amount), @@ -260,9 +281,9 @@ func (s *Service) processOnePayout(ctx context.Context, bal *SellerBalance) erro payout := &SellerPayout{ ID: uuid.New(), - SellerID: bal.SellerID, + SellerID: lockedBal.SellerID, AmountCents: amount, - Currency: bal.Currency, + Currency: lockedBal.Currency, Status: "processing", ScheduledAt: time.Now(), } @@ -271,14 +292,14 @@ func (s *Service) processOnePayout(ctx context.Context, bal *SellerBalance) erro } // Attempt transfer - err := s.transferService.CreateTransfer(ctx, bal.SellerID, amount, bal.Currency, payout.ID.String()) + err := s.transferService.CreateTransfer(ctx, lockedBal.SellerID, amount, lockedBal.Currency, payout.ID.String()) now := time.Now() if err != nil { payout.Status = "failed" payout.ErrorMessage = err.Error() // Revert pending back to available tx.WithContext(ctx).Model(&SellerBalance{}). - Where("id = ?", bal.ID). + Where("id = ?", lockedBal.ID). Updates(map[string]interface{}{ "available_cents": gorm.Expr("available_cents + ?", amount), "pending_cents": gorm.Expr("pending_cents - ?", amount), @@ -288,7 +309,7 @@ func (s *Service) processOnePayout(ctx context.Context, bal *SellerBalance) erro payout.ProcessedAt = &now // Move from pending to paid out tx.WithContext(ctx).Model(&SellerBalance{}). - Where("id = ?", bal.ID). + Where("id = ?", lockedBal.ID). Updates(map[string]interface{}{ "pending_cents": gorm.Expr("pending_cents - ?", amount), "total_paid_out_cents": gorm.Expr("total_paid_out_cents + ?", amount), diff --git a/veza-backend-api/internal/core/marketplace/service.go b/veza-backend-api/internal/core/marketplace/service.go index affd98e75..f95dd3ca7 100644 --- a/veza-backend-api/internal/core/marketplace/service.go +++ b/veza-backend-api/internal/core/marketplace/service.go @@ -579,9 +579,11 @@ func (s *Service) ListOrders(ctx context.Context, buyerID uuid.UUID) ([]Order, e type HyperswitchWebhookPayload struct { PaymentID string `json:"payment_id"` Status string `json:"status"` + Amount *int64 `json:"amount"` // SECURITY(REM-014): Amount for validation against order total Object *struct { PaymentID string `json:"payment_id"` Status string `json:"status"` + Amount *int64 `json:"amount"` } `json:"object"` } @@ -605,6 +607,17 @@ func (wp *HyperswitchWebhookPayload) getStatus() string { return "" } +// SECURITY(REM-014): Extract payment amount from webhook for validation. +func (wp *HyperswitchWebhookPayload) getAmount() *int64 { + if wp.Amount != nil { + return wp.Amount + } + if wp.Object != nil && wp.Object.Amount != nil { + return wp.Object.Amount + } + return nil +} + // ProcessPaymentWebhook handles Hyperswitch payment webhook. // Updates order status and creates licenses when status is "succeeded". func (s *Service) ProcessPaymentWebhook(ctx context.Context, payload []byte) error { @@ -628,6 +641,18 @@ func (s *Service) ProcessPaymentWebhook(ctx context.Context, payload []byte) err return err } + // SECURITY(REM-014): Validate payment amount matches order total when available. + if webhookAmount := wp.getAmount(); webhookAmount != nil { + expectedCents := int64(order.TotalAmount * 100) + if *webhookAmount != expectedCents { + s.logger.Error("Hyperswitch webhook: amount mismatch", + zap.Int64("webhook_amount", *webhookAmount), + zap.Int64("expected_amount", expectedCents), + zap.String("order_id", order.ID.String())) + return fmt.Errorf("webhook amount mismatch: got %d, expected %d", *webhookAmount, expectedCents) + } + } + switch status { case "succeeded": if order.Status == "completed" { @@ -806,8 +831,9 @@ func (s *Service) GetDownloadURL(ctx context.Context, buyerID uuid.UUID, product err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 1. Atomically check and lock the license row (prevents TOCTOU race condition) var license License + // SECURITY(REM-026): Also check license expiration before serving download err := tx.Set("gorm:query_option", "FOR UPDATE"). - Where("buyer_id = ? AND product_id = ? AND downloads_left > 0 AND revoked_at IS NULL", buyerID, productID). + Where("buyer_id = ? AND product_id = ? AND downloads_left > 0 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > NOW())", buyerID, productID). First(&license).Error if err != nil { @@ -1134,56 +1160,69 @@ var ErrRefundNotAvailable = errors.New("refunds not available") var ErrRefundForbidden = errors.New("you are not allowed to refund this order") // RefundOrder initiates a refund for an order (v0.403 R2) +// SECURITY(REM-003): Uses transaction with SELECT FOR UPDATE to prevent double-refund race condition. func (s *Service) RefundOrder(ctx context.Context, orderID, initiatorID uuid.UUID, reason string) error { - var order Order - if err := s.db.WithContext(ctx).Preload("Items").First(&order, "id = ?", orderID).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrOrderNotFound - } - return err - } - if order.BuyerID != initiatorID { - isSeller := false - for _, item := range order.Items { - var p Product - if err := s.db.WithContext(ctx).First(&p, "id = ?", item.ProductID).Error; err == nil && p.SellerID == initiatorID { - isSeller = true - break - } - } - if !isSeller { - return ErrRefundForbidden - } - } - if order.Status != "completed" && order.Status != "paid" { - return ErrOrderNotRefundable - } - // v0.12.0: Enforce 14-day refund window - if order.RefundDeadline != nil && time.Now().After(*order.RefundDeadline) { - return fmt.Errorf("%w: refund window expired (14 days)", ErrOrderNotRefundable) - } - // Fallback: if no deadline set, use 14 days from creation - if order.RefundDeadline == nil && time.Since(order.CreatedAt) > 14*24*time.Hour { - return fmt.Errorf("%w: refund window expired (14 days)", ErrOrderNotRefundable) - } - if order.HyperswitchPaymentID == "" { - return ErrOrderNotRefundable - } rp, ok := s.paymentProvider.(refundProvider) if !ok || rp == nil { return ErrRefundNotAvailable } - if err := rp.Refund(ctx, order.HyperswitchPaymentID, nil, reason); err != nil { - return fmt.Errorf("hyperswitch refund: %w", err) - } - now := time.Now() - if err := s.db.WithContext(ctx).Model(&Order{}).Where("id = ?", orderID).Updates(map[string]interface{}{ - "status": "refunded", - }).Error; err != nil { - return err - } - if err := s.db.WithContext(ctx).Model(&License{}).Where("order_id = ?", orderID).Update("revoked_at", now).Error; err != nil { - s.logger.Error("Failed to revoke licenses on refund", zap.Error(err), zap.String("order_id", orderID.String())) - } - return nil + + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // Lock the order row to prevent concurrent refund + var order Order + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Preload("Items"). + First(&order, "id = ?", orderID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrOrderNotFound + } + return err + } + + if order.BuyerID != initiatorID { + isSeller := false + for _, item := range order.Items { + var p Product + if err := tx.First(&p, "id = ?", item.ProductID).Error; err == nil && p.SellerID == initiatorID { + isSeller = true + break + } + } + if !isSeller { + return ErrRefundForbidden + } + } + + if order.Status != "completed" && order.Status != "paid" { + return ErrOrderNotRefundable + } + // v0.12.0: Enforce 14-day refund window + if order.RefundDeadline != nil && time.Now().After(*order.RefundDeadline) { + return fmt.Errorf("%w: refund window expired (14 days)", ErrOrderNotRefundable) + } + if order.RefundDeadline == nil && time.Since(order.CreatedAt) > 14*24*time.Hour { + return fmt.Errorf("%w: refund window expired (14 days)", ErrOrderNotRefundable) + } + if order.HyperswitchPaymentID == "" { + return ErrOrderNotRefundable + } + + // Mark as refunded BEFORE calling external API to prevent double-refund + if err := tx.Model(&Order{}).Where("id = ?", orderID).Updates(map[string]interface{}{ + "status": "refunded", + }).Error; err != nil { + return err + } + + // Call external refund API + if err := rp.Refund(ctx, order.HyperswitchPaymentID, nil, reason); err != nil { + return fmt.Errorf("hyperswitch refund: %w", err) + } + + now := time.Now() + if err := tx.Model(&License{}).Where("order_id = ?", orderID).Update("revoked_at", now).Error; err != nil { + s.logger.Error("Failed to revoke licenses on refund", zap.Error(err), zap.String("order_id", orderID.String())) + } + return nil + }) } diff --git a/veza-backend-api/internal/core/social/models.go b/veza-backend-api/internal/core/social/models.go index d96db040a..4eed0b7f5 100644 --- a/veza-backend-api/internal/core/social/models.go +++ b/veza-backend-api/internal/core/social/models.go @@ -29,8 +29,8 @@ type Post struct { PlaylistID *uuid.UUID `gorm:"type:uuid" json:"playlist_id,omitempty"` // Metrics (Cached) - LikeCount int `gorm:"default:0" json:"like_count"` - CommentCount int `gorm:"default:0" json:"comment_count"` + LikeCount int `gorm:"default:0" json:"-"` // SECURITY(REM-007): Hidden — popularity metrics are private + CommentCount int `gorm:"default:0" json:"comment_count"` // comment_count is interaction metadata, not popularity CreatedAt time.Time `gorm:"autoCreateTime;index" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` diff --git a/veza-backend-api/internal/core/subscription/service.go b/veza-backend-api/internal/core/subscription/service.go index 1a627c079..62eaf7feb 100644 --- a/veza-backend-api/internal/core/subscription/service.go +++ b/veza-backend-api/internal/core/subscription/service.go @@ -232,30 +232,30 @@ func (s *Service) createNewSubscription(ctx context.Context, userID uuid.UUID, p CurrentPeriodEnd: periodEnd, } - // Apply trial if available - // SECURITY(MEDIUM-009): Check if user already used a free trial to prevent unlimited reuse. - if plan.TrialDays > 0 { - var previousTrialCount int64 - s.db.WithContext(ctx).Model(&UserSubscription{}). - Where("user_id = ? AND trial_start IS NOT NULL", userID). - Count(&previousTrialCount) - if previousTrialCount > 0 { - // User already had a trial — skip trial, go straight to active/payment - sub.Status = StatusActive - } else { - trialEnd := now.AddDate(0, 0, plan.TrialDays) - sub.Status = StatusTrialing - sub.TrialStart = &now - sub.TrialEnd = &trialEnd - sub.CurrentPeriodEnd = trialEnd // Trial period is the first period - } - } else { - sub.Status = StatusActive - } - var clientSecret, paymentID string + // SECURITY(REM-015): Trial check + subscription creation in single transaction to prevent + // race condition where two concurrent requests both see previousTrialCount=0. err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // Apply trial if available — checked INSIDE transaction for atomicity + if plan.TrialDays > 0 { + var previousTrialCount int64 + tx.Model(&UserSubscription{}). + Where("user_id = ? AND trial_start IS NOT NULL", userID). + Count(&previousTrialCount) + if previousTrialCount > 0 { + sub.Status = StatusActive + } else { + trialEnd := now.AddDate(0, 0, plan.TrialDays) + sub.Status = StatusTrialing + sub.TrialStart = &now + sub.TrialEnd = &trialEnd + sub.CurrentPeriodEnd = trialEnd + } + } else { + sub.Status = StatusActive + } + if err := tx.Create(sub).Error; err != nil { return fmt.Errorf("failed to create subscription: %w", err) } diff --git a/veza-backend-api/internal/handlers/account_deletion_handler.go b/veza-backend-api/internal/handlers/account_deletion_handler.go index 377a62b9c..11a9dbe9f 100644 --- a/veza-backend-api/internal/handlers/account_deletion_handler.go +++ b/veza-backend-api/internal/handlers/account_deletion_handler.go @@ -109,6 +109,18 @@ func DeleteAccountHandler( _ = db.WithContext(ctx).Exec("UPDATE tracks SET deleted_at = NOW() WHERE creator_id = ?", userID) } + // 2d. SECURITY(REM-013): Anonymize financial data for GDPR compliance. + // Orders are anonymized (not deleted) for legal/accounting obligations. + _ = db.WithContext(ctx).Exec("UPDATE orders SET buyer_id = ? WHERE buyer_id = ?", uuid.Nil, userID) + // Revoke active licenses + _ = db.WithContext(ctx).Exec("UPDATE licenses SET revoked_at = NOW() WHERE buyer_id = ? AND revoked_at IS NULL", userID) + // Zero out seller balances (prevent orphaned payouts) + _ = db.WithContext(ctx).Exec("UPDATE seller_balances SET available_cents = 0, pending_cents = 0 WHERE seller_id = ?", userID) + // Cancel pending payouts + _ = db.WithContext(ctx).Exec("UPDATE seller_payouts SET status = 'cancelled' WHERE seller_id = ? AND status IN ('pending', 'processing')", userID) + // Cancel active subscriptions + _ = db.WithContext(ctx).Exec("UPDATE user_subscriptions SET status = 'cancelled', cancelled_at = NOW() WHERE user_id = ? AND status IN ('active', 'trialing')", userID) + // 3. Revoke all sessions if sessionService != nil { if _, err := sessionService.RevokeAllUserSessions(ctx, userID); err != nil { diff --git a/veza-backend-api/internal/handlers/chat_websocket_handler.go b/veza-backend-api/internal/handlers/chat_websocket_handler.go index 56fdf37aa..5bc605da1 100644 --- a/veza-backend-api/internal/handlers/chat_websocket_handler.go +++ b/veza-backend-api/internal/handlers/chat_websocket_handler.go @@ -47,7 +47,7 @@ func (h *ChatWebSocketHandler) HandleWebSocket(c *gin.Context) { } conn, err := websocket.Accept(c.Writer, c.Request, &websocket.AcceptOptions{ - InsecureSkipVerify: true, + OriginPatterns: GetAllowedWebSocketOrigins(), }) if err != nil { h.logger.Error("Failed to accept WebSocket", diff --git a/veza-backend-api/internal/handlers/co_listening_websocket_handler.go b/veza-backend-api/internal/handlers/co_listening_websocket_handler.go index 3baf5e1c6..d1dff3bf7 100644 --- a/veza-backend-api/internal/handlers/co_listening_websocket_handler.go +++ b/veza-backend-api/internal/handlers/co_listening_websocket_handler.go @@ -102,7 +102,7 @@ func (h *CoListeningWebSocketHandler) HandleWebSocket(c *gin.Context) { } conn, err := websocket.Accept(c.Writer, c.Request, &websocket.AcceptOptions{ - InsecureSkipVerify: true, + OriginPatterns: GetAllowedWebSocketOrigins(), }) if err != nil { h.logger.Error("Failed to accept WebSocket", zap.Error(err)) diff --git a/veza-backend-api/internal/handlers/common.go b/veza-backend-api/internal/handlers/common.go index 2e0f6e2c1..17386a2d5 100644 --- a/veza-backend-api/internal/handlers/common.go +++ b/veza-backend-api/internal/handlers/common.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "os" "strconv" "strings" "time" @@ -598,15 +599,10 @@ func (h *CommonHandler) SafeMarshalJSON(v interface{}) ([]byte, error) { return data, nil } -// GetClientIP obtient l'IP réelle du client +// GetClientIP obtient l'IP réelle du client via Gin's trusted proxy mechanism. +// SECURITY(REM-006): Uses c.ClientIP() which respects SetTrustedProxies() instead of +// manually parsing X-Forwarded-For (which is spoofable). func (h *CommonHandler) GetClientIP(c *gin.Context) string { - // Vérifier les headers de proxy - if ip := c.GetHeader("X-Forwarded-For"); ip != "" { - return strings.Split(ip, ",")[0] - } - if ip := c.GetHeader("X-Real-IP"); ip != "" { - return ip - } return c.ClientIP() } @@ -618,3 +614,25 @@ func (h *CommonHandler) RateLimitKey(c *gin.Context, prefix string) string { } return prefix + ":ip:" + h.GetClientIP(c) } + +// GetAllowedWebSocketOrigins returns the list of allowed origins for WebSocket connections. +// SECURITY(REM-001): Replaces InsecureSkipVerify: true with explicit origin whitelist. +func GetAllowedWebSocketOrigins() []string { + originsStr := os.Getenv("CORS_ALLOWED_ORIGINS") + if originsStr == "" { + // Default development origins + return []string{"http://localhost:*", "http://127.0.0.1:*", "http://veza.fr:*"} + } + origins := strings.Split(originsStr, ",") + var patterns []string + for _, o := range origins { + o = strings.TrimSpace(o) + if o != "" { + patterns = append(patterns, o) + } + } + if len(patterns) == 0 { + return []string{"http://localhost:*"} + } + return patterns +} diff --git a/veza-backend-api/internal/handlers/marketplace.go b/veza-backend-api/internal/handlers/marketplace.go index 04769d0f8..920c3b536 100644 --- a/veza-backend-api/internal/handlers/marketplace.go +++ b/veza-backend-api/internal/handlers/marketplace.go @@ -272,22 +272,30 @@ func (h *MarketplaceHandler) UploadProductPreview(c *gin.Context) { response.BadRequest(c, "Missing or invalid file") return } + // SECURITY(REM-005): Validate file size (max 50MB for audio previews) + const maxPreviewSize = 50 << 20 // 50MB + if file.Size > maxPreviewSize { + response.BadRequest(c, "File too large. Maximum 50MB") + return + } ext := strings.ToLower(filepath.Ext(file.Filename)) if ext != ".mp3" && ext != ".wav" && ext != ".m4a" && ext != ".ogg" { response.BadRequest(c, "Invalid file type. Allowed: mp3, wav, m4a, ogg") return } + // SECURITY(REM-005): Sanitize filename to prevent path traversal — use UUID instead of user-supplied name. + safeFilename := uuid.New().String() + ext previewDir := filepath.Join(h.uploadDir, "products", "previews", productID.String()) if err := os.MkdirAll(previewDir, 0755); err != nil { response.InternalServerError(c, "Failed to create preview directory") return } - destPath := filepath.Join(previewDir, file.Filename) + destPath := filepath.Join(previewDir, safeFilename) if err := c.SaveUploadedFile(file, destPath); err != nil { response.InternalServerError(c, "Failed to save preview") return } - relativePath := "products/previews/" + productID.String() + "/" + file.Filename + relativePath := "products/previews/" + productID.String() + "/" + safeFilename preview, err := h.service.AddProductPreview(c.Request.Context(), productID, userID, relativePath, nil) if err != nil { if err == marketplace.ErrProductNotFound { diff --git a/veza-backend-api/internal/handlers/playback_websocket_handler.go b/veza-backend-api/internal/handlers/playback_websocket_handler.go index f49c0f8bd..439e1e423 100644 --- a/veza-backend-api/internal/handlers/playback_websocket_handler.go +++ b/veza-backend-api/internal/handlers/playback_websocket_handler.go @@ -103,7 +103,7 @@ func (h *PlaybackWebSocketHandler) WebSocketHandler(c *gin.Context) { // Mettre à niveau la connexion HTTP vers WebSocket (INT-06: coder/websocket) conn, err := websocket.Accept(c.Writer, c.Request, &websocket.AcceptOptions{ - InsecureSkipVerify: true, // En production, vérifier l'origine via OriginPatterns + OriginPatterns: GetAllowedWebSocketOrigins(), }) if err != nil { h.logger.Error("Failed to upgrade connection to WebSocket", @@ -135,6 +135,9 @@ func (c *Client) readPump() { _ = c.conn.Close(websocket.StatusNormalClosure, "") }() + // SECURITY(REM-025): Limit WebSocket message size to prevent memory exhaustion + c.conn.SetReadLimit(64 * 1024) // 64KB max message size + for { readCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) _, message, err := c.conn.Read(readCtx) diff --git a/veza-backend-api/internal/services/social_service.go b/veza-backend-api/internal/services/social_service.go index 05ccab0fe..c26b249dc 100644 --- a/veza-backend-api/internal/services/social_service.go +++ b/veza-backend-api/internal/services/social_service.go @@ -318,7 +318,7 @@ type SuggestionUser struct { ID uuid.UUID `json:"id"` Username string `json:"username"` AvatarURL string `json:"avatar_url"` - FollowersCount int `json:"followers_count"` + FollowersCount int `json:"-"` // SECURITY(REM-007): Hidden — popularity metrics are private } // GetFollowSuggestions returns users to follow based on "friends of friends" (v0.10.0 F211). diff --git a/veza-backend-api/internal/services/user_service.go b/veza-backend-api/internal/services/user_service.go index 77f24c735..0221db597 100644 --- a/veza-backend-api/internal/services/user_service.go +++ b/veza-backend-api/internal/services/user_service.go @@ -72,8 +72,8 @@ type Profile struct { SocialLinks map[string]interface{} `json:"social_links"` IsPublic bool `json:"is_public"` CreatedAt time.Time `json:"created_at"` - FollowersCount int `json:"followers_count"` - FollowingCount int `json:"following_count"` + FollowersCount int `json:"-"` // SECURITY(REM-007): Hidden from public API — popularity metrics are private + FollowingCount int `json:"-"` // SECURITY(REM-007): Hidden from public API — popularity metrics are private IsFollowing bool `json:"is_following"` } diff --git a/veza-backend-api/internal/utils/utils.go b/veza-backend-api/internal/utils/utils.go index 52f534125..f0c812794 100644 --- a/veza-backend-api/internal/utils/utils.go +++ b/veza-backend-api/internal/utils/utils.go @@ -54,7 +54,7 @@ func GenerateRandomBytes(length int) ([]byte, error) { // HashPassword hash un mot de passe avec bcrypt func HashPassword(password string) (string, error) { - hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), 12) // SECURITY(REM-030): Aligned with password_service.go bcryptCost=12 if err != nil { return "", fmt.Errorf("failed to hash password: %w", err) } diff --git a/veza-stream-server/Dockerfile b/veza-stream-server/Dockerfile index c4ee12f8b..7926e360c 100644 --- a/veza-stream-server/Dockerfile +++ b/veza-stream-server/Dockerfile @@ -26,7 +26,7 @@ COPY veza-stream-server/build.rs ./ RUN cargo build --release --locked --target x86_64-unknown-linux-musl # Runtime stage -FROM alpine:latest +FROM alpine:3.21 # Install runtime dependencies RUN apk --no-cache add ca-certificates tzdata && \ diff --git a/veza-stream-server/src/auth/token_validator.rs b/veza-stream-server/src/auth/token_validator.rs index 53634ab5e..973a82ae7 100644 --- a/veza-stream-server/src/auth/token_validator.rs +++ b/veza-stream-server/src/auth/token_validator.rs @@ -137,7 +137,8 @@ impl TokenValidator { Ok(signature_bytes.ct_eq(&expected_bytes).into()) } - /// Valide et enregistre un token de streaming + /// Valide et enregistre un token de streaming. + /// SECURITY(REM-012): Enforces single-use tokens with max_access_count to prevent replay attacks. pub async fn validate_and_register_token(&self, request: &StreamRequest) -> Result { // Valider la signature if !self.validate_signature( @@ -151,6 +152,20 @@ impl TokenValidator { }); } + // SECURITY(REM-012): Include user_id in token key to prevent cross-user replay + let uid_str = request.user_id.map(|u| u.to_string()).unwrap_or_default(); + let token_key = format!("{}:{}:{}", request.track_id, request.expires, uid_str); + let mut active_tokens = self.active_tokens.write().await; + + // Check if token was already consumed (replay detection) + if let Some(existing) = active_tokens.get(&token_key) { + if existing.access_count >= Self::MAX_TOKEN_ACCESS { + return Err(AppError::SignatureError { + message: "Token already consumed (replay detected)".to_string(), + }); + } + } + // Créer les informations du token let token_info = TokenInfo { track_id: request.track_id.clone(), @@ -161,20 +176,27 @@ impl TokenValidator { }; // Enregistrer le token actif - let token_key = format!("{}:{}", request.track_id, request.expires); - let mut active_tokens = self.active_tokens.write().await; active_tokens.insert(token_key, token_info.clone()); Ok(token_info) } - /// Enregistre l'accès à un token + /// Maximum number of times a single token can be used before being rejected. + const MAX_TOKEN_ACCESS: u32 = 3; + + /// Enregistre l'accès à un token. + /// SECURITY(REM-012): Enforces max access count to prevent stream URL replay. pub async fn record_token_access(&self, track_id: &str, expires: u64) -> Result<()> { - let token_key = format!("{}:{}", track_id, expires); + let token_key = format!("{}:{}:", track_id, expires); // empty user_id for backward compat let mut active_tokens = self.active_tokens.write().await; if let Some(token_info) = active_tokens.get_mut(&token_key) { token_info.access_count += 1; + if token_info.access_count > Self::MAX_TOKEN_ACCESS { + return Err(AppError::SignatureError { + message: "Token access limit exceeded".to_string(), + }); + } } Ok(())