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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user