feat: add security headers to nginx configurations and improve environment variable handling

This commit is contained in:
2026-04-28 14:12:28 -04:00
parent 49d24b5fb5
commit 2eb887c486
15 changed files with 43 additions and 85 deletions
+2
View File
@@ -96,6 +96,8 @@ DEFAULT_SECONDARY_COLOR="#8B5CF6"
# false = Production (certificados reales) # false = Production (certificados reales)
# ---------------------------------------- # ----------------------------------------
LETSENCRYPT_STAGING=true 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) # Frontend URLs (para producción con SSL)
+2 -2
View File
@@ -35,7 +35,7 @@ services:
- html:/usr/share/nginx/html - html:/usr/share/nginx/html
- ./nginx/certs-data:/etc/acme.sh:rw - ./nginx/certs-data:/etc/acme.sh:rw
environment: environment:
- DEFAULT_EMAIL=admin@norteamericano.com - DEFAULT_EMAIL=${ACME_EMAIL:?ACME_EMAIL env var must be set}
- NGINX_PROXY_CONTAINER=nginx-proxy - NGINX_PROXY_CONTAINER=nginx-proxy
- LETSENCRYPT_STAGING=${LETSENCRYPT_STAGING:-true} - LETSENCRYPT_STAGING=${LETSENCRYPT_STAGING:-true}
depends_on: depends_on:
@@ -52,7 +52,7 @@ services:
container_name: openccb-db container_name: openccb-db
environment: environment:
POSTGRES_USER: user POSTGRES_USER: user
POSTGRES_PASSWORD: ${DB_PASSWORD:-password} POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD env var must be set}
POSTGRES_DB: openccb POSTGRES_DB: openccb
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
+11
View File
@@ -1,6 +1,13 @@
# Custom nginx configuration for OpenCCB Learning # Custom nginx configuration for OpenCCB Learning
# Keep the learning frontend on port 3003 and expose LMS API via same-origin /lms-api. # 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/ { location /lms-api/ {
rewrite ^/lms-api/(.*)$ /$1 break; rewrite ^/lms-api/(.*)$ /$1 break;
proxy_pass http://openccb-experience:3002; proxy_pass http://openccb-experience:3002;
@@ -30,11 +37,15 @@ location /cms-api/ {
add_header Access-Control-Max-Age 86400 always; add_header Access-Control-Max-Age 86400 always;
add_header Content-Length 0 always; add_header Content-Length 0 always;
add_header Content-Type "text/plain; charset=utf-8" 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; return 204;
} }
add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Origin $cors_origin always;
add_header Vary "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; rewrite ^/cms-api/(.*)$ /$1 break;
proxy_pass http://openccb-studio:3001; proxy_pass http://openccb-studio:3001;
+11
View File
@@ -1,6 +1,13 @@
# Custom nginx configuration for OpenCCB Studio # Custom nginx configuration for OpenCCB Studio
# This overrides the default location block to route API requests correctly # 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). # Allow large ZIP uploads (RAG bulk import can exceed 2GB).
client_max_body_size 4096m; client_max_body_size 4096m;
client_body_timeout 43200s; client_body_timeout 43200s;
@@ -45,11 +52,15 @@ location /cms-api/ {
add_header Access-Control-Max-Age 86400 always; add_header Access-Control-Max-Age 86400 always;
add_header Content-Length 0 always; add_header Content-Length 0 always;
add_header Content-Type "text/plain; charset=utf-8" 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; return 204;
} }
add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Origin $cors_origin always;
add_header Vary "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; rewrite ^/cms-api/(.*)$ /$1 break;
proxy_pass http://openccb-studio:3001; proxy_pass http://openccb-studio:3001;
+4 -30
View File
@@ -129,8 +129,9 @@ async fn main() {
.allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool { .allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool {
let origin_str = origin.to_str().unwrap_or(""); let origin_str = origin.to_str().unwrap_or("");
// Orígenes de desarrollo // Allowlist explícita de orígenes permitidos
let allowed_origins = [ let allowed_origins = [
// Desarrollo local
"http://localhost:3000", "http://localhost:3000",
"http://localhost:3003", "http://localhost:3003",
"http://127.0.0.1:3000", "http://127.0.0.1:3000",
@@ -138,41 +139,14 @@ async fn main() {
"http://192.168.0.254:3000", "http://192.168.0.254:3000",
"http://192.168.0.254:3003", "http://192.168.0.254:3003",
"http://192.168.0.254", "http://192.168.0.254",
// Producción - Dominios de Norteamericano (.cl y .com) // Producción - solo HTTPS
"http://studio.norteamericano.com",
"https://studio.norteamericano.com", "https://studio.norteamericano.com",
"http://learning.norteamericano.com",
"https://learning.norteamericano.com", "https://learning.norteamericano.com",
"http://studio.norteamericano.cl",
"https://studio.norteamericano.cl", "https://studio.norteamericano.cl",
"http://learning.norteamericano.cl",
"https://learning.norteamericano.cl", "https://learning.norteamericano.cl",
]; ];
// Comprobar coincidencias exactas allowed_origins.contains(&origin_str)
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
})) }))
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH, Method::HEAD]) .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH, Method::HEAD])
.allow_headers([ .allow_headers([
+6 -3
View File
@@ -9,17 +9,20 @@ pub async fn set_session_context(
event_type: Option<String>, event_type: Option<String>,
) -> Result<(), sqlx::Error> { ) -> Result<(), sqlx::Error> {
if let Some(uid) = user_id { 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) .execute(&mut **tx)
.await?; .await?;
} }
if let Some(oid) = org_id { 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) .execute(&mut **tx)
.await?; .await?;
} }
if let Some(ip_addr) = ip { 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) .execute(&mut **tx)
.await?; .await?;
} }
+4 -22
View File
@@ -94,8 +94,9 @@ async fn main() {
.allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool { .allow_origin(AllowOrigin::predicate(|origin: &http::HeaderValue, _request: &http::request::Parts| -> bool {
let origin_str = origin.to_str().unwrap_or(""); let origin_str = origin.to_str().unwrap_or("");
// Orígenes de desarrollo // Allowlist explícita de orígenes permitidos
let allowed_origins = [ let allowed_origins = [
// Desarrollo local
"http://localhost:3000", "http://localhost:3000",
"http://localhost:3003", "http://localhost:3003",
"http://127.0.0.1:3000", "http://127.0.0.1:3000",
@@ -103,31 +104,12 @@ async fn main() {
"http://192.168.0.254:3000", "http://192.168.0.254:3000",
"http://192.168.0.254:3003", "http://192.168.0.254:3003",
"http://192.168.0.254", "http://192.168.0.254",
// Producción - Dominios de Norteamericano (HTTPS) // Producción - solo HTTPS
"https://studio.norteamericano.cl", "https://studio.norteamericano.cl",
"https://learning.norteamericano.cl", "https://learning.norteamericano.cl",
]; ];
// Comprobar coincidencias exactas allowed_origins.contains(&origin_str)
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
})) }))
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH]) .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS, Method::PATCH])
.allow_headers([ .allow_headers([
+2 -2
View File
@@ -32,7 +32,7 @@ pub fn create_jwt(
token_type: Some("access".to_string()), 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( encode(
&Header::default(), &Header::default(),
&claims, &claims,
@@ -59,7 +59,7 @@ pub fn create_preview_token(
token_type: Some("preview".to_string()), 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( encode(
&Header::default(), &Header::default(),
&claims, &claims,
+1 -2
View File
@@ -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").expect("JWT_SECRET env var must be set");
let secret = std::env::var("JWT_SECRET").unwrap_or_else(|_| "secret".to_string());
let claims = decode::<Claims>( let claims = decode::<Claims>(
&token, &token,
@@ -145,8 +145,6 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
} }
return [...prev, res]; return [...prev, res];
}); });
console.log(`Score for block ${blockId} submitted: ${score}`);
} catch (err) {
console.error(`Failed to submit score for block ${blockId}`, err); console.error(`Failed to submit score for block ${blockId}`, err);
} }
} }
@@ -89,7 +89,6 @@ export default function AudioResponsePlayer({
} }
try { try {
console.log('[AudioResponse] Requesting microphone access...');
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
audio: { audio: {
echoCancellation: true, echoCancellation: true,
@@ -97,7 +96,6 @@ export default function AudioResponsePlayer({
sampleRate: 44100 sampleRate: 44100
} }
}); });
console.log('[AudioResponse] Microphone access granted');
const mediaRecorder = new MediaRecorder(stream, { const mediaRecorder = new MediaRecorder(stream, {
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm' mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm'
@@ -108,15 +106,12 @@ export default function AudioResponsePlayer({
mediaRecorder.ondataavailable = (event) => { mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) { if (event.data.size > 0) {
audioChunksRef.current.push(event.data); audioChunksRef.current.push(event.data);
console.log('[AudioResponse] Data available, chunk size:', event.data.size);
} }
}; };
mediaRecorder.onstop = () => { mediaRecorder.onstop = () => {
console.log('[AudioResponse] Recording stopped, creating blob...');
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
setAudioBlob(audioBlob); setAudioBlob(audioBlob);
console.log('[AudioResponse] Blob created, size:', audioBlob.size, 'bytes');
stream.getTracks().forEach(track => track.stop()); stream.getTracks().forEach(track => track.stop());
}; };
@@ -128,14 +123,12 @@ export default function AudioResponsePlayer({
mediaRecorder.start(); mediaRecorder.start();
setIsRecording(true); setIsRecording(true);
setRecordingTime(0); setRecordingTime(0);
console.log('[AudioResponse] Recording started');
// Start speech recognition // Start speech recognition
if (!isGraded && recognitionRef.current) { if (!isGraded && recognitionRef.current) {
setTranscript(""); setTranscript("");
try { try {
recognitionRef.current.start(); recognitionRef.current.start();
console.log('[AudioResponse] Speech recognition started');
} catch (err) { } catch (err) {
console.warn('[AudioResponse] Could not start speech recognition:', err); console.warn('[AudioResponse] Could not start speech recognition:', err);
} }
@@ -146,7 +139,6 @@ export default function AudioResponsePlayer({
setRecordingTime(prev => { setRecordingTime(prev => {
const newTime = prev + 1; const newTime = prev + 1;
if (timeLimit && newTime >= timeLimit) { if (timeLimit && newTime >= timeLimit) {
console.log('[AudioResponse] Time limit reached, stopping...');
stopRecording(); stopRecording();
} }
return newTime; return newTime;
@@ -256,7 +256,6 @@ export default function MediaPlayer({ id, lessonId, title, url, media_type, conf
setFeedback({ isCorrect }); setFeedback({ isCorrect });
setA11yStatus(isCorrect ? "Respuesta correcta." : "Respuesta incorrecta."); setA11yStatus(isCorrect ? "Respuesta correcta." : "Respuesta incorrecta.");
// Save answer to backend (mocked for now) // Save answer to backend (mocked for now)
console.log(`Submitted answer for marker at ${activeMarker}: ${isCorrect ? 'Correct' : 'Wrong'}`);
setTimeout(() => { setTimeout(() => {
setFeedback(null); setFeedback(null);
@@ -133,10 +133,7 @@ export default function GlobalAiControl() {
startDate.setDate(startDate.getDate() - 90); 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'); const token = localStorage.getItem('studio_token');
console.log('Token from localStorage:', token ? 'EXISTS' : 'NULL');
if (!token) { if (!token) {
console.error('No token found. Please login again.'); console.error('No token found. Please login again.');
@@ -150,7 +147,6 @@ export default function GlobalAiControl() {
endDate.toISOString().split('T')[0] endDate.toISOString().split('T')[0]
); );
console.log('Data loaded:', jsonData);
setData(jsonData); setData(jsonData);
setAuthError(false); setAuthError(false);
} catch (error) { } catch (error) {
@@ -55,7 +55,6 @@ export default function AdminTokenTracking() {
const loadTokenUsage = async () => { const loadTokenUsage = async () => {
try { try {
const token = localStorage.getItem('studio_token'); const token = localStorage.getItem('studio_token');
console.log('[TokenUsage] Token from localStorage:', token ? 'Present (studio_token)' : 'Missing');
if (!token) { if (!token) {
console.error('[TokenUsage] No authentication token found!'); 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) { if (response.status === 401) {
console.error('[TokenUsage] Unauthorized - Token may be expired'); console.error('[TokenUsage] Unauthorized - Token may be expired');
alert('Session expired. Please login again.'); alert('Session expired. Please login again.');
@@ -28,26 +28,20 @@ export default function MemoryBlock({ id, title, pairs = [], editMode, onChange
left: "", left: "",
right: "" right: ""
}; };
console.log('[MemoryBlock] Adding new pair:', newPair);
onChange({ pairs: [...pairs, newPair] }); onChange({ pairs: [...pairs, newPair] });
}; };
const updatePair = (index: number, updates: Partial<MatchingPair>) => { const updatePair = (index: number, updates: Partial<MatchingPair>) => {
const newPairs = [...pairs]; const newPairs = [...pairs];
newPairs[index] = { ...newPairs[index], ...updates }; newPairs[index] = { ...newPairs[index], ...updates };
console.log('[MemoryBlock] Updating pair at index', index, ':', updates);
onChange({ pairs: newPairs }); onChange({ pairs: newPairs });
}; };
const removePair = (index: number) => { const removePair = (index: number) => {
console.log('[MemoryBlock] Removing pair at index', index);
const newPairs = pairs.filter((_, i) => i !== index); const newPairs = pairs.filter((_, i) => i !== index);
onChange({ pairs: newPairs }); onChange({ pairs: newPairs });
}; };
// Debug: Log pairs on render
console.log('[MemoryBlock] Render with pairs:', pairs);
if (!editMode) { if (!editMode) {
return ( return (
<div className="space-y-8" id={id}> <div className="space-y-8" id={id}>