From 2eb887c4864a9003bfb3341f13d004e77c5a356a Mon Sep 17 00:00:00 2001 From: Nurfog Date: Tue, 28 Apr 2026 14:12:28 -0400 Subject: [PATCH] feat: add security headers to nginx configurations and improve environment variable handling --- .env.example | 2 ++ docker-compose.yml | 4 +-- nginx/learning.conf | 11 ++++++ nginx/studio.conf | 11 ++++++ services/cms-service/src/main.rs | 34 +++---------------- services/lms-service/src/db_util.rs | 9 +++-- services/lms-service/src/main.rs | 26 +++----------- shared/common/src/auth.rs | 4 +-- shared/common/src/middleware.rs | 3 +- .../courses/[id]/lessons/[lessonId]/page.tsx | 2 -- .../components/blocks/AudioResponsePlayer.tsx | 8 ----- .../src/components/blocks/MediaPlayer.tsx | 1 - .../src/app/admin/ai-usage-global/page.tsx | 4 --- web/studio/src/app/admin/token-usage/page.tsx | 3 -- .../src/components/blocks/MemoryBlock.tsx | 6 ---- 15 files changed, 43 insertions(+), 85 deletions(-) diff --git a/.env.example b/.env.example index e84dd32..0fc7c90 100644 --- a/.env.example +++ b/.env.example @@ -96,6 +96,8 @@ DEFAULT_SECONDARY_COLOR="#8B5CF6" # false = Production (certificados reales) # ---------------------------------------- LETSENCRYPT_STAGING=true +# Email para notificaciones de Let's Encrypt (renovación, errores) +ACME_EMAIL=admin@example.com # ---------------------------------------- # Frontend URLs (para producción con SSL) diff --git a/docker-compose.yml b/docker-compose.yml index 6c1e943..b1f793f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,7 +35,7 @@ services: - html:/usr/share/nginx/html - ./nginx/certs-data:/etc/acme.sh:rw environment: - - DEFAULT_EMAIL=admin@norteamericano.com + - DEFAULT_EMAIL=${ACME_EMAIL:?ACME_EMAIL env var must be set} - NGINX_PROXY_CONTAINER=nginx-proxy - LETSENCRYPT_STAGING=${LETSENCRYPT_STAGING:-true} depends_on: @@ -52,7 +52,7 @@ services: container_name: openccb-db environment: POSTGRES_USER: user - POSTGRES_PASSWORD: ${DB_PASSWORD:-password} + POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD env var must be set} POSTGRES_DB: openccb volumes: - postgres_data:/var/lib/postgresql/data diff --git a/nginx/learning.conf b/nginx/learning.conf index 3ae6cbc..6755b3f 100644 --- a/nginx/learning.conf +++ b/nginx/learning.conf @@ -1,6 +1,13 @@ # Custom nginx configuration for OpenCCB Learning # Keep the learning frontend on port 3003 and expose LMS API via same-origin /lms-api. +# Security headers (server level) +add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; +add_header X-Content-Type-Options "nosniff" always; +add_header X-Frame-Options "SAMEORIGIN" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + location /lms-api/ { rewrite ^/lms-api/(.*)$ /$1 break; proxy_pass http://openccb-experience:3002; @@ -30,11 +37,15 @@ location /cms-api/ { add_header Access-Control-Max-Age 86400 always; add_header Content-Length 0 always; add_header Content-Type "text/plain; charset=utf-8" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; return 204; } add_header Access-Control-Allow-Origin $cors_origin always; add_header Vary "Origin" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; rewrite ^/cms-api/(.*)$ /$1 break; proxy_pass http://openccb-studio:3001; diff --git a/nginx/studio.conf b/nginx/studio.conf index 4d386ba..efcfdfe 100644 --- a/nginx/studio.conf +++ b/nginx/studio.conf @@ -1,6 +1,13 @@ # Custom nginx configuration for OpenCCB Studio # This overrides the default location block to route API requests correctly +# Security headers (server level - heredados por location blocks sin add_header propios) +add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; +add_header X-Content-Type-Options "nosniff" always; +add_header X-Frame-Options "SAMEORIGIN" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + # Allow large ZIP uploads (RAG bulk import can exceed 2GB). client_max_body_size 4096m; client_body_timeout 43200s; @@ -45,11 +52,15 @@ location /cms-api/ { add_header Access-Control-Max-Age 86400 always; add_header Content-Length 0 always; add_header Content-Type "text/plain; charset=utf-8" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; return 204; } add_header Access-Control-Allow-Origin $cors_origin always; add_header Vary "Origin" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; rewrite ^/cms-api/(.*)$ /$1 break; proxy_pass http://openccb-studio:3001; diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 997b61c..29eb6f4 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -129,8 +129,9 @@ async fn main() { .allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool { let origin_str = origin.to_str().unwrap_or(""); - // Orígenes de desarrollo + // Allowlist explícita de orígenes permitidos let allowed_origins = [ + // Desarrollo local "http://localhost:3000", "http://localhost:3003", "http://127.0.0.1:3000", @@ -138,41 +139,14 @@ async fn main() { "http://192.168.0.254:3000", "http://192.168.0.254:3003", "http://192.168.0.254", - // Producción - Dominios de Norteamericano (.cl y .com) - "http://studio.norteamericano.com", + // Producción - solo HTTPS "https://studio.norteamericano.com", - "http://learning.norteamericano.com", "https://learning.norteamericano.com", - "http://studio.norteamericano.cl", "https://studio.norteamericano.cl", - "http://learning.norteamericano.cl", "https://learning.norteamericano.cl", ]; - // Comprobar coincidencias exactas - if allowed_origins.contains(&origin_str) { - return true; - } - - // Comprobar comodín para subdominios en norteamericano.cl/.com sobre HTTP(S) - for scheme in ["http://", "https://"] { - for domain in [".norteamericano.cl", ".norteamericano.com"] { - if origin_str.starts_with(scheme) && origin_str.ends_with(domain) { - let subdomain = origin_str - .strip_prefix(scheme) - .unwrap_or("") - .strip_suffix(domain) - .unwrap_or(""); - - // Permitir cualquier subdominio (ej., api., cdn., admin., etc.) - if !subdomain.is_empty() && !subdomain.contains('/') { - return true; - } - } - } - } - - false + allowed_origins.contains(&origin_str) })) .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH, Method::HEAD]) .allow_headers([ diff --git a/services/lms-service/src/db_util.rs b/services/lms-service/src/db_util.rs index de7650b..dfd0480 100644 --- a/services/lms-service/src/db_util.rs +++ b/services/lms-service/src/db_util.rs @@ -9,17 +9,20 @@ pub async fn set_session_context( event_type: Option, ) -> Result<(), sqlx::Error> { if let Some(uid) = user_id { - sqlx::query(&format!("SET LOCAL app.current_user_id = '{}'", uid)) + sqlx::query("SELECT set_config('app.current_user_id', $1, true)") + .bind(uid.to_string()) .execute(&mut **tx) .await?; } if let Some(oid) = org_id { - sqlx::query(&format!("SET LOCAL app.current_org_id = '{}'", oid)) + sqlx::query("SELECT set_config('app.current_org_id', $1, true)") + .bind(oid.to_string()) .execute(&mut **tx) .await?; } if let Some(ip_addr) = ip { - sqlx::query(&format!("SET LOCAL app.client_ip = '{}'", ip_addr)) + sqlx::query("SELECT set_config('app.client_ip', $1, true)") + .bind(ip_addr) .execute(&mut **tx) .await?; } diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 74183f5..7888617 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -94,8 +94,9 @@ async fn main() { .allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool { let origin_str = origin.to_str().unwrap_or(""); - // Orígenes de desarrollo + // Allowlist explícita de orígenes permitidos let allowed_origins = [ + // Desarrollo local "http://localhost:3000", "http://localhost:3003", "http://127.0.0.1:3000", @@ -103,31 +104,12 @@ async fn main() { "http://192.168.0.254:3000", "http://192.168.0.254:3003", "http://192.168.0.254", - // Producción - Dominios de Norteamericano (HTTPS) + // Producción - solo HTTPS "https://studio.norteamericano.cl", "https://learning.norteamericano.cl", ]; - // Comprobar coincidencias exactas - if allowed_origins.contains(&origin_str) { - return true; - } - - // Comprobar comodín para subdominios: https://*.norteamericano.cl - if origin_str.starts_with("https://") && origin_str.ends_with(".norteamericano.cl") { - let subdomain = origin_str - .strip_prefix("https://") - .unwrap_or("") - .strip_suffix(".norteamericano.cl") - .unwrap_or(""); - - // Permitir cualquier subdominio (p. ej., api., cdn., admin., etc.) - if !subdomain.is_empty() && !subdomain.contains('/') { - return true; - } - } - - false + allowed_origins.contains(&origin_str) })) .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH]) .allow_headers([ diff --git a/shared/common/src/auth.rs b/shared/common/src/auth.rs index e4e33a6..72a7ec2 100644 --- a/shared/common/src/auth.rs +++ b/shared/common/src/auth.rs @@ -32,7 +32,7 @@ pub fn create_jwt( token_type: Some("access".to_string()), }; - let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string()); + let secret = std::env::var("JWT_SECRET").expect("JWT_SECRET env var must be set"); encode( &Header::default(), &claims, @@ -59,7 +59,7 @@ pub fn create_preview_token( token_type: Some("preview".to_string()), }; - let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string()); + let secret = std::env::var("JWT_SECRET").expect("JWT_SECRET env var must be set"); encode( &Header::default(), &claims, diff --git a/shared/common/src/middleware.rs b/shared/common/src/middleware.rs index 08abdd6..1b5e9e3 100644 --- a/shared/common/src/middleware.rs +++ b/shared/common/src/middleware.rs @@ -42,8 +42,7 @@ pub async fn org_extractor_middleware( } }; - // NOTA: El secreto debe venir de una variable de entorno en producción. - let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string()); + let secret = std::env::var("JWT_SECRET").expect("JWT_SECRET env var must be set"); let claims = decode::( &token, diff --git a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx index a618c6d..3809c5a 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -145,8 +145,6 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les } return [...prev, res]; }); - console.log(`Score for block ${blockId} submitted: ${score}`); - } catch (err) { console.error(`Failed to submit score for block ${blockId}`, err); } } diff --git a/web/experience/src/components/blocks/AudioResponsePlayer.tsx b/web/experience/src/components/blocks/AudioResponsePlayer.tsx index d949d4c..ed5de93 100644 --- a/web/experience/src/components/blocks/AudioResponsePlayer.tsx +++ b/web/experience/src/components/blocks/AudioResponsePlayer.tsx @@ -89,7 +89,6 @@ export default function AudioResponsePlayer({ } try { - console.log('[AudioResponse] Requesting microphone access...'); const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, @@ -97,7 +96,6 @@ export default function AudioResponsePlayer({ sampleRate: 44100 } }); - console.log('[AudioResponse] Microphone access granted'); const mediaRecorder = new MediaRecorder(stream, { mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm' @@ -108,15 +106,12 @@ export default function AudioResponsePlayer({ mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { audioChunksRef.current.push(event.data); - console.log('[AudioResponse] Data available, chunk size:', event.data.size); } }; mediaRecorder.onstop = () => { - console.log('[AudioResponse] Recording stopped, creating blob...'); const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); setAudioBlob(audioBlob); - console.log('[AudioResponse] Blob created, size:', audioBlob.size, 'bytes'); stream.getTracks().forEach(track => track.stop()); }; @@ -128,14 +123,12 @@ export default function AudioResponsePlayer({ mediaRecorder.start(); setIsRecording(true); setRecordingTime(0); - console.log('[AudioResponse] Recording started'); // Start speech recognition if (!isGraded && recognitionRef.current) { setTranscript(""); try { recognitionRef.current.start(); - console.log('[AudioResponse] Speech recognition started'); } catch (err) { console.warn('[AudioResponse] Could not start speech recognition:', err); } @@ -146,7 +139,6 @@ export default function AudioResponsePlayer({ setRecordingTime(prev => { const newTime = prev + 1; if (timeLimit && newTime >= timeLimit) { - console.log('[AudioResponse] Time limit reached, stopping...'); stopRecording(); } return newTime; diff --git a/web/experience/src/components/blocks/MediaPlayer.tsx b/web/experience/src/components/blocks/MediaPlayer.tsx index 52ebc1b..c209595 100644 --- a/web/experience/src/components/blocks/MediaPlayer.tsx +++ b/web/experience/src/components/blocks/MediaPlayer.tsx @@ -256,7 +256,6 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf setFeedback({ isCorrect }); setA11yStatus(isCorrect ? "Respuesta correcta." : "Respuesta incorrecta."); // Save answer to backend (mocked for now) - console.log(`Submitted answer for marker at ${activeMarker}: ${isCorrect ? 'Correct' : 'Wrong'}`); setTimeout(() => { setFeedback(null); diff --git a/web/studio/src/app/admin/ai-usage-global/page.tsx b/web/studio/src/app/admin/ai-usage-global/page.tsx index c1940d7..9a9648a 100644 --- a/web/studio/src/app/admin/ai-usage-global/page.tsx +++ b/web/studio/src/app/admin/ai-usage-global/page.tsx @@ -133,10 +133,7 @@ export default function GlobalAiControl() { startDate.setDate(startDate.getDate() - 90); } - console.log('Loading AI usage from', startDate.toISOString().split('T')[0], 'to', endDate.toISOString().split('T')[0]); - const token = localStorage.getItem('studio_token'); - console.log('Token from localStorage:', token ? 'EXISTS' : 'NULL'); if (!token) { console.error('No token found. Please login again.'); @@ -150,7 +147,6 @@ export default function GlobalAiControl() { endDate.toISOString().split('T')[0] ); - console.log('Data loaded:', jsonData); setData(jsonData); setAuthError(false); } catch (error) { diff --git a/web/studio/src/app/admin/token-usage/page.tsx b/web/studio/src/app/admin/token-usage/page.tsx index dd3f0d3..b393d51 100644 --- a/web/studio/src/app/admin/token-usage/page.tsx +++ b/web/studio/src/app/admin/token-usage/page.tsx @@ -55,7 +55,6 @@ export default function AdminTokenTracking() { const loadTokenUsage = async () => { try { const token = localStorage.getItem('studio_token'); - console.log('[TokenUsage] Token from localStorage:', token ? 'Present (studio_token)' : 'Missing'); if (!token) { console.error('[TokenUsage] No authentication token found!'); @@ -71,8 +70,6 @@ export default function AdminTokenTracking() { }, }); - console.log('[TokenUsage] API Response status:', response.status); - if (response.status === 401) { console.error('[TokenUsage] Unauthorized - Token may be expired'); alert('Session expired. Please login again.'); diff --git a/web/studio/src/components/blocks/MemoryBlock.tsx b/web/studio/src/components/blocks/MemoryBlock.tsx index 6f9de22..34872a7 100644 --- a/web/studio/src/components/blocks/MemoryBlock.tsx +++ b/web/studio/src/components/blocks/MemoryBlock.tsx @@ -28,26 +28,20 @@ export default function MemoryBlock({ id, title, pairs = [], editMode, onChange left: "", right: "" }; - console.log('[MemoryBlock] Adding new pair:', newPair); onChange({ pairs: [...pairs, newPair] }); }; const updatePair = (index: number, updates: Partial) => { const newPairs = [...pairs]; newPairs[index] = { ...newPairs[index], ...updates }; - console.log('[MemoryBlock] Updating pair at index', index, ':', updates); onChange({ pairs: newPairs }); }; const removePair = (index: number) => { - console.log('[MemoryBlock] Removing pair at index', index); const newPairs = pairs.filter((_, i) => i !== index); onChange({ pairs: newPairs }); }; - // Debug: Log pairs on render - console.log('[MemoryBlock] Render with pairs:', pairs); - if (!editMode) { return (