feat: Añadidos de seguridad (Backend, Frontend e IA)

Implementación de medidas de seguridad críticas tras auditoría:

Backend (API & IA):
- Anti-Prompt Injection: Reestructuración de prompts con delimitadores XML y sanitización estricta de inputs (Tag Injection).
- Anti-SSRF: Implementación de servicio `UrlSecurity` para validar URLs y bloquear accesos a IPs internas/privadas en funciones de scraping.
- Moderación: Activación de `SafetySettings` en Gemini API.
- Infraestructura:
  - Configuración de Headers de seguridad (HSTS, CSP, NoSniff).
  - CORS restrictivo (solo métodos HTTP necesarios).
  - Rate Limiting global y política estricta para Login (5 req/min).
  - Timeouts en HttpClient para prevenir DoS.
- Auth: Endpoint `setup-admin` restringido exclusivamente a entorno Debug.

Frontend (React):
- Anti-XSS & Tabnabbing: Configuración de esquema estricto en `rehype-sanitize` y forzado de `rel="noopener noreferrer"` en enlaces.
- Validación de longitud de input en cliente.

IA:
- Se realiza afinación de contexto de preguntas.
This commit is contained in:
2025-11-27 15:11:54 -03:00
parent 6f96ca9c79
commit 67e179441d
8 changed files with 539 additions and 443 deletions

View File

@@ -187,3 +187,19 @@ opacity: 0.8;
transform: scale(0.75);
}
}
/* Enlaces dentro de mensajes del USUARIO (Fondo Azul -> Enlace Blanco) */
.message.user a {
color: white !important;
}
/* Enlaces dentro de mensajes del BOT (Fondo Gris -> Enlace Azul El Día) */
.message.bot a {
color: #007bff !important;
font-weight: bold;
}
.message.bot a:hover {
text-decoration: none;
opacity: 0.8;
}

View File

@@ -1,8 +1,8 @@
// src/components/Chatbot.tsx
import React, { useState, useEffect, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import rehypeSanitize from 'rehype-sanitize';
import ReactMarkdown, { type Components } from 'react-markdown';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import './Chatbot.css';
interface Message {
@@ -11,7 +11,6 @@ interface Message {
}
const MAX_CHARS = 200;
// Constantes para la clave del localStorage
const CHAT_HISTORY_KEY = 'chatbot-history';
const CHAT_CONTEXT_KEY = 'chatbot-active-article';
const CHAT_SUMMARY_KEY = 'chatbot-summary';
@@ -20,16 +19,11 @@ const Chatbot: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>(() => {
try {
// 1. Intentamos obtener el historial guardado.
const savedHistory = localStorage.getItem(CHAT_HISTORY_KEY);
if (savedHistory) {
// 2. Si existe, lo parseamos y lo devolvemos para usarlo como estado inicial.
return JSON.parse(savedHistory);
}
if (savedHistory) return JSON.parse(savedHistory);
} catch (error) {
console.error("No se pudo cargar el historial del chat desde localStorage:", error);
console.error("Error cargando historial:", error);
}
// 3. Si no hay nada guardado o hay un error, devolvemos el estado por defecto.
return [{ text: '¡Hola! Soy tu asistente virtual. ¿En qué puedo ayudarte hoy?', sender: 'bot' }];
});
@@ -40,100 +34,55 @@ const Chatbot: React.FC = () => {
const inputRef = useRef<null | HTMLInputElement>(null);
const [activeArticle, setActiveArticle] = useState<{ url: string; title: string; } | null>(() => {
try {
// 1. Intentamos obtener el contexto del artículo guardado.
const savedContext = localStorage.getItem(CHAT_CONTEXT_KEY);
if (savedContext) {
// 2. Si existe, lo parseamos y lo usamos como estado inicial.
return JSON.parse(savedContext);
}
} catch (error) {
console.error("No se pudo cargar el contexto del artículo desde localStorage:", error);
}
// 3. Si no hay nada guardado o hay un error, el estado inicial es null.
if (savedContext) return JSON.parse(savedContext);
} catch { /* Ignorar error */ }
return null;
});
const [shownLinks, setShownLinks] = useState<string[]>([]);
const [conversationSummary, setConversationSummary] = useState<string>(() => {
try {
return localStorage.getItem(CHAT_SUMMARY_KEY) || "";
} catch (error) {
console.error("No se pudo cargar el resumen de la conversación:", error);
return "";
}
return localStorage.getItem(CHAT_SUMMARY_KEY) || "";
});
useEffect(() => {
try {
localStorage.setItem(CHAT_SUMMARY_KEY, conversationSummary);
} catch (error) {
console.error("No se pudo guardar el resumen de la conversación:", error);
}
localStorage.setItem(CHAT_SUMMARY_KEY, conversationSummary);
}, [conversationSummary]);
// Añadimos un useEffect para guardar los mensajes.
useEffect(() => {
try {
// Cada vez que el array de 'messages' cambie, lo guardamos en localStorage.
localStorage.setItem(CHAT_HISTORY_KEY, JSON.stringify(messages));
} catch (error) {
console.error("No se pudo guardar el historial del chat en localStorage:", error);
}
localStorage.setItem(CHAT_HISTORY_KEY, JSON.stringify(messages));
}, [messages]);
useEffect(() => {
try {
if (activeArticle) {
// Si hay un artículo activo, lo guardamos en localStorage.
localStorage.setItem(CHAT_CONTEXT_KEY, JSON.stringify(activeArticle));
} else {
// Si el artículo activo es null, lo eliminamos de localStorage para mantenerlo limpio.
localStorage.removeItem(CHAT_CONTEXT_KEY);
}
} catch (error) {
console.error("No se pudo guardar el contexto del artículo en localStorage:", error);
if (activeArticle) {
localStorage.setItem(CHAT_CONTEXT_KEY, JSON.stringify(activeArticle));
} else {
localStorage.removeItem(CHAT_CONTEXT_KEY);
}
}, [activeArticle]); // Wste efecto se ejecuta cada vez que 'activeArticle' cambia.
}, [activeArticle]);
useEffect(() => {
// Solo intentamos hacer scroll si la ventana del chat está abierta.
if (isOpen) {
// Usamos un pequeño retardo para asegurar que el navegador haya renderizado
// completamente la ventana antes de intentar hacer el scroll.
// Esto previene problemas si hay animaciones CSS.
setTimeout(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 50);
setTimeout(() => messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 50);
}
}, [messages, isOpen]);
// Este useEffect se encarga de gestionar el foco del campo de texto.
useEffect(() => {
// Solo aplicamos la lógica si la ventana del chat está abierta.
if (isOpen) {
// Si el bot NO está cargando, significa que el usuario puede escribir.
// Esto se cumple en dos escenarios:
// 1. Justo cuando se abre la ventana del chat.
// 2. Justo cuando el bot termina de responder (isLoading pasa de true a false).
if (!isLoading) {
// Usamos un pequeño retardo (100ms) para asegurar que el DOM se haya actualizado
// y cualquier animación de CSS haya terminado antes de intentar hacer foco.
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
if (isOpen && !isLoading) {
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [isOpen, isLoading]); // Las dependencias: se ejecuta si cambia `isOpen` o `isLoading`.
}, [isOpen, isLoading]);
const toggleChat = () => setIsOpen(!isOpen);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
};
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => setInputValue(event.target.value);
const handleSendMessage = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (inputValue.trim() === '' || isLoading) return;
// [SEGURIDAD] Validación básica de longitud en frontend
if (inputValue.length > MAX_CHARS) return;
const userMessage: Message = { text: inputValue, sender: 'user' };
setMessages(prev => [...prev, userMessage]);
const messageToSend = inputValue;
@@ -146,7 +95,8 @@ const Chatbot: React.FC = () => {
const requestBody = {
message: messageToSend,
contextUrl: activeArticle ? activeArticle.url : null,
conversationSummary: conversationSummary
conversationSummary: conversationSummary,
shownArticles: shownLinks
};
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/chat/stream-message`, {
@@ -155,9 +105,7 @@ const Chatbot: React.FC = () => {
body: JSON.stringify(requestBody),
});
if (!response.ok || !response.body) {
throw new Error('Error en la respuesta del servidor.');
}
if (!response.ok || !response.body) throw new Error('Error en la respuesta del servidor.');
const reader = response.body.getReader();
const decoder = new TextDecoder();
@@ -171,6 +119,7 @@ const Chatbot: React.FC = () => {
if (done) {
try {
// Limpieza final de JSON array de ASP.NET Core
const responseArray = JSON.parse(fullReplyRaw);
let intent = 'Homepage';
const messageChunks: string[] = [];
@@ -179,13 +128,9 @@ const Chatbot: React.FC = () => {
if (Array.isArray(responseArray)) {
responseArray.forEach((item: string) => {
if (typeof item === 'string') {
if (item.startsWith('INTENT::')) {
intent = item.split('::')[1];
} else if (item.startsWith('SUMMARY::')) {
finalSummary = item.split('::')[1];
} else {
messageChunks.push(item);
}
if (item.startsWith('INTENT::')) intent = item.split('::')[1];
else if (item.startsWith('SUMMARY::')) finalSummary = item.split('::')[1];
else messageChunks.push(item);
}
});
}
@@ -193,25 +138,39 @@ const Chatbot: React.FC = () => {
setConversationSummary(finalSummary);
const finalCleanText = messageChunks.join('');
// 1. Aseguramos que el último mensaje tenga el texto final y 100% limpio.
setMessages(prev => {
const updatedMessages = [...prev];
if (updatedMessages.length > 0) {
updatedMessages[updatedMessages.length - 1].text = finalCleanText;
}
if (updatedMessages.length > 0) updatedMessages[updatedMessages.length - 1].text = finalCleanText;
return updatedMessages;
});
// 1. Detectamos si hay un enlace NUEVO en la respuesta
const linkRegex = /\[(.*?)\]\((https?:\/\/[^\s]+)\)/;
const match = finalCleanText.match(linkRegex);
// 2. Heurística: Si la respuesta tiene muchas líneas (es una lista) o viñetas,
// asumimos que es un resumen general y NO una charla sobre un artículo específico.
// Detectamos saltos de línea o caracteres de lista (*, -, •)
const isListResponse = finalCleanText.split('\n').length > 4 || finalCleanText.includes(' * ') || finalCleanText.includes(' - ');
if (match && match[1] && match[2]) {
setActiveArticle({ title: match[1], url: match[2] });
} else if (intent === 'Database' || intent === 'Homepage') {
// CASO A: El bot nos dio un enlace nuevo -> Lo ponemos activo
const newUrl = match[2];
setActiveArticle({ title: match[1], url: newUrl });
// Actualizamos lista de vistos
setShownLinks(prev => {
if (!prev.includes(newUrl)) return [...prev, newUrl];
return prev;
});
} else if (intent !== 'Article' || isListResponse) {
// CASO B: No hay enlace nuevo Y (la intención cambió O es una lista larga)
// -> Borramos el enlace viejo para que no quede "pegado" descontextualizado
setActiveArticle(null);
}
} catch (e) {
console.error("Error al procesar la respuesta final del stream:", e, "Contenido crudo:", fullReplyRaw);
console.error("Error procesando respuesta final:", e);
setActiveArticle(null);
}
break;
@@ -220,21 +179,19 @@ const Chatbot: React.FC = () => {
const chunk = decoder.decode(value);
fullReplyRaw += chunk;
// --- LÓGICA DE VISUALIZACIÓN EN TIEMPO REAL ---
let cleanTextForDisplay = '';
try {
// Intentamos parsear el array JSON parcial que envía ASP.NET IAsyncEnumerable
const parsedArray = JSON.parse(fullReplyRaw.replace(/,$/, '') + ']');
// Filtramos CUALQUIER item que sea una pista interna.
const displayChunks = Array.isArray(parsedArray)
? parsedArray.filter((item: string) =>
typeof item === 'string' && !item.startsWith('INTENT::') && !item.startsWith('SUMMARY::')
)
: [];
cleanTextForDisplay = displayChunks.join('');
} catch (e) {
// El fallback también debe filtrar ambas pistas.
// Fallback regex
cleanTextForDisplay = fullReplyRaw
.replace(/\"INTENT::.*?\",?/g, '')
.replace(/\"SUMMARY::.*?\",?/g, '')
@@ -248,20 +205,17 @@ const Chatbot: React.FC = () => {
} else {
setMessages(prev => {
const updatedMessages = [...prev];
if (updatedMessages.length > 0) {
updatedMessages[updatedMessages.length - 1].text = cleanTextForDisplay;
}
if (updatedMessages.length > 0) updatedMessages[updatedMessages.length - 1].text = cleanTextForDisplay;
return updatedMessages;
});
}
}
};
await readStream();
} catch (error) {
console.error("Error al conectar con la API de streaming:", error);
const errorText = error instanceof Error ? error.message : 'Lo siento, no pude conectarme.';
console.error("Error API:", error);
const errorText = 'Lo siento, no pude conectarme en este momento.';
setMessages(prev => [...prev, { text: errorText, sender: 'bot' }]);
} finally {
setIsLoading(false);
@@ -269,6 +223,38 @@ const Chatbot: React.FC = () => {
}
};
// [SEGURIDAD] Configuración Estricta de Markdown
// 1. Personalizamos el renderizado de enlaces <a>
const MarkdownComponents: Components = {
a: ({ href, children }) => {
// Si es un enlace 'javascript:', no lo renderizamos o lo ponemos como #
const safeHref = href && !href.startsWith('javascript:') ? href : '#';
return (
<a
href={safeHref}
target="_blank"
rel="noopener noreferrer" // [CRÍTICO] Previene Tabnabbing
style={{ textDecoration: 'underline' }}
>
{children}
</a>
);
}
};
// [SEGURIDAD] Configuración de rehype-sanitize
const sanitizeSchema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
a: ['href', 'title', 'target', 'rel'], // Permitimos estos atributos
},
protocols: {
...defaultSchema.protocols,
href: ['http', 'https', 'mailto'], // Bloqueamos 'javascript', 'data', 'vbscript'
}
};
return (
<>
<div className="chat-bubble" onClick={toggleChat}>
@@ -284,7 +270,10 @@ const Chatbot: React.FC = () => {
<div className={`messages-container ${isLoading ? 'is-loading' : ''}`}>
{messages.map((msg, index) => (
<div key={index} className={`message ${msg.sender}`}>
<ReactMarkdown rehypePlugins={[rehypeSanitize]}>
<ReactMarkdown
components={MarkdownComponents} // Usamos componentes seguros
rehypePlugins={[[rehypeSanitize, sanitizeSchema]]} // Esquema de sanitización estricto
>
{msg.text.replace(/\\n/g, "\n")}
</ReactMarkdown>
</div>
@@ -292,9 +281,7 @@ const Chatbot: React.FC = () => {
{isLoading && !isStreaming && (
<div className="message bot">
<div className="typing-indicator">
<span></span>
<span></span>
<span></span>
<span></span><span></span><span></span>
</div>
</div>
)}
@@ -319,13 +306,9 @@ const Chatbot: React.FC = () => {
disabled={isLoading}
maxLength={MAX_CHARS}
/>
<div className="char-counter">
{inputValue.length} / {MAX_CHARS}
</div>
<div className="char-counter">{inputValue.length} / {MAX_CHARS}</div>
</div>
<button type="submit" disabled={isLoading}>
{isLoading ? '...' : '→'}
</button>
<button type="submit" disabled={isLoading}>{isLoading ? '...' : '→'}</button>
</form>
</div>
)}