veza/veza-backend-api/internal/services/webhook_service.go
senke 78db1fa684 fix(security): add SSRF protection, real track access validation, and pagination bounds
- Add IsURLSafe() function to webhook service blocking private IPs,
  localhost, and cloud metadata endpoints (SSRF protection)
- Implement real validate_track_access() in stream server querying DB
  for track visibility, ownership, and purchase status
- Remove dangerous JWT fallback user in chat server that allowed
  deleted users to maintain access with forged credentials
- Add upper limit (100) on pagination in profile, track, and room handlers
- Fix Dockerfile.production healthcheck path to /api/v1/health

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 22:44:03 +01:00

383 lines
10 KiB
Go

package services
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"veza-backend-api/internal/models"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
)
// WebhookService gère les webhooks
type WebhookService struct {
db *gorm.DB
logger *zap.Logger
secret string
client *http.Client
}
// WebhookPayload représente le payload d'un webhook
type WebhookPayload struct {
Event string `json:"event"`
Timestamp time.Time `json:"timestamp"`
Data map[string]interface{} `json:"data"`
}
// blockedHostnames contains hostnames that must never be used as webhook targets (SSRF protection).
var blockedHostnames = []string{
"localhost",
"metadata",
"metadata.google.internal",
"metadata.google",
"instance-data",
}
// isPrivateIP checks whether an IP address belongs to a private or reserved range.
func isPrivateIP(ip net.IP) bool {
privateRanges := []struct {
network string
}{
{"10.0.0.0/8"},
{"172.16.0.0/12"},
{"192.168.0.0/16"},
{"127.0.0.0/8"},
{"169.254.0.0/16"}, // link-local / cloud metadata
{"::1/128"}, // IPv6 loopback
{"fc00::/7"}, // IPv6 unique local
{"fe80::/10"}, // IPv6 link-local
}
for _, r := range privateRanges {
_, cidr, err := net.ParseCIDR(r.network)
if err != nil {
continue
}
if cidr.Contains(ip) {
return true
}
}
return false
}
// IsURLSafe validates that a webhook URL does not target internal or private resources (SSRF protection).
func IsURLSafe(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
// Only allow http and https schemes
scheme := strings.ToLower(parsed.Scheme)
if scheme != "http" && scheme != "https" {
return fmt.Errorf("unsupported URL scheme %q: only http and https are allowed", parsed.Scheme)
}
hostname := strings.ToLower(parsed.Hostname())
if hostname == "" {
return fmt.Errorf("URL must have a hostname")
}
// Block known dangerous hostnames
for _, blocked := range blockedHostnames {
if hostname == blocked {
return fmt.Errorf("hostname %q is not allowed", hostname)
}
}
// Resolve hostname to IPs and check each one
ips, err := net.LookupIP(hostname)
if err != nil {
return fmt.Errorf("failed to resolve hostname %q: %w", hostname, err)
}
for _, ip := range ips {
if isPrivateIP(ip) {
return fmt.Errorf("hostname %q resolves to private IP %s which is not allowed", hostname, ip.String())
}
}
return nil
}
// NewWebhookService crée un nouveau service de webhooks
func NewWebhookService(db *gorm.DB, logger *zap.Logger, secret string) *WebhookService {
return &WebhookService{
db: db,
logger: logger,
secret: secret,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// GenerateAPIKey génère une clé API sécurisée pour un webhook (BE-SEC-012)
func (s *WebhookService) GenerateAPIKey() (string, error) {
// Générer 32 bytes aléatoires
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
// Encoder en base64 URL-safe et préfixer avec "whk_" pour identifier les clés webhook
apiKey := "whk_" + base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes)
return apiKey, nil
}
// RegisterWebhook enregistre une nouvelle URL de webhook avec génération de clé API (BE-SEC-012)
func (s *WebhookService) RegisterWebhook(ctx context.Context, userID uuid.UUID, url string, events []string) (*models.Webhook, error) {
// SSRF protection: validate URL before registering
if err := IsURLSafe(url); err != nil {
return nil, fmt.Errorf("webhook URL rejected: %w", err)
}
// Générer une clé API unique
apiKey, err := s.GenerateAPIKey()
if err != nil {
return nil, fmt.Errorf("failed to generate API key: %w", err)
}
webhook := &models.Webhook{
UserID: userID,
URL: url,
Events: events,
Active: true,
APIKey: apiKey,
CreatedAt: time.Now(),
}
if err := s.db.WithContext(ctx).Create(webhook).Error; err != nil {
return nil, fmt.Errorf("failed to register webhook: %w", err)
}
s.logger.Info("Webhook registered",
zap.String("user_id", userID.String()),
zap.String("url", url),
zap.Strings("events", events),
zap.String("api_key_prefix", apiKey[:8]+"..."))
return webhook, nil
}
// DeliverWebhook envoie un webhook avec retry et signature HMAC
func (s *WebhookService) DeliverWebhook(ctx context.Context, webhook *models.Webhook, event string, data map[string]interface{}) error {
payload := WebhookPayload{
Event: event,
Timestamp: time.Now(),
Data: data,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
// Générer signature HMAC
signature := s.generateSignature(jsonData)
// SSRF protection: validate URL before delivering
if err := IsURLSafe(webhook.URL); err != nil {
return fmt.Errorf("webhook URL rejected: %w", err)
}
// Créer la requête HTTP
req, err := http.NewRequestWithContext(ctx, "POST", webhook.URL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Veza-Signature", signature)
req.Header.Set("X-Veza-Event", event)
req.Header.Set("X-Veza-Timestamp", payload.Timestamp.Format(time.RFC3339))
// FIX #11: Propager le request_id depuis le contexte si disponible
if requestID := extractRequestIDFromContext(ctx); requestID != "" {
req.Header.Set("X-Request-ID", requestID)
}
// Envoyer avec retry
maxRetries := 3
backoff := time.Second
for i := 0; i < maxRetries; i++ {
resp, err := s.client.Do(req)
if err != nil {
s.logger.Warn("Webhook delivery failed, retrying",
zap.Int("attempt", i+1),
zap.Error(err))
if i < maxRetries-1 {
time.Sleep(backoff)
backoff *= 2 // Exponential backoff
continue
}
return fmt.Errorf("webhook delivery failed after %d attempts: %w", maxRetries, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
s.logger.Info("Webhook delivered successfully",
zap.String("url", webhook.URL),
zap.String("event", event))
return nil
}
s.logger.Warn("Webhook returned non-200 status",
zap.String("url", webhook.URL),
zap.Int("status", resp.StatusCode))
}
return fmt.Errorf("webhook delivery failed")
}
// generateSignature génère une signature HMAC-SHA256
func (s *WebhookService) generateSignature(payload []byte) string {
mac := hmac.New(sha256.New, []byte(s.secret))
mac.Write(payload)
return hex.EncodeToString(mac.Sum(nil))
}
// VerifySignature vérifie une signature HMAC
func (s *WebhookService) VerifySignature(signature string, payload []byte) bool {
expected := s.generateSignature(payload)
return hmac.Equal([]byte(signature), []byte(expected))
}
// TriggerEvent déclenche un événement pour tous les webhooks concernés
func (s *WebhookService) TriggerEvent(ctx context.Context, event string, data map[string]interface{}, userID *uuid.UUID) error {
// Récupérer les webhooks actifs pour cet événement
var webhooks []models.Webhook
query := s.db.WithContext(ctx).Where("active = ? AND events @> ARRAY[?]", true, event)
if userID != nil {
query = query.Where("user_id = ?", *userID)
}
if err := query.Find(&webhooks).Error; err != nil {
return fmt.Errorf("failed to fetch webhooks: %w", err)
}
// Envoyer les webhooks en async
for _, webhook := range webhooks {
go func(w models.Webhook) {
if err := s.DeliverWebhook(ctx, &w, event, data); err != nil {
s.logger.Error("Failed to deliver webhook",
zap.Error(err),
zap.String("url", w.URL),
zap.String("event", event))
}
}(webhook)
}
return nil
}
// ListWebhooks liste les webhooks d'un utilisateur
func (s *WebhookService) ListWebhooks(ctx context.Context, userID uuid.UUID) ([]models.Webhook, error) {
var webhooks []models.Webhook
if err := s.db.WithContext(ctx).
Where("user_id = ?", userID).
Find(&webhooks).Error; err != nil {
return nil, fmt.Errorf("failed to list webhooks: %w", err)
}
return webhooks, nil
}
// GetWebhook récupère un webhook par son ID et userID
func (s *WebhookService) GetWebhook(ctx context.Context, webhookID, userID uuid.UUID) (*models.Webhook, error) {
var webhook models.Webhook
if err := s.db.WithContext(ctx).
Where("id = ? AND user_id = ?", webhookID, userID).
First(&webhook).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("webhook not found")
}
return nil, fmt.Errorf("failed to get webhook: %w", err)
}
return &webhook, nil
}
// DeleteWebhook supprime un webhook
func (s *WebhookService) DeleteWebhook(ctx context.Context, webhookID, userID uuid.UUID) error {
result := s.db.WithContext(ctx).
Where("id = ? AND user_id = ?", webhookID, userID).
Delete(&models.Webhook{})
if result.Error != nil {
return fmt.Errorf("failed to delete webhook: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("webhook not found")
}
return nil
}
// ValidateAPIKey valide une clé API et retourne le webhook associé (BE-SEC-012)
func (s *WebhookService) ValidateAPIKey(ctx context.Context, apiKey string) (*models.Webhook, error) {
if !strings.HasPrefix(apiKey, "whk_") {
return nil, fmt.Errorf("invalid API key format")
}
var webhook models.Webhook
if err := s.db.WithContext(ctx).
Where("api_key = ? AND active = ?", apiKey, true).
First(&webhook).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("invalid API key")
}
return nil, fmt.Errorf("failed to validate API key: %w", err)
}
return &webhook, nil
}
// RegenerateAPIKey régénère la clé API d'un webhook (BE-SEC-012)
func (s *WebhookService) RegenerateAPIKey(ctx context.Context, webhookID, userID uuid.UUID) (string, error) {
// Vérifier que le webhook existe et appartient à l'utilisateur
webhook, err := s.GetWebhook(ctx, webhookID, userID)
if err != nil {
return "", err
}
// Générer une nouvelle clé API
newAPIKey, err := s.GenerateAPIKey()
if err != nil {
return "", fmt.Errorf("failed to generate API key: %w", err)
}
// Mettre à jour le webhook
webhook.APIKey = newAPIKey
webhook.UpdatedAt = time.Now()
if err := s.db.WithContext(ctx).Save(webhook).Error; err != nil {
return "", fmt.Errorf("failed to update webhook: %w", err)
}
s.logger.Info("Webhook API key regenerated",
zap.String("webhook_id", webhookID.String()),
zap.String("user_id", userID.String()))
return newAPIKey, nil
}