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)
# ----------------------------------------
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)
+2 -2
View File
@@ -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
+11
View File
@@ -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;
+11
View File
@@ -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;
+4 -30
View File
@@ -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([
+6 -3
View File
@@ -9,17 +9,20 @@ pub async fn set_session_context(
event_type: Option<String>,
) -> 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?;
}
+4 -22
View File
@@ -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([
+2 -2
View File
@@ -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,
+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").unwrap_or_else(|_| "secret".to_string());
let secret = std::env::var("JWT_SECRET").expect("JWT_SECRET env var must be set");
let claims = decode::<Claims>(
&token,
@@ -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);
}
}
@@ -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;
@@ -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);
@@ -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) {
@@ -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.');
@@ -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<MatchingPair>) => {
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 (
<div className="space-y-8" id={id}>