veza/veza-stream-server/src/auth/token_validator.rs
senke 9cd0da0046 fix(v0.12.6): apply all pentest remediations — 36 findings across 36 files
CRITICAL fixes:
- Race condition (TOCTOU) in payout/refund with SELECT FOR UPDATE (CRITICAL-001/002)
- IDOR on analytics endpoint — ownership check enforced (CRITICAL-003)
- CSWSH on all WebSocket endpoints — origin whitelist (CRITICAL-004)
- Mass assignment on user self-update — strip privileged fields (CRITICAL-005)

HIGH fixes:
- Path traversal in marketplace upload — UUID filenames (HIGH-001)
- IP spoofing — use Gin trusted proxy c.ClientIP() (HIGH-002)
- Popularity metrics (followers, likes) set to json:"-" (HIGH-003)
- bcrypt cost hardened to 12 everywhere (HIGH-004)
- Refresh token lock made mandatory (HIGH-005)
- Stream token replay prevention with access_count (HIGH-006)
- Subscription trial race condition fixed (HIGH-007)
- License download expiration check (HIGH-008)
- Webhook amount validation (HIGH-009)
- pprof endpoint removed from production (HIGH-010)

MEDIUM fixes:
- WebSocket message size limit 64KB (MEDIUM-010)
- HSTS header in nginx production (MEDIUM-001)
- CORS origin restricted in nginx-rtmp (MEDIUM-002)
- Docker alpine pinned to 3.21 (MEDIUM-003/004)
- Redis authentication enforced (MEDIUM-005)
- GDPR account deletion expanded (MEDIUM-006)
- .gitignore hardened (MEDIUM-007)

LOW/INFO fixes:
- GitHub Actions SHA pinning on all workflows (LOW-001)
- .env.example security documentation (INFO-001)
- Production CORS set to HTTPS (LOW-002)

All tests pass. Go and Rust compile clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:44:46 +01:00

584 lines
18 KiB
Rust

//! Module de validation des signatures pour le streaming
//!
//! Ce module implémente la validation HMAC-SHA256 des URLs de streaming
//! pour sécuriser l'accès aux fichiers audio.
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};
use tokio::sync::RwLock;
use uuid::Uuid;
type HmacSha256 = Hmac<Sha256>;
/// Configuration de sécurité pour les signatures
#[derive(Debug, Clone)]
pub struct SignatureConfig {
pub secret_key: String,
pub default_ttl: Duration,
pub max_ttl: Duration,
}
/// Gestionnaire de validation des signatures
pub struct TokenValidator {
config: SignatureConfig,
active_tokens: Arc<RwLock<HashMap<String, TokenInfo>>>,
db_pool: Option<PgPool>,
}
/// Informations sur un token actif
#[derive(Debug, Clone)]
pub struct TokenInfo {
pub track_id: String,
pub user_id: Option<Uuid>,
pub created_at: SystemTime,
pub expires_at: SystemTime,
pub access_count: u32,
}
/// Paramètres de requête de streaming
#[derive(Debug, Deserialize)]
pub struct StreamRequest {
pub track_id: String,
pub expires: u64,
pub sig: String,
pub user_id: Option<Uuid>,
}
impl TokenValidator {
/// Crée un nouveau validateur de tokens
pub fn new(config: SignatureConfig) -> Self {
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),
}
}
/// Génère une signature HMAC-SHA256 pour une URL de streaming
pub fn generate_signature(
&self,
track_id: &str,
expires: u64,
user_id: Option<Uuid>,
) -> Result<String> {
let mut mac =
HmacSha256::new_from_slice(self.config.secret_key.as_bytes()).map_err(|e| {
AppError::SignatureError {
message: format!("HMAC creation failed: {}", e),
}
})?;
// Construire le message à signer
let message = format!(
"{}:{}:{}",
track_id,
expires,
user_id.map(|u| u.to_string()).unwrap_or_default()
);
mac.update(message.as_bytes());
let result = mac.finalize();
Ok(hex::encode(result.into_bytes()))
}
/// Valide une signature de streaming
pub fn validate_signature(
&self,
track_id: &str,
expires: u64,
signature: &str,
user_id: Option<Uuid>,
) -> Result<bool> {
// Vérifier l'expiration
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| AppError::SignatureError {
message: format!("Time error: {}", e),
})?
.as_secs();
if expires < now {
return Err(AppError::SignatureError {
message: "Token expired".to_string(),
});
}
// Générer la signature attendue
let expected_signature = self.generate_signature(track_id, expires, user_id)?;
// Comparaison constante du temps pour éviter les attaques par timing
use subtle::ConstantTimeEq;
let signature_bytes = match hex::decode(signature) {
Ok(bytes) => bytes,
Err(_) => return Ok(false),
};
let expected_bytes =
hex::decode(&expected_signature).map_err(|e| AppError::SignatureError {
message: format!("Invalid hex expected: {}", e),
})?;
Ok(signature_bytes.ct_eq(&expected_bytes).into())
}
/// 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<TokenInfo> {
// Valider la signature
if !self.validate_signature(
&request.track_id,
request.expires,
&request.sig,
request.user_id,
)? {
return Err(AppError::SignatureError {
message: "Invalid signature".to_string(),
});
}
// 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(),
user_id: request.user_id,
created_at: SystemTime::now(),
expires_at: SystemTime::UNIX_EPOCH + Duration::from_secs(request.expires),
access_count: 0,
};
// Enregistrer le token actif
active_tokens.insert(token_key, token_info.clone());
Ok(token_info)
}
/// 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); // 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(())
}
/// Nettoie les tokens expirés
pub async fn cleanup_expired_tokens(&self) -> Result<()> {
let now = SystemTime::now();
let mut active_tokens = self.active_tokens.write().await;
active_tokens.retain(|_, token_info| token_info.expires_at > now);
Ok(())
}
/// Génère une URL de streaming sécurisée
pub fn generate_secure_url(
&self,
track_id: &str,
ttl: Option<Duration>,
user_id: Option<Uuid>,
) -> Result<SecureStreamUrl> {
let ttl = ttl.unwrap_or(self.config.default_ttl);
// Limiter la TTL maximale
let ttl = if ttl > self.config.max_ttl {
self.config.max_ttl
} else {
ttl
};
let expires = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| AppError::SignatureError {
message: format!("Time error: {}", e),
})?
.as_secs()
+ ttl.as_secs();
let signature = self.generate_signature(track_id, expires, user_id)?;
Ok(SecureStreamUrl {
track_id: track_id.to_string(),
expires,
signature,
user_id,
})
}
/// Obtient les statistiques des tokens
pub async fn get_token_stats(&self) -> Result<TokenStats> {
let active_tokens = self.active_tokens.read().await;
let total_tokens = active_tokens.len();
let now = SystemTime::now();
let active_last_hour = active_tokens
.values()
.filter(|token| {
now.duration_since(token.created_at)
.map(|d| d.as_secs() < 3600)
.unwrap_or(false)
})
.count();
let total_accesses: u32 = active_tokens.values().map(|token| token.access_count).sum();
Ok(TokenStats {
total_tokens,
active_last_hour,
total_accesses,
})
}
/// 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> {
if track_id.is_empty() {
return Ok(false);
}
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)
}
}
/// URL de streaming sécurisée
#[derive(Debug, Serialize)]
pub struct SecureStreamUrl {
pub track_id: String,
pub expires: u64,
pub signature: String,
pub user_id: Option<Uuid>,
}
impl SecureStreamUrl {
/// Construit l'URL complète de streaming
pub fn build_url(&self, base_url: &str) -> String {
let mut url = format!("{}/stream/{}", base_url, self.track_id);
url.push_str(&format!("?expires={}&sig={}", self.expires, self.signature));
if let Some(user_id) = self.user_id {
url.push_str(&format!("&user_id={}", user_id));
}
url
}
}
/// Statistiques des tokens
#[derive(Debug, Serialize)]
pub struct TokenStats {
pub total_tokens: usize,
pub active_last_hour: usize,
pub total_accesses: u32,
}
impl Default for TokenValidator {
fn default() -> Self {
// SECURITY: Default impl ne doit être utilisé QUE pour les tests
// 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 {
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,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn test_signature_generation_and_validation() {
let validator = TokenValidator::new(SignatureConfig {
secret_key: "test_secret".to_string(),
default_ttl: Duration::from_secs(3600),
max_ttl: Duration::from_secs(86400),
});
let track_id = "test_track_123";
let expires = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 3600;
let user_id = Some(Uuid::new_v4());
// Générer la signature
let signature = validator
.generate_signature(track_id, expires, user_id)
.unwrap();
// Valider la signature
let is_valid = validator
.validate_signature(track_id, expires, &signature, user_id)
.unwrap();
assert!(is_valid);
// Test avec une signature invalide
let invalid_signature = "invalid_signature";
let is_invalid = validator
.validate_signature(track_id, expires, invalid_signature, user_id)
.unwrap();
assert!(!is_invalid);
}
#[test]
fn test_expired_token() {
let validator = TokenValidator::new(SignatureConfig {
secret_key: "test_secret".to_string(),
default_ttl: Duration::from_secs(3600),
max_ttl: Duration::from_secs(86400),
});
let track_id = "test_track_123";
let expired_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
- 3600; // Expiré il y a 1 heure
let signature = validator
.generate_signature(track_id, expired_time, None)
.unwrap();
let result = validator.validate_signature(track_id, expired_time, &signature, None);
assert!(result.is_err());
}
#[tokio::test]
async fn test_token_registration() {
let validator = TokenValidator::new(SignatureConfig {
secret_key: "test_secret".to_string(),
default_ttl: Duration::from_secs(3600),
max_ttl: Duration::from_secs(86400),
});
let expires = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 3600;
let request = StreamRequest {
track_id: "test_track_123".to_string(),
expires,
sig: validator
.generate_signature("test_track_123", expires, None)
.unwrap(),
user_id: None,
};
let token_info = validator
.validate_and_register_token(&request)
.await
.unwrap();
assert_eq!(token_info.track_id, "test_track_123");
assert_eq!(token_info.access_count, 0);
}
#[test]
fn test_generate_secure_url() {
let validator = TokenValidator::new(SignatureConfig {
secret_key: "test_secret".to_string(),
default_ttl: Duration::from_secs(3600),
max_ttl: Duration::from_secs(86400),
});
let url = validator
.generate_secure_url("track-456", None, Some(Uuid::new_v4()))
.unwrap();
assert_eq!(url.track_id, "track-456");
assert!(url.expires > 0);
assert!(!url.signature.is_empty());
assert!(url.user_id.is_some());
}
#[test]
fn test_secure_stream_url_build_url() {
let validator = TokenValidator::new(SignatureConfig {
secret_key: "test_secret".to_string(),
default_ttl: Duration::from_secs(3600),
max_ttl: Duration::from_secs(86400),
});
let url = validator
.generate_secure_url("track-789", None, None)
.unwrap();
let full_url = url.build_url("https://stream.example.com");
assert!(full_url.starts_with("https://stream.example.com/stream/track-789"));
assert!(full_url.contains("expires="));
assert!(full_url.contains("sig="));
}
#[test]
fn test_validate_signature_invalid_hex() {
let validator = TokenValidator::new(SignatureConfig {
secret_key: "test_secret".to_string(),
default_ttl: Duration::from_secs(3600),
max_ttl: Duration::from_secs(86400),
});
let expires = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 3600;
let result = validator.validate_signature(
"track",
expires,
"not-valid-hex!!",
None,
);
assert!(result.is_ok());
assert!(!result.unwrap());
}
#[tokio::test]
async fn test_get_token_stats() {
let validator = TokenValidator::new(SignatureConfig {
secret_key: "test_secret".to_string(),
default_ttl: Duration::from_secs(3600),
max_ttl: Duration::from_secs(86400),
});
let stats = validator.get_token_stats().await.unwrap();
assert_eq!(stats.total_tokens, 0);
assert_eq!(stats.active_last_hour, 0);
assert_eq!(stats.total_accesses, 0);
let expires = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 3600;
let request = StreamRequest {
track_id: "stats_test".to_string(),
expires,
sig: validator.generate_signature("stats_test", expires, None).unwrap(),
user_id: None,
};
validator.validate_and_register_token(&request).await.unwrap();
let stats2 = validator.get_token_stats().await.unwrap();
assert_eq!(stats2.total_tokens, 1);
}
}