feat: add security headers to nginx configurations and improve environment variable handling
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user