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>
This commit is contained in:
parent
8365cbfa6f
commit
2d664f9177
7 changed files with 197 additions and 30 deletions
|
|
@ -65,7 +65,7 @@ EXPOSE 8080
|
|||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/v1/health || exit 1
|
||||
|
||||
# Run the application
|
||||
ENTRYPOINT ["./veza-api"]
|
||||
|
|
|
|||
|
|
@ -827,6 +827,9 @@ func (h *TrackHandler) ListTracks(c *gin.Context) {
|
|||
if _, err := fmt.Sscanf(limit, "%d", &limitInt); err != nil || limitInt < 1 {
|
||||
limitInt = 20
|
||||
}
|
||||
if limitInt > 100 {
|
||||
limitInt = 100
|
||||
}
|
||||
|
||||
// Construire les paramètres
|
||||
params := TrackListParams{
|
||||
|
|
@ -1414,6 +1417,9 @@ func (h *TrackHandler) SearchTracks(c *gin.Context) {
|
|||
params.Limit = limit
|
||||
}
|
||||
}
|
||||
if params.Limit > 100 {
|
||||
params.Limit = 100
|
||||
}
|
||||
|
||||
// Parser tags
|
||||
if tagsStr := c.Query("tags"); tagsStr != "" {
|
||||
|
|
|
|||
|
|
@ -215,6 +215,9 @@ func (h *ProfileHandler) ListUsers(c *gin.Context) {
|
|||
if err != nil || limit < 1 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// Récupérer les paramètres de filtrage
|
||||
// BE-SEC-009: Sanitize search query to prevent injection
|
||||
|
|
|
|||
|
|
@ -260,6 +260,9 @@ func (h *RoomHandler) GetRoomHistory(c *gin.Context) {
|
|||
if err != nil || limitInt <= 0 {
|
||||
limitInt = 50
|
||||
}
|
||||
if limitInt > 100 {
|
||||
limitInt = 100
|
||||
}
|
||||
offsetInt, err := strconv.Atoi(offset)
|
||||
if err != nil || offsetInt < 0 {
|
||||
offsetInt = 0
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import (
|
|||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -36,6 +38,82 @@ type WebhookPayload struct {
|
|||
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{
|
||||
|
|
@ -63,6 +141,11 @@ func (s *WebhookService) GenerateAPIKey() (string, error) {
|
|||
|
||||
// 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 {
|
||||
|
|
@ -107,6 +190,11 @@ func (s *WebhookService) DeliverWebhook(ctx context.Context, webhook *models.Web
|
|||
// 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 {
|
||||
|
|
|
|||
|
|
@ -407,21 +407,23 @@ impl JwtManager {
|
|||
(username, role)
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
tracing::error!(
|
||||
user_id = %claims.user_id,
|
||||
"Utilisateur non trouvé dans la DB lors du refresh token, utilisation de valeurs par défaut"
|
||||
"User not found in DB during token refresh — rejecting token"
|
||||
);
|
||||
// Fallback si utilisateur non trouvé (ne devrait pas arriver en production)
|
||||
("user".to_string(), "user".to_string())
|
||||
return Err(ChatError::AuthError(
|
||||
"User no longer exists — token refresh denied".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback si pas de pool DB (mode dégradé)
|
||||
tracing::warn!(
|
||||
tracing::error!(
|
||||
user_id = %claims.user_id,
|
||||
"Pas de pool DB disponible, utilisation de valeurs par défaut pour refresh token"
|
||||
"No DB pool available for token refresh — rejecting token"
|
||||
);
|
||||
("user".to_string(), "user".to_string())
|
||||
return Err(ChatError::AuthError(
|
||||
"Database unavailable — token refresh denied".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
// MIGRATION UUID: Cloner user_id avant de le move
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use crate::error::{AppError, Result};
|
|||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
|
@ -27,6 +28,7 @@ pub struct SignatureConfig {
|
|||
pub struct TokenValidator {
|
||||
config: SignatureConfig,
|
||||
active_tokens: Arc<RwLock<HashMap<String, TokenInfo>>>,
|
||||
db_pool: Option<PgPool>,
|
||||
}
|
||||
|
||||
/// Informations sur un token actif
|
||||
|
|
@ -54,6 +56,16 @@ impl TokenValidator {
|
|||
Self {
|
||||
config,
|
||||
active_tokens: Arc::new(RwLock::new(HashMap::new())),
|
||||
db_pool: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un nouveau validateur de tokens avec accès à la base de données
|
||||
pub fn with_db_pool(config: SignatureConfig, db_pool: PgPool) -> Self {
|
||||
Self {
|
||||
config,
|
||||
active_tokens: Arc::new(RwLock::new(HashMap::new())),
|
||||
db_pool: Some(db_pool),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -237,31 +249,80 @@ impl TokenValidator {
|
|||
})
|
||||
}
|
||||
|
||||
/// Valide les permissions d'accès à un track
|
||||
/// Valide les permissions d'accès à un track en interrogeant la base de données.
|
||||
///
|
||||
/// Règles d'accès :
|
||||
/// - Le track doit exister
|
||||
/// - Le track public (`is_public = true`) est accessible à tous
|
||||
/// - Le propriétaire du track y a toujours accès
|
||||
/// - Un utilisateur ayant acheté le track (`orders` avec `status = 'completed'`) y a accès
|
||||
/// - Sinon, accès refusé
|
||||
pub async fn validate_track_access(
|
||||
&self,
|
||||
track_id: &str,
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<bool> {
|
||||
// Pour l'instant, on autorise tous les accès authentifiés
|
||||
// Dans une implémentation complète, on vérifierait les permissions spécifiques
|
||||
// en interrogeant la base de données ou un service d'autorisation
|
||||
|
||||
// Vérifier que le track existe (simulation)
|
||||
if track_id.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Vérifier les permissions utilisateur si fourni
|
||||
if let Some(user_id) = user_id {
|
||||
// Ici on pourrait vérifier si l'utilisateur a le droit d'accéder à ce track
|
||||
// Par exemple : est-ce que l'utilisateur a acheté ce track ?
|
||||
// Ou est-ce que le track est public ?
|
||||
Ok(true) // Simplifié pour l'exemple
|
||||
} else {
|
||||
// Accès anonyme - vérifier si le track est public
|
||||
Ok(true) // Simplifié pour l'exemple
|
||||
let pool = match &self.db_pool {
|
||||
Some(pool) => pool,
|
||||
None => {
|
||||
tracing::warn!("No DB pool configured for track access validation — denying access by default");
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
let track_uuid = Uuid::parse_str(track_id).map_err(|_| AppError::SignatureError {
|
||||
message: format!("Invalid track ID format: {}", track_id),
|
||||
})?;
|
||||
|
||||
// Check if the track exists and whether it is public
|
||||
let track_row = sqlx::query_as::<_, (bool, Uuid)>(
|
||||
"SELECT is_public, user_id FROM tracks WHERE id = $1"
|
||||
)
|
||||
.bind(track_uuid)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| AppError::SignatureError {
|
||||
message: format!("Database error checking track: {}", e),
|
||||
})?;
|
||||
|
||||
let (is_public, owner_id) = match track_row {
|
||||
Some(row) => row,
|
||||
None => return Ok(false), // Track does not exist
|
||||
};
|
||||
|
||||
// Public tracks are accessible to everyone
|
||||
if is_public {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Private track — require a user
|
||||
let uid = match user_id {
|
||||
Some(uid) => uid,
|
||||
None => return Ok(false), // Anonymous access to private track denied
|
||||
};
|
||||
|
||||
// Owner always has access
|
||||
if uid == owner_id {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Check if the user purchased this track
|
||||
let purchased = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM orders WHERE user_id = $1 AND track_id = $2 AND status = 'completed')"
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(track_uuid)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| AppError::SignatureError {
|
||||
message: format!("Database error checking purchase: {}", e),
|
||||
})?;
|
||||
|
||||
Ok(purchased)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -299,18 +360,22 @@ pub struct TokenStats {
|
|||
impl Default for TokenValidator {
|
||||
fn default() -> Self {
|
||||
// SECURITY: Default impl ne doit être utilisé QUE pour les tests
|
||||
// En production, utilisez TokenValidator::new() avec require_env_min_length("SECRET_KEY", 32)
|
||||
// En production, utilisez TokenValidator::with_db_pool() avec require_env_min_length("SECRET_KEY", 32)
|
||||
#[cfg(not(any(test, debug_assertions)))]
|
||||
{
|
||||
panic!("Default TokenValidator should not be used in production");
|
||||
}
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
{
|
||||
Self::new(SignatureConfig {
|
||||
Self {
|
||||
config: SignatureConfig {
|
||||
secret_key: "test_secret_key_minimum_32_characters_long".to_string(),
|
||||
default_ttl: Duration::from_secs(3600), // 1 heure
|
||||
max_ttl: Duration::from_secs(86400), // 24 heures
|
||||
})
|
||||
},
|
||||
active_tokens: Arc::new(RwLock::new(HashMap::new())),
|
||||
db_pool: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue