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>
132 lines
3.7 KiB
Go
132 lines
3.7 KiB
Go
package workers
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// HardDeleteWorker performs final GDPR cleanup for users past recovery deadline (v0.10.8 F065)
|
|
// Runs periodically to irreversibly anonymize accounts that were soft-deleted 30+ days ago
|
|
type HardDeleteWorker struct {
|
|
db *gorm.DB
|
|
logger *zap.Logger
|
|
interval time.Duration
|
|
stopChan chan struct{}
|
|
running bool
|
|
}
|
|
|
|
// NewHardDeleteWorker creates a new hard delete worker
|
|
func NewHardDeleteWorker(db *gorm.DB, logger *zap.Logger, interval time.Duration) *HardDeleteWorker {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
if interval <= 0 {
|
|
interval = 24 * time.Hour
|
|
}
|
|
return &HardDeleteWorker{
|
|
db: db,
|
|
logger: logger,
|
|
interval: interval,
|
|
stopChan: make(chan struct{}),
|
|
running: false,
|
|
}
|
|
}
|
|
|
|
// Start runs the worker periodically
|
|
func (w *HardDeleteWorker) Start(ctx context.Context) {
|
|
if w.running {
|
|
w.logger.Warn("Hard delete worker is already running")
|
|
return
|
|
}
|
|
w.running = true
|
|
w.logger.Info("Starting hard delete worker", zap.Duration("interval", w.interval))
|
|
|
|
go w.runOnce(ctx)
|
|
|
|
ticker := time.NewTicker(w.interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
w.logger.Info("Stopping hard delete worker")
|
|
w.running = false
|
|
return
|
|
case <-w.stopChan:
|
|
w.logger.Info("Stopping hard delete worker (stop requested)")
|
|
w.running = false
|
|
return
|
|
case <-ticker.C:
|
|
go w.runOnce(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stop stops the worker
|
|
func (w *HardDeleteWorker) Stop() {
|
|
if !w.running {
|
|
return
|
|
}
|
|
close(w.stopChan)
|
|
}
|
|
|
|
// runOnce executes one pass of hard delete / final anonymization
|
|
func (w *HardDeleteWorker) runOnce(ctx context.Context) {
|
|
logger := w.logger.With(zap.String("worker", "hard_delete"))
|
|
runCtx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
|
defer cancel()
|
|
|
|
var userIDs []uuid.UUID
|
|
if err := w.db.WithContext(runCtx).Raw(`
|
|
SELECT id FROM users
|
|
WHERE deleted_at IS NOT NULL AND recovery_deadline IS NOT NULL AND recovery_deadline < NOW()
|
|
`).Scan(&userIDs).Error; err != nil {
|
|
logger.Error("Failed to query users for hard delete", zap.Error(err))
|
|
return
|
|
}
|
|
|
|
if len(userIDs) == 0 {
|
|
logger.Debug("No users eligible for hard delete")
|
|
return
|
|
}
|
|
|
|
logger.Info("Processing hard delete", zap.Int("count", len(userIDs)))
|
|
|
|
for _, id := range userIDs {
|
|
// Final anonymization pass (ORIGIN §19.3) - ensure no PII remains
|
|
res := w.db.WithContext(runCtx).Exec(`
|
|
UPDATE users SET
|
|
password_hash = '',
|
|
first_name = NULL,
|
|
last_name = NULL,
|
|
bio = '',
|
|
location = '',
|
|
avatar = '',
|
|
banner_url = '',
|
|
recovery_deadline = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = ? AND deleted_at IS NOT NULL
|
|
`, id)
|
|
if res.Error != nil {
|
|
logger.Error("Failed to anonymize user", zap.Error(res.Error), zap.String("user_id", id.String()))
|
|
continue
|
|
}
|
|
// Delete user_profiles (may contain PII)
|
|
w.db.WithContext(runCtx).Exec("DELETE FROM user_profiles WHERE user_id = ?", id)
|
|
|
|
// SECURITY(HIGH-007): RGPD — clean additional PII-containing tables
|
|
w.db.WithContext(runCtx).Exec("DELETE FROM user_sessions WHERE user_id = ?", id)
|
|
w.db.WithContext(runCtx).Exec("DELETE FROM user_settings WHERE user_id = ?", id)
|
|
w.db.WithContext(runCtx).Exec("DELETE FROM user_follows WHERE follower_id = ? OR following_id = ?", id, id)
|
|
w.db.WithContext(runCtx).Exec("DELETE FROM notifications WHERE user_id = ? OR actor_id = ?", id, id)
|
|
w.db.WithContext(runCtx).Exec("UPDATE audit_logs SET user_id = NULL, ip_address = NULL WHERE user_id = ?", id)
|
|
// TODO(HIGH-007): Clean Redis cache keys (user:{id}:*) and Elasticsearch user documents.
|
|
// Requires injecting Redis/ES clients into HardDeleteWorker.
|
|
|
|
logger.Info("Hard delete completed", zap.String("user_id", id.String()))
|
|
}
|
|
}
|