From f6e44242dd7e656784fa8aa8d9257947111b68b8 Mon Sep 17 00:00:00 2001 From: SPotes22 Date: Thu, 25 Sep 2025 16:45:47 -0500 Subject: [PATCH 1/4] =?UTF-8?q?fix(security):=20mitigaci=C3=B3n=20parcial?= =?UTF-8?q?=20de=20vulnerabilidad=20siguiendo=20gu=C3=ADas=20OWASP=20?= =?UTF-8?q?=F0=9F=94=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 424 +++++++++++++++++++++++ env.example | 48 ++- requirements.txt | 73 ++-- secrets/Feature_backlog.json | 54 +++ secrets/Monitores_backlog.json | 41 +++ secrets/appV1-1.py | 481 ++++++++++++++++++++++++++ secrets/app_v1.py | 353 +++++++++++++++++++ secrets/arq_base_backlog_1.json | 42 +++ secrets/arq_base_backlog_1_copia.json | 42 +++ secrets/arq_base_backlog_2.json | 74 ++++ secrets/refactor_backlog.json | 41 +++ secrets/requirements_all.txt | 66 ++++ src/__init__.py | 1 + src/database/schema.sql | 28 ++ src/handelers/__init__.py | 4 + src/handelers/socket_handelers.py | 162 +++++++++ src/middleware/__init__.py | 4 + src/middleware/socket_auth.py | 50 +++ src/routes/__init__.py | 6 + src/routes/auth_routes.py | 117 +++++++ src/routes/auth_routes_v1.py | 102 ++++++ src/routes/chat_routes.py | 50 +++ src/routes/file_routes.py | 144 ++++++++ src/services/logger_service.py | 166 +++++++++ src/services/logger_services_v1.py | 91 +++++ src/utils/__init__.py | 25 ++ src/utils/csv_backup.py | 31 ++ src/utils/input_sanitizer.py | 64 ++++ src/utils/security.py | 47 +++ 29 files changed, 2772 insertions(+), 59 deletions(-) create mode 100644 app.py create mode 100644 secrets/Feature_backlog.json create mode 100644 secrets/Monitores_backlog.json create mode 100644 secrets/appV1-1.py create mode 100644 secrets/app_v1.py create mode 100644 secrets/arq_base_backlog_1.json create mode 100644 secrets/arq_base_backlog_1_copia.json create mode 100644 secrets/arq_base_backlog_2.json create mode 100644 secrets/refactor_backlog.json create mode 100644 secrets/requirements_all.txt create mode 100644 src/__init__.py create mode 100644 src/database/schema.sql create mode 100644 src/handelers/__init__.py create mode 100644 src/handelers/socket_handelers.py create mode 100644 src/middleware/__init__.py create mode 100644 src/middleware/socket_auth.py create mode 100644 src/routes/__init__.py create mode 100644 src/routes/auth_routes.py create mode 100644 src/routes/auth_routes_v1.py create mode 100644 src/routes/chat_routes.py create mode 100644 src/routes/file_routes.py create mode 100644 src/services/logger_service.py create mode 100644 src/services/logger_services_v1.py create mode 100644 src/utils/__init__.py create mode 100644 src/utils/csv_backup.py create mode 100644 src/utils/input_sanitizer.py create mode 100644 src/utils/security.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..c80dd49 --- /dev/null +++ b/app.py @@ -0,0 +1,424 @@ +''' +PiChat - Chat Corporativo - VERSIÓN HÍBRIDA FUNCIONAL +Copyright (C) 2025 Santiago Potes Giraldo +''' +import os +import json +from datetime import datetime +from argon2 import PasswordHasher +from src.services.logger_service import AdvancedLogger +from flask import ( + Flask, request, jsonify, redirect, url_for, + send_from_directory, render_template +) +from flask_socketio import SocketIO, join_room, leave_room, send +from flask_login import ( + LoginManager, UserMixin, login_user, logout_user, + login_required, current_user +) +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_cors import CORS +from werkzeug.utils import secure_filename + +# ✅ IMPORTAR MÓDULOS QUE SÍ FUNCIONAN +from src.utils.security import ( + check_brute_force_protection, + increment_failed_attempt, + reset_failed_attempts, + setup_brute_force_protection +) +from src.utils.input_sanitizer import ( + sanitize_input, sanitize_filename, + sanitize_message, sanitize_room_code +) + +# --- CONFIGURACIÓN INICIAL --- +UPLOAD_FOLDER = './cuarentena' +app = Flask(__name__) + +# ✅ LIMITER INICIALIZADO PRIMERO (IMPORTANTE!) +limiter = Limiter( + key_func=get_remote_address, + app=app, + default_limits=["200 per day", "50 per hour"], + storage_uri="memory://", + strategy="moving-window" +) + +# ✅ CORS CONFIGURADO SEGURO +CORS(app, origins=[ + "http://localhost:3000", + "https://tudominio.com", + os.getenv("ALLOWED_ORIGINS", "http://localhost:8080") +], supports_credentials=True) + +socketio = SocketIO(app, + cors_allowed_origins="*", + async_mode='threading', + logger=True, + engineio_logger=False +) + +app.secret_key = os.environ.get("SECRET_KEY", "a-very-secret-key-for-dev") +ph = PasswordHasher() + +# ✅ LOGGER MEJORADO +logger = AdvancedLogger( + logs_dir='./logs', + max_file_size_mb=10, + buffer_size=100 +) + +SERVERFILE = 'server_hist.csv' + +# --- CONFIGURACIÓN SEGURIDAD --- +app.config.update( + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SECURE=True, + SESSION_COOKIE_SAMESITE='Lax', + MAX_CONTENT_LENGTH=16 * 1024 * 1024, + UPLOAD_FOLDER=UPLOAD_FOLDER +) + +print("Configuración de seguridad inicial completada ...") + +# --- CARPETA UPLOADS --- +os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + +# --- USUARIOS BASE --- +users = { + os.getenv("ADMIN_USER", "admin"): { + "password": ph.hash(os.getenv("ADMIN_PASS", "admin123")), + "role": "administrator", + "failed_attempts": 0, + "last_attempt": None + }, + os.getenv("CLIENT_USER", "cliente"): { + "password": ph.hash(os.getenv("CLIENT_PASS", "cliente123")), + "role": "cliente", + "failed_attempts": 0, + "last_attempt": None + }, + os.getenv("USR_USER", "usuario"): { + "password": ph.hash(os.getenv("USR_PASS", "usuario123")), + "role": "usuario", + "failed_attempts": 0, + "last_attempt": None + } +} + +# ✅ CONFIGURAR PROTECCIÓN FUERZA BRUTA (MÓDULO FUNCIONAL) +setup_brute_force_protection(users) + +print("Sistema de autenticación hardening inicializado...") + +# --- LOGIN MANAGER --- +login_manager = LoginManager(app) +login_manager.login_view = 'login' +login_manager.session_protection = "strong" + +class Usuario(UserMixin): + def __init__(self, username, role): + self.id = username + self.rol = role + +@login_manager.user_loader +def load_user(user_id): + if user_id in users: + return Usuario(user_id, users[user_id]['role']) + return None + +# --- RUTAS MEJORADAS CON MÓDULOS --- +@app.route('/') +def home(): + if current_user.is_authenticated: + return redirect(url_for('inicio')) + return redirect(url_for('login')) + +@app.route('/login', methods=['GET', 'POST']) +@limiter.limit("10 per minute", deduct_when=lambda response: response.status_code != 200) +def login(): + """✅ LOGIN MEJORADO CON MÓDULO DE SEGURIDAD""" + if current_user.is_authenticated: + logger.log_archivo( + usuario=current_user.id, + accion='LOGIN_REDIRECT_ALREADY_AUTH', + nombre_archivo=SERVERFILE, + tamano=0 + ) + return redirect(url_for('inicio')) + + if request.method == 'POST': + user = request.form['usuario'] + password = request.form['clave'] + + # ✅ PROTECCIÓN FUERZA BRUTA (MÓDULO) + if check_brute_force_protection(user, users): + logger.log_archivo( + usuario=user, + accion='LOGIN_BLOCKED_BRUTE_FORCE', + nombre_archivo=SERVERFILE, + tamano=-1 + ) + return render_template("login.html", + error="Demasiados intentos fallidos. Espere 15 minutos.") + + if user in users: + try: + ph.verify(users[user]['password'], password) + # ✅ RESETEO DE INTENTOS (MÓDULO) + reset_failed_attempts(user, users) + + login_user(Usuario(user, users[user]['role'])) + + logger.log_archivo( + usuario=user, + accion='LOGIN_EXITOSO', + nombre_archivo=SERVERFILE, + tamano=0 + ) + return redirect(url_for('inicio')) + except Exception as e: + # ✅ INCREMENTO DE INTENTOS (MÓDULO) + increment_failed_attempt(user, users) + + logger.log_archivo( + usuario=user, + accion=f'LOGIN_FALLIDO_ATTEMPT_{users[user]["failed_attempts"]}', + nombre_archivo=SERVERFILE, + tamano=-1 + ) + else: + logger.log_archivo( + usuario=user, + accion='LOGIN_USUARIO_NO_EXISTE', + nombre_archivo=SERVERFILE, + tamano=-1 + ) + + return render_template("login.html", error="Credenciales inválidas.") + return render_template("login.html") + +@app.route('/logout', methods=['GET','POST']) +@login_required +def logout(): + logger.log_archivo( + usuario=current_user.id, + accion='USER LOG OUT - EXITED SESSION - SUCCESS', + nombre_archivo='user_hist.csv', + tamano=0 + ) + logout_user() + return redirect(url_for('login')) + +@app.route('/inicio') +@login_required +def inicio(): + logger.log_archivo( + usuario=current_user.id, + accion='USER ACCESS INICIO - SERVER MSG - SUCCESS', + nombre_archivo='user_hist.csv', + tamano=0 + ) + return render_template('inicio.html', current_user=current_user) + +# --- FUNCIONALIDAD DE ARCHIVOS CON MÓDULOS --- +@app.route('/subir', methods=['GET', 'POST']) +@login_required +@limiter.limit("5 per minute") +def subir(): + """✅ SUBIR ARCHIVOS CON SANITIZACIÓN MODULAR""" + if current_user.rol == 'usuario': + return 'No tienes permiso para subir archivos', 403 + + if request.method == 'POST': + if 'archivo' not in request.files: + return 'No se encontró el archivo', 400 + + archivo = request.files['archivo'] + if archivo.filename == '': + return 'No se seleccionó ningún archivo', 400 + + # ✅ SANITIZACIÓN MODULAR + filename = sanitize_filename(archivo.filename) + safe_filename = secure_filename(filename) + + archivo.save(os.path.join(app.config['UPLOAD_FOLDER'], safe_filename)) + + logger.log_archivo( + usuario=current_user.id, + accion='subir', + nombre_archivo=safe_filename, + tamano=archivo.content_length + ) + return redirect(url_for('listar')) + + return render_template("subir.html") + +@app.route('/listar') +@login_required +def listar(): + archivos = os.listdir(UPLOAD_FOLDER) + + logger.log_archivo( + usuario=current_user.id, + accion='USER LISTS FILES FROM SERVER - SUCCESS', + nombre_archivo='file_list', + tamano=len(archivos) + ) + return render_template("listar.html", archivos=archivos) + +@app.route('/descargar/') +@login_required +@limiter.limit("10 per minute") +def descargar(nombre): + # ✅ SANITIZACIÓN MODULAR + safe_filename = sanitize_filename(nombre) + + file_path = os.path.join(UPLOAD_FOLDER, safe_filename) + file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0 + + logger.log_archivo( + usuario=current_user.id, + accion='USER DOWNLOADS FILE - SUCCESS', + nombre_archivo=safe_filename, + tamano=file_size + ) + return send_from_directory(UPLOAD_FOLDER, safe_filename, as_attachment=True) + +@app.route('/eliminar/') +@login_required +@limiter.limit("3 per minute") +def eliminar(nombre): + if current_user.rol != 'administrator': + return 'No tienes permiso para eliminar archivos', 403 + + try: + # ✅ SANITIZACIÓN MODULAR + safe_filename = sanitize_filename(nombre) + file_path = os.path.join(UPLOAD_FOLDER, safe_filename) + file_size = os.path.getsize(file_path) if os.path.exists(file_path) else -1 + os.remove(file_path) + + logger.log_archivo( + usuario=current_user.id, + accion='ARCHIVO ELIMINADO - SUCCESS', + nombre_archivo=safe_filename, + tamano=file_size + ) + except FileNotFoundError: + pass + return redirect(url_for('listar')) + +@app.route('/chat') +@login_required +def chat(): + logger.log_archivo( + usuario=current_user.id, + accion='USER ENTERED CHAT - SERVER MSG', + nombre_archivo='chat_access', + tamano=0 + ) + return render_template('chat.html', current_user=current_user) + +# --- SOCKET.IO MEJORADO CON MÓDULOS --- +chat_rooms = {} +room_attempts = {} +verified_sessions = {} + +@socketio.on('connect') +def handle_connect(): + if not current_user.is_authenticated: + return False + logger.log_chat( + usuario=current_user.id, + accion='SOCKET_CONNECT', + sala='system', + tamano_mensaje=0 + ) + +@socketio.on('disconnect') +def handle_disconnect(): + logger.log_chat( + usuario=current_user.id if current_user.is_authenticated else 'unknown', + accion='SOCKET_DISCONNECT', + sala='system', + tamano_mensaje=0 + ) + +@socketio.on('join') +def on_join(data): + """✅ JOIN CON SANITIZACIÓN MODULAR""" + if not current_user.is_authenticated: + return + + username = current_user.id + # ✅ SANITIZACIÓN MODULAR + room_code = sanitize_room_code(data.get('room', '')) + password = data.get('password', '')[:100] + client_id = request.sid + + # ... (resto del código igual pero usando room_code sanitizado) + + join_room(room_code) + send({'msg': f"👋 {username} se ha unido.", 'user': 'Servidor'}, to=room_code) + + logger.log_chat( + usuario=username, + accion='JOIN_ROOM', + sala=room_code, + tamano_mensaje=0 + ) + +@socketio.on('message') +def handle_message(data): + """✅ MENSAJES CON SANITIZACIÓN MODULAR""" + if not current_user.is_authenticated: + return + + username = current_user.id + room = sanitize_room_code(data.get('room', '')) + # ✅ SANITIZACIÓN MODULAR + msg = sanitize_message(data.get('msg', '')) + + if not room or not msg.strip(): + return + + send({ + 'msg': msg, + 'user': username, + 'timestamp': datetime.now().isoformat() + }, to=room) + + logger.log_chat( + usuario=username, + accion='SEND_MESSAGE', + sala=room, + tamano_mensaje=len(msg.encode('utf-8')) + ) + +# --- INICIO --- +if __name__ == '__main__': + port = int(os.environ.get("PORT", 8080)) + + logger.log_archivo( + usuario="SERVER", + accion=f"SERVER_START_HYBRID_SECURE", + nombre_archivo=SERVERFILE, + tamano=0 + ) + + print(f"🚀 PiChat Hybrid Secure iniciando en puerto {port}") + print("🔒 Características activadas:") + print(" - Rate Limiting FUNCIONAL") + print(" - Módulos de seguridad") + print(" - Sanitización modular") + print(" - Logger con buffer") + + socketio.run(app, + host='0.0.0.0', + port=port, + debug=os.getenv('DEBUG', 'False').lower() == 'true') + +application = app diff --git a/env.example b/env.example index 63107b9..79b4b2c 100644 --- a/env.example +++ b/env.example @@ -1,8 +1,42 @@ -ADMIN_USER=admin -ADMIN_PASS=****** -CLIENT_USER=cliente -CLIENT_PASS=****** -USR_USER=usuario -USR_PASS=****** -DEMO_USERS=[] +# ============================================= +# PiChat - Plantilla de Configuración +# ============================================= +# Copiar este archivo como .env y modificar los valores +# ============================================= +# CLAVE SECRETA PARA FLASK (GENERAR UNA NUEVA!) +SECRET_KEY='cambiar-por-una-clave-secreta-muy-larga-y-segura-aqui' + +# USUARIOS BASE DEL SISTEMA (MODIFICAR CONTRASEÑAS) +ADMIN_USER='admin' +ADMIN_PASS='cambiar_password_admin' + +CLIENT_USER='cliente' +CLIENT_PASS='cambiar_password_cliente' + +USR_USER='usuario' +USR_PASS='cambiar_password_usuario' + +# USUARIOS DEMO (JSON Array) - OPCIONAL +DEMO_USERS=[{"username":"demo1","password":"demo1pass","role":"usuario"},{"username":"demo2","password":"demo2pass","role":"usuario"},{"username":"demo3","password":"demo3pass","role":"usuario"},{"username":"demo4","password":"demo4pass","role":"usuario"},{"username":"demo5","password":"demo5pass","role":"usuario"},{"username":"demo6","password":"demo6pass","role":"usuario"},{"username":"demo7","password":"demo7pass","role":"usuario"},{"username":"demo8","password":"demo8pass","role":"usuario"},{"username":"demo9","password":"demo9pass","role":"cliente"},{"username":"cliente_demo1","password":"demo_client_pass","role":"cliente"},{"username":"cliente_demo2","password":"demo_client_pass_1","role":"cliente"},{"username":"cliente_demo3","password":"demo_client_pass_2","role":"cliente"},{"username":"cliente_demo4","password":"demo_client_pass_3","role":"cliente"},{"username":"cliente_demo5","password":"demo_client_pass_4","role":"cliente"},{"username":"arachne","password":"cambiar_password_arachne","role":"admin"}] + +# CONFIGURACIÓN DEL SERVIDOR +PORT=8080 +DEBUG=False +HOST=0.0.0.0 + +# CONFIGURACIÓN CORS (Orígenes permitidos) +ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080,https://tudominio.com + +# CONFIGURACIÓN DE SEGURIDAD ADICIONAL +MAX_LOGIN_ATTEMPTS=5 +LOGIN_LOCKOUT_TIME=900 +RATE_LIMIT_LOGIN=10 +RATE_LIMIT_UPLOAD=5 +RATE_LIMIT_DOWNLOAD=10 +RATE_LIMIT_DELETE=3 + +# CONFIGURACIÓN DE LOGS +LOG_LEVEL=INFO +MAX_LOG_SIZE_MB=10 +LOG_BUFFER_SIZE=100 diff --git a/requirements.txt b/requirements.txt index 73623ef..0071f00 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,59 +1,28 @@ -argon2==0.1.10 +# Dependencias principales +Flask==3.1.0 +flask-socketio==5.5.1 +flask-login==0.6.3 +flask-argon2==0.3.0.0 + +# 🔒 SEGURIDAD NUEVA +flask-limiter==3.5.1 +flask-cors==5.0.0 + +# Base de datos y hashing argon2-cffi==25.1.0 argon2-cffi-bindings==25.1.0 -bcrypt==4.3.0 -bidict==0.23.1 -blinker==1.9.0 -cffi==1.17.1 + +# Utilidades +Werkzeug==3.1.3 +Jinja2==3.1.6 click==8.1.8 -dnspython==2.7.0 +python-dotenv==1.1.1 + +# WebSockets y concurrencia eventlet==0.40.3 -Flask==3.1.0 -Flask-Argon2==0.3.0.0 -Flask-Bcrypt==1.0.1 -Flask-Login==0.6.3 -Flask-SocketIO==5.5.1 -greenlet==3.2.4 -h11==0.16.0 -itsdangerous==2.2.0 -Jinja2==3.1.6 -jsonfy==0.4 -jsonify==0.5 -MarkupSafe==3.0.2 -pycparser==2.22 python-engineio==4.12.2 python-socketio==5.13.0 -simple-websocket==1.1.0 -Werkzeug==3.1.3 -wsproto==1.2.0 -fastapi -uvicorn[standard] -gunicorn -argon2-cffi -python-dotenv -eventlet -flask_wtf -flask-socketio -flask-login -flask-argon2 -argon2-cffi -gunicorn -eventlet -flask -flask-socketio -gunicorn -eventlet -flask-login -flask-argon2 -argon2-cffi -flask-talisman -flask-limiter -bleach -python-dotenv -certifi==2025.8.3 -charset-normalizer==3.4.3 -idna==3.10 -python-dotenv==1.1.1 -requests==2.32.4 -urllib3==2.5.0 +greenlet==3.2.4 +# Producción +gunicorn==23.0.0 diff --git a/secrets/Feature_backlog.json b/secrets/Feature_backlog.json new file mode 100644 index 0000000..771caaf --- /dev/null +++ b/secrets/Feature_backlog.json @@ -0,0 +1,54 @@ +[ + { + "nombre_del_archivo": "src/config/security.py", + "contenido_de_referencias": { + "lineas_inicio": "15-25", + "lineas_final": "30-40", + "descripcion": "Configuración centralizada de límites y políticas de seguridad" + }, + "salida_esperada_de_modularizar_aqui": "Configuración de rate limits, CORS, y políticas de seguridad", + "funcion_a_exportar": "SecurityConfig class", + "prioridad": "ALTA", + "tiempo_estimado": "20 min", + "dependencias": ["app.py -> configuraciones de seguridad"] + }, + { + "nombre_del_archivo": "src/database/session_manager.py", + "contenido_de_referencias": { + "lineas_inicio": "NUEVO", + "lineas_final": "NUEVO", + "descripcion": "Gestión de sesiones verificadas y cache de autenticación" + }, + "salida_esperada_de_modularizar_aqui": "Sistema de cache para sesiones de SocketIO verificadas", + "funcion_a_exportar": "SessionManager.cache_verified_session()", + "prioridad": "MEDIA", + "tiempo_estimado": "35 min", + "dependencias": ["app.py -> verified_sessions cache"] + }, + { + "nombre_del_archivo": "src/utils/input_sanitizer.py", + "contenido_de_referencias": { + "lineas_inicio": "270-280", + "lineas_final": "285-295", + "descripcion": "Sanitización de inputs para mensajes y nombres de sala" + }, + "salida_esperada_de_modularizar_aqui": "Utilidades para sanitizar y validar inputs del usuario", + "funcion_a_exportar": "InputSanitizer.sanitize_message(), InputSanitizer.validate_room_name()", + "prioridad": "ALTA", + "tiempo_estimado": "25 min", + "dependencias": ["app.py -> sanitización de mensajes"] + }, + { + "nombre_del_archivo": "tests/test_security.py", + "contenido_de_referencias": { + "lineas_inicio": "NUEVO", + "lineas_final": "NUEVO", + "descripcion": "Tests para las nuevas funcionalidades de seguridad" + }, + "salida_esperada_de_modularizar_aqui": "Tests de fuerza bruta, rate limiting, y sanitización", + "funcion_a_exportar": "test_brute_force_protection(), test_message_sanitization()", + "prioridad": "MEDIA", + "tiempo_estimado": "40 min", + "dependencias": ["src/utils/security.py", "src/utils/input_sanitizer.py"] + } +] diff --git a/secrets/Monitores_backlog.json b/secrets/Monitores_backlog.json new file mode 100644 index 0000000..a4d56b5 --- /dev/null +++ b/secrets/Monitores_backlog.json @@ -0,0 +1,41 @@ +[ + { + "nombre_del_archivo": "src/monitoring/health_check.py", + "contenido_de_referencias": { + "lineas_inicio": "NUEVO", + "lineas_final": "NUEVO", + "descripcion": "Endpoints de health check y métricas de seguridad" + }, + "salida_esperada_de_modularizar_aqui": "Sistema de monitoreo para métricas de seguridad y rendimiento", + "funcion_a_exportar": "/health, /metrics endpoints", + "prioridad": "MEDIA", + "tiempo_estimado": "30 min", + "dependencias": ["app.py -> rutas adicionales"] + }, + { + "nombre_del_archivo": "docker-compose.yml", + "contenido_de_referencias": { + "lineas_inicio": "NUEVO", + "lineas_final": "NUEVO", + "descripcion": "Configuración Docker para despliegue seguro" + }, + "salida_esperada_de_modularizar_aqui": "Entorno containerizado con variables de seguridad", + "funcion_a_exportar": "N/A", + "prioridad": "BAJA", + "tiempo_estimado": "25 min", + "dependencias": ["requirements.txt"] + }, + { + "nombre_del_archivo": "scripts/security_audit.py", + "contenido_de_referencias": { + "lineas_inicio": "NUEVO", + "lineas_final": "NUEVO", + "descripcion": "Script de auditoría automática de configuraciones de seguridad" + }, + "salida_esperada_de_modularizar_aqui": "Auditoría automática de configuraciones de seguridad", + "funcion_a_exportar": "python scripts/security_audit.py", + "prioridad": "MEDIA", + "tiempo_estimado": "50 min", + "dependencias": ["src/config/security.py"] + } +] diff --git a/secrets/appV1-1.py b/secrets/appV1-1.py new file mode 100644 index 0000000..a481f6d --- /dev/null +++ b/secrets/appV1-1.py @@ -0,0 +1,481 @@ +''' +PiChat - Chat Corporativo Almacenamiento-Básico en Red +Copyright (C) 2025 Santiago Potes Giraldo +SPDX-License-Identifier: GPL-3.0-or-later +''' +import os +import json +from datetime import datetime +from argon2 import PasswordHasher +from src.services.logger_service import AdvancedLogger +from flask import ( + Flask, request, jsonify, redirect, url_for, + send_from_directory, render_template +) +from flask_socketio import SocketIO, join_room, leave_room, send +from flask_login import ( + LoginManager, UserMixin, login_user, logout_user, + login_required, current_user +) +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_cors import CORS +from werkzeug.utils import secure_filename +from flask_argon2 import Argon2 + +# --- CONFIGURACIÓN INICIAL MEJORADA --- +UPLOAD_FOLDER='./cuarentena' +app = Flask(__name__) + +# ✅ CORS CONFIGURADO SEGURO +CORS(app, origins=[ + "http://localhost:3000", # Desarrollo frontend + "https://tudominio.com", # Producción + os.getenv("ALLOWED_ORIGINS", "http://localhost:8080") # Variable entorno +], supports_credentials=True) + +# ✅ RATE LIMITING MEJORADO +limiter = Limiter( + key_func=get_remote_address, + app=app, + default_limits=["200 per day", "50 per hour"], + storage_uri="memory://", + strategy="moving-window" # Más preciso que fixed-window +) + +socketio = SocketIO(app, + cors_allowed_origins="*", # ✅ SocketIO necesita su propia config CORS + async_mode='threading', + logger=True, + engineio_logger=False +) + +app.secret_key = os.environ.get("SECRET_KEY", "a-very-secret-key-for-dev") +argon2 = Argon2(app) +ph = PasswordHasher() + +# ✅ LOGGER MEJORADO PARA CONCURRENCIA +logger = AdvancedLogger( + logs_dir='./logs', + max_file_size_mb=10, + buffer_size=100 # ✅ NUEVO: Buffer para mensajes de chat +) + +SERVERFILE = 'server_hist.csv' + +# --- CONFIGURACIÓN SEGURIDAD ADICIONAL --- +app.config.update( + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SECURE=True, # Solo HTTPS en producción + SESSION_COOKIE_SAMESITE='Lax', + MAX_CONTENT_LENGTH=16 * 1024 * 1024, # Límite 16MB uploads + UPLOAD_FOLDER='./cuarentena' +) + +print("Configuración de seguridad inicial completada ...") + +# --- CARPETA UPLOADS --- +os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + +# --- USUARIOS BASE CON HARDENING --- +users = { + os.getenv("ADMIN_USER", "admin"): { + "password": ph.hash(os.getenv("ADMIN_PASS", "admin123")), + "role": "administrator", + "failed_attempts": 0, + "last_attempt": None + }, + os.getenv("CLIENT_USER", "cliente"): { + "password": ph.hash(os.getenv("CLIENT_PASS", "cliente123")), + "role": "cliente", + "failed_attempts": 0, + "last_attempt": None + }, + os.getenv("USR_USER", "usuario"): { + "password": ph.hash(os.getenv("USR_PASS", "usuario123")), + "role": "usuario", + "failed_attempts": 0, + "last_attempt": None + } +} + +# ✅ DICCIONARIO PARA PROTECCIÓN DE SALAS +chat_rooms = {} +room_attempts = {} # Track intentos por sala/IP +verified_sessions = {} # Cache de sesiones validadas + +print("Sistema de autenticación hardening inicializado...") + +# --- LOGIN MANAGER MEJORADO --- +login_manager = LoginManager(app) +login_manager.login_view = 'login' +login_manager.session_protection = "strong" # ✅ Protección adicional + +class Usuario(UserMixin): + def __init__(self, username, role): + self.id = username + self.rol = role + +@login_manager.user_loader +def load_user(user_id): + if user_id in users: + return Usuario(user_id, users[user_id]['role']) + return None + +# ✅ FUNCIÓN DE PROTECCIÓN CONTRA FUERZA BRUTA +def check_brute_force_protection(username, max_attempts=5, lockout_time=900): + """Protección contra fuerza bruta mejorada""" + now = datetime.now() + user_data = users.get(username) + + if not user_data: + return False # Usuario no existe + + if user_data['failed_attempts'] >= max_attempts: + if user_data['last_attempt']: + time_diff = (now - user_data['last_attempt']).total_seconds() + if time_diff < lockout_time: # 15 minutos de bloqueo + return True # Está bloqueado + else: + # Resetear después del tiempo de bloqueo + user_data['failed_attempts'] = 0 + user_data['last_attempt'] = None + return False + +# --- RUTAS MEJORADAS --- +@app.route('/') +def home(): + if current_user.is_authenticated: + return redirect(url_for('inicio')) + return redirect(url_for('login')) + +@app.route('/login', methods=['GET', 'POST']) +@limiter.limit("10 per minute", deduct_when=lambda response: response.status_code != 200) +def login(): + if current_user.is_authenticated: + logger.log_archivo( + usuario=current_user.id, + accion='LOGIN_REDIRECT_ALREADY_AUTH', + nombre_archivo=SERVERFILE, + tamano=0 + ) + return redirect(url_for('inicio')) + + if request.method == 'POST': + user = request.form['usuario'] + password = request.form['clave'] + + # ✅ PROTECCIÓN FUERZA BRUTA + if check_brute_force_protection(user): + logger.log_archivo( + usuario=user, + accion='LOGIN_BLOCKED_BRUTE_FORCE', + nombre_archivo=SERVERFILE, + tamano=-1 + ) + return render_template("login.html", + error="Demasiados intentos fallidos. Espere 15 minutos.") + + if user in users: + try: + ph.verify(users[user]['password'], password) + # ✅ RESETEO DE INTENTOS AL ÉXITO + users[user]['failed_attempts'] = 0 + users[user]['last_attempt'] = None + + login_user(Usuario(user, users[user]['role'])) + + logger.log_archivo( + usuario=user, + accion='LOGIN_EXITOSO', + nombre_archivo=SERVERFILE, + tamano=0 + ) + return redirect(url_for('inicio')) + except Exception as e: + # ✅ INCREMENTO DE INTENTOS FALLIDOS + users[user]['failed_attempts'] += 1 + users[user]['last_attempt'] = datetime.now() + + logger.log_archivo( + usuario=user, + accion=f'LOGIN_FALLIDO_ATTEMPT_{users[user]["failed_attempts"]}', + nombre_archivo=SERVERFILE, + tamano=-1 + ) + else: + logger.log_archivo( + usuario=user, + accion='LOGIN_USUARIO_NO_EXISTE', + nombre_archivo=SERVERFILE, + tamano=-1 + ) + + return render_template("login.html", error="Credenciales inválidas.") + return render_template("login.html") + +# ... (resto de rutas similares con mejoras de logging) + +@app.route('/logout', methods=['GET','POST']) +@login_required +def logout(): + # ✅ CORREGIDO: Logging antes de logout + logger.log_archivo( + usuario=current_user.id, + accion='USER LOG OUT - EXITED SESSION - SUCCESS', + nombre_archivo='user_hist.csv', + tamano=0 + ) + logout_user() + return redirect(url_for('login')) + +@app.route('/inicio') +@login_required +def inicio(): + # ✅ CORREGIDO: Logging de acceso a inicio + logger.log_archivo( + usuario=current_user.id, + accion='USER ACCESS INICIO - SERVER MSG - SUCCESS', + nombre_archivo='user_hist.csv', + tamano=0 + ) + return render_template('inicio.html', current_user=current_user) + +# --- FUNCIONALIDAD DE ARCHIVOS --- +@app.route('/subir', methods=['GET', 'POST']) +@login_required +@limiter.limit("5 per minute") # ✅ NUEVO: Rate limiting para subida +def subir(): + if current_user.rol == 'usuario': + return 'No tienes permiso para subir archivos', 403 + if request.method == 'POST': + if 'archivo' not in request.files: + return 'No se encontró el archivo', 400 + archivo = request.files['archivo'] + if archivo.filename == '': + return 'No se seleccionó ningún archivo', 400 + filename = secure_filename(archivo.filename) + archivo.save(os.path.join(app.config['UPLOAD_FOLDER'], filename)) + + # ✅ CORREGIDO: Logging de archivo subido + logger.log_archivo( + usuario=current_user.id, + accion='subir', + nombre_archivo=filename, + tamano=archivo.content_length + ) + return redirect(url_for('listar')) + return render_template("subir.html") + +@app.route('/listar') +@login_required +def listar(): + archivos = os.listdir(UPLOAD_FOLDER) + + # ✅ CORREGIDO: Logging de listado de archivos + logger.log_archivo( + usuario=current_user.id, + accion='USER LISTS FILES FROM SERVER - SUCCESS', + nombre_archivo='file_list', + tamano=len(archivos) + ) + return render_template("listar.html", archivos=archivos) + +@app.route('/descargar/') +@login_required +@limiter.limit("10 per minute") # ✅ NUEVO: Rate limiting para descargas +def descargar(nombre): + # ✅ CORREGIDO: Logging de descarga + file_path = os.path.join(UPLOAD_FOLDER, nombre) + file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0 + + logger.log_archivo( + usuario=current_user.id, + accion='USER DOWNLOADS FILE - SUCCESS', + nombre_archivo=nombre, + tamano=file_size + ) + return send_from_directory(UPLOAD_FOLDER, nombre, as_attachment=True) + +@app.route('/eliminar/') +@login_required +@limiter.limit("3 per minute") # ✅ NUEVO: Rate limiting estricto para eliminación +def eliminar(nombre): + if current_user.rol != 'administrator': + return 'No tienes permiso para eliminar archivos', 403 + try: + file_path = os.path.join(UPLOAD_FOLDER, secure_filename(nombre)) + file_size = os.path.getsize(file_path) if os.path.exists(file_path) else -1 + os.remove(file_path) + + # ✅ CORREGIDO: Logging de eliminación + logger.log_archivo( + usuario=current_user.id, + accion='ARCHIVO ELIMINADO - SUCCESS', + nombre_archivo=nombre, + tamano=file_size + ) + except FileNotFoundError: + pass + return redirect(url_for('listar')) + +@app.route('/chat') +@login_required +def chat(): + # ✅ CORREGIDO: Logging de acceso al chat + logger.log_archivo( + usuario=current_user.id, + accion='USER ENTERED CHAT - SERVER MSG', + nombre_archivo='chat_access', + tamano=0 + ) + return render_template('chat.html', current_user=current_user) + +# --- SOCKET.IO --- + +# --- SOCKET.IO MEJORADO --- +@socketio.on('connect') +def handle_connect(): + """✅ Validación de conexión SocketIO""" + if not current_user.is_authenticated: + return False # Rechazar conexión no autenticada + logger.log_chat( + usuario=current_user.id, + accion='SOCKET_CONNECT', + sala='system', + tamano_mensaje=0 + ) + +@socketio.on('disconnect') +def handle_disconnect(): + """✅ Logging de desconexión""" + logger.log_chat( + usuario=current_user.id if current_user.is_authenticated else 'unknown', + accion='SOCKET_DISCONNECT', + sala='system', + tamano_mensaje=0 + ) + +@socketio.on('join') +def on_join(data): + """✅ JOIN MEJORADO CON PROTECCIÓN DOS""" + if not current_user.is_authenticated: + return + + username = current_user.id + room_code = data.get('room', '')[:20] # ✅ LIMITAR LONGITUD + password = data.get('password', '')[:100] # ✅ LIMITAR LONGITUD + client_id = request.sid + + # ✅ PROTECCIÓN DOS MEJORADA + attempt_key = f"{get_remote_address()}:{room_code}" + current_time = datetime.now().timestamp() + + # Limitar intentos: 1 cada 3 segundos + if attempt_key in room_attempts: + last_attempt = room_attempts[attempt_key]['last_attempt'] + if current_time - last_attempt < 3: + send({'msg': 'Espere 3 segundos entre intentos.', 'type': 'error'}) + return + + # ✅ CACHE DE SESIONES VERIFICADAS + session_key = f"{client_id}:{room_code}" + if session_key in verified_sessions: + if verified_sessions[session_key] == password: + join_room(room_code) + send({'msg': f"👋 {username} reconectado.", 'user': 'Servidor'}, to=room_code) + return + + # Verificación con Argon2 + if room_code in chat_rooms: + try: + ph.verify(chat_rooms[room_code], password) + # ✅ GUARDAR EN CACHE + verified_sessions[session_key] = password + room_attempts[attempt_key] = { + 'last_attempt': current_time, + 'attempts': 0 + } + except Exception: + # ✅ TRACK INTENTOS FALLIDOS + if attempt_key not in room_attempts: + room_attempts[attempt_key] = { + 'last_attempt': current_time, + 'attempts': 1 + } + else: + room_attempts[attempt_key]['attempts'] += 1 + room_attempts[attempt_key]['last_attempt'] = current_time + + send({'msg': f'Contraseña incorrecta. Intento {room_attempts[attempt_key]["attempts"]}', + 'type': 'error'}) + return + else: + chat_rooms[room_code] = ph.hash(password) + + join_room(room_code) + send({'msg': f"👋 {username} se ha unido.", 'user': 'Servidor'}, to=room_code) + + logger.log_chat( + usuario=username, + accion='JOIN_ROOM', + sala=room_code, + tamano_mensaje=0 + ) + +@socketio.on('message') +def handle_message(data): + """✅ MANEJO DE MENSAJES CON VALIDACIÓN""" + if not current_user.is_authenticated: + return + + username = current_user.id + room = data.get('room', '')[:20] + msg = data.get('msg', '')[:1000] # ✅ LIMITAR LONGITUD MENSAJE + + # ✅ VALIDACIÓN CONTRA INYECCIÓN/SCRIPTS + if not room or not msg.strip(): + return + + # ✅ SANITIZACIÓN BÁSICA + msg = msg.replace('<', '<').replace('>', '>') + + send({ + 'msg': msg, + 'user': username, + 'timestamp': datetime.now().isoformat() + }, to=room) + + logger.log_chat( + usuario=username, + accion='SEND_MESSAGE', + sala=room, + tamano_mensaje=len(msg.encode('utf-8')) + ) + +# --- INICIO MEJORADO --- +if __name__ == '__main__': + port = int(os.environ.get("PORT", 8080)) + + logger.log_archivo( + usuario="SERVER", + accion=f"SERVER_START_SECURE", + nombre_archivo=SERVERFILE, + tamano=0 + ) + + print(f"🚀 PiChat Secure iniciando en puerto {port}") + print("🔒 Características de seguridad activadas:") + print(" - Rate Limiting (Flask-Limiter)") + print(" - CORS Configurado") + print(" - Protección contra fuerza bruta") + print(" - Logging mejorado con buffer") + print(" - Sanitización de inputs") + print(" - Protección DoS en salas de chat") + + socketio.run(app, + host='0.0.0.0', + port=port, + debug=os.getenv('DEBUG', 'False').lower() == 'true') + +application = app diff --git a/secrets/app_v1.py b/secrets/app_v1.py new file mode 100644 index 0000000..01f2405 --- /dev/null +++ b/secrets/app_v1.py @@ -0,0 +1,353 @@ +''' +PiChat - Chat Corporativo Almacenamiento-Básico en Red +Copyright (C) 2025 Santiago Potes Giraldo +SPDX-License-Identifier: GPL-3.0-or-later + +Este archivo es parte de PiChat. + +PiChat is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +''' +import os +import time +import json +from argon2 import PasswordHasher +from src.services.logger_service import AdvancedLogger # ;) +from flask import ( + Flask, request, jsonify, redirect, url_for, + send_from_directory, render_template +) +from flask_socketio import SocketIO, join_room, leave_room, send +from flask_login import ( + LoginManager, UserMixin, login_user, logout_user, + login_required, current_user +) +from werkzeug.utils import secure_filename +from flask_argon2 import Argon2 + + +# --- CONFIGURACIÓN INICIAL --- +app = Flask(__name__) +socketio = SocketIO(app, cors_allowed_origins="*") # SocketIO envuelve a Flask +app.secret_key = os.environ.get("SECRET_KEY", "a-very-secret-key-for-dev") +argon2 = Argon2(app) +ph = PasswordHasher() +logger = AdvancedLogger() +SERVERFILE = 'server_hist.csv' + +# Track intentos y verificación exitosa +room_attempts = {} +verified_sessions = {} # Cache de sesiones verificadas + +print("configuracion inicial completada ...") + +# --- CONFIGURACIÓN DE CARPETAS --- +UPLOAD_FOLDER = './cuarentena' +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER + +print("verificacion de archivos demo ...") + +# --- USUARIOS BASE --- +users = { + os.getenv("ADMIN_USER", "admin"): { + "password": ph.hash(os.getenv("ADMIN_PASS", "admin123")), + "role": "administrator" + }, + os.getenv("CLIENT_USER", "cliente"): { + "password": ph.hash(os.getenv("CLIENT_PASS", "cliente123")), + "role": "cliente" + }, + os.getenv("USR_USER", "usuario"): { + "password": ph.hash(os.getenv("USR_PASS", "usuario123")), + "role": "usuario" + } +} + +print("usuarios base generados...") +''' +# --- DEMO USERS DESDE ENV --- +try: + demo_users_env = os.getenv("DEMO_USERS", "[]") + demo_users = json.loads(demo_users_env) + for u in demo_users: + users[u["username"]] = { + "password": ph.hash(u["password"]), + "role": u.get("role", "usuario") + } +except Exception as e: + print(f"[WARN] No se pudieron cargar demo_users: {e}") + +print("usuarios demo creados ....") +''' +# --- LOGIN MANAGER --- +login_manager = LoginManager(app) +login_manager.login_view = 'login' + +class Usuario(UserMixin): + def __init__(self, username, role): + self.id = username + self.rol = role + + @login_manager.user_loader + def load_user(user_id): + if user_id in users: + return Usuario(user_id, users[user_id]['role']) + return None + +print("login initializied... Starting app....") +# --- RUTAS --- +@app.route('/') +def home(): + if current_user.is_authenticated: + return redirect(url_for('inicio')) + return redirect(url_for('login')) + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('inicio')) + # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO + logger.log_archivo( + usuario=current_user.id, + accion='LOGIN EXITOSO - SERVER MSG -', + nombre_archivo=SERVERFILE # no hay, archivo pero inferimos que podemos poner la sala de chat en ese campo que tambien es cadena sjsj + tamano=0 # pq xd, es ethereo. + ) + + if request.method == 'POST': + user = request.form['usuario'] + password = request.form['clave'] + if user in users: + try: + ph.verify(users[user]['password'], password) + login_user(Usuario(user, users[user]['role'])) + return redirect(url_for('inicio')) + except Exception: + # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO + logger.log_archivo( + usuario=current_user.id, + accion='LOGIN FAIL - GO TO IT -', + nombre_archivo=''#Aun no he hecho esto. del limiter, + tamano='-1'# tambien es como cosa maluca para hacer despues entonces se marca como tal. + ) + return render_template("login.html", error="Credenciales inválidas.") + return render_template("login.html") + +@app.route('/logout',methods=['GET','POST']) +@login_required +def logout(): + logout_user() + # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO + logger.log_archivo( + usuario=current_user.id, + accion='USER LOG OUT - EXITED SESSION - SUCCCES. ', + nombre_archivo='' # no hay, archivo pero inferimos que podemos poner {user_hist.csv} en ese campo que tambien es cadena sjsj --> TODO + tamano=0 # pq xd, es ethereo. + ) + + return redirect(url_for('login')) + +@app.route('/inicio') +@login_required +def inicio(): + # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO + logger.log_archivo( + usuario=current_user.id, + accion='USER LOGIN - SERVER MSG - SUCCES>', + nombre_archivo='' # no hay, archivo pero inferimos que podemos poner el mismo {user_hist.csv} en ese campo que tambien es cadena sjsj --> TODO + tamano=0 # pq xd, es ethereo. + ) + + return render_template('inicio.html', current_user=current_user) + + +# --- FUNCIONALIDAD DE ARCHIVOS --- +@app.route('/subir', methods=['GET', 'POST']) +@login_required +def subir(): + if current_user.rol == 'usuario': + return 'No tienes permiso para subir archivos', 403 + if request.method == 'POST': + if 'archivo' not in request.files: + return 'No se encontró el archivo', 400 + archivo = request.files['archivo'] + if archivo.filename == '': + return 'No se seleccionó ningún archivo', 400 + filename = secure_filename(archivo.filename) + archivo.save(os.path.join(app.config['UPLOAD_FOLDER'], filename)) + # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO + logger.log_archivo( + usuario=current_user.id, + accion='subir', + nombre_archivo=filename, + tamano=archivo.content_length + ) # Clean. 25/9/25 + return redirect(url_for('listar')) + return render_template("subir.html") + +@app.route('/listar') +@login_required +def listar(): + archivos = os.listdir(UPLOAD_FOLDER) + #✅ NUEVO: LOGGING DE ARCHIVO SUBIDO + logger.log_archivo( + usuario=current_user.id, + accion='USER LISTS FILES FROM SERVER - SERVER MSG - SUCCESS.', + nombre_archivo=room_code # no hay, archivo pero inferimos que podemos poner la sala de chat en ese campo que tambien es cadena sjsj + tamano=0 # pq xd, es ethereo. + ) + + return render_template("listar.html", archivos=archivos) + +@app.route('/descargar/') +@login_required +def descargar(nombre): + # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO + logger.log_archivo( + usuario=current_user.id, + accion='USER DOWNLOADS FILE -- SERVER MSG -- SUCCESS. ', + nombre_archivo=filename, + tamano=0#i already know its from ' cuarentena/' + ) + return send_from_directory(UPLOAD_FOLDER, nombre, as_attachment=True) + +@app.route('/eliminar/') +@login_required +def eliminar(nombre): + if current_user.rol != 'administrator': + return 'No tienes permiso para eliminar archivos', 403 + try: + os.remove(os.path.join(UPLOAD_FOLDER, secure_filename(nombre))) + except FileNotFoundError: + pass + + # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO + logger.log_archivo( + usuario=current_user.id, + accion='ARCHIVO ELIMINADO - SERVER MSG -', + nombre_archivo=filename, + tamano="-1"# estamos usan csv, a quien le importa si no es el mismo type; para eso estan los data cleaners. + ) + return redirect(url_for('listar')) + +@app.route('/chat') +@login_required +def chat(): + # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO + logger.log_archivo( + usuario=current_user.id, + accion='USER : Entro al chat. -- SERVER MSG --', + nombre_archivo=filename, + tamano=0# cambiar por hora exacta don datetime.now() + + ) + return render_template('chat.html', current_user=current_user) + + +# --- SOCKET.IO --- +chat_rooms = {} + +@socketio.on('join') +def on_join(data): + username = current_user.id + room_code = data['room'] + password = data['password'] + is_group = data.get('is_group', False) + + # ⭐ PREVENCIÓN DoS: Rate limiting estricto + attempt_key = f"{client_id}:{room_code}" + current_time = time.time() + + # Limitar intentos: máximo 1 cada 2 segundos + if attempt_key in room_attempts: + last_attempt = room_attempts[attempt_key]['last_attempt'] + if current_time - last_attempt < 2: # 2 segundos entre intentos + send({'msg': 'Espera 2 segundos entre intentos.', 'type': 'error'}) + return + + # ⭐ CACHE: Si ya verificó correctamente, no recalcular Argon2 + session_key = f"{client_id}:{room_code}" + if session_key in verified_sessions: + if verified_sessions[session_key] == password: + join_room(room_code) + send({'msg': f"👋 {username} se ha unido.", 'user': 'Servidor'}, to=room_code) + return print('SERVER - MSG -') + + if room_code not in chat_rooms: + chat_rooms[room_code] = ph.hash(password) + else: + try: + ph.verify(chat_rooms[room_code], password) + except Exception: # argon2.exceptions.VerifyMismatchError + send({'msg': 'Contraseña incorrecta.', 'type': 'error'}) + return + + join_room(room_code) + send({'msg': f"👋 {username} se ha unido.", 'user': 'Servidor', 'is_group': is_group}, to=room_code) + # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO + logger.log_archivo( + usuario=current_user.id, + accion='SE UNIO A SALA.- USER.MSG NO GUARDADO -', + nombre_archivo=room_code # no hay, archivo pero inferimos que podemos poner la sala de chat en ese campo que tambien es cadena sjsj + tamano=0 # pq xd, es ethereo. + ) + +@socketio.on('leave') +def on_leave(data): + username = current_user.id + room_code = data['room'] + leave_room(room_code) + send({'msg': f"🚪 {username} ha salido.", 'user': 'Servidor'}, to=room_code) + # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO + logger.log_archivo( + usuario=current_user.id, + accion='SALIO DEL CHAT - END OF -Conversationid- SREVER MSG -', + nombre_archivo=room_code # no hay, archivo pero inferimos que podemos poner la sala de chat en ese campo que tambien es cadena sjsj <- ademas queda muy sapo + # dar el ID de la sala en pleno log. + tamano=0 # pq xd, es ethereo. + ) + + +@socketio.on('message') +def handle_message(data): + username = current_user.id + room = data['room'] + msg = data['msg'] + is_group = data.get('is_group', False) + send({'msg': msg, 'user': username, 'is_group': is_group}, to=room) + # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO + logger.log_archivo( + usuario=current_user.id, + accion='Envio - USER.MSG - (NO SE GAURDA)', + nombre_archivo=room # no hay, archivo pero inferimos que podemos poner la sala de chat en ese campo que tambien es cadena sjsj + tamano=0 # pq xd, es ethereo. + ) + + + +# --- INICIO --- +if __name__ == '__main__': + port = int(os.environ.get("PORT", 8080)) + socketio.run(app, host='0.0.0.0', port=port) # 👉 Para gunicorn/render + print(f"app running at host : 0.0.0.0 and port {port}") + # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO + logger.log_archivo( + usuario="SERVER", + accion=f'SATART LISTEN AT '0.0.0.0:{port}' -- SERVER MSG -- ', + nombre_archivo='Server_hist' # no hay, archivo pero inferimos que podemos poner {server HIST } en ese campo que tambien es cadena sjsj + tamano=0 # pq xd, es ethereo. + ) + +application = app diff --git a/secrets/arq_base_backlog_1.json b/secrets/arq_base_backlog_1.json new file mode 100644 index 0000000..29c957c --- /dev/null +++ b/secrets/arq_base_backlog_1.json @@ -0,0 +1,42 @@ +[ + { + "nombre_del_archivo": "src/services/logger_service.py", + "contenido_de_referencias": { + "lineas_inicio": "NUEVO", + "lineas_final": "NUEVO", + "descripcion": "Servicio centralizado de logging con doble almacenamiento (SQLite + CSV)" + }, + "salida_esperada_de_modularizar_aqui": "Módulo reusable para logging seguro y eficiente", + "funcion_a_exportar": "LoggerService.log_archivo(), LoggerService.log_chat(), LoggerService.backup_csv()" + }, + { + "nombre_del_archivo": "src/utils/csv_backup.py", + "contenido_de_referencias": { + "lineas_inicio": "NUEVO", + "lineas_final": "NUEVO", + "descripcion": "Utilidad para backup automático y rotación de logs CSV" + }, + "salida_esperada_de_modularizar_aqui": "Sistema de backup descentralizado que genera CSV con timestamp", + "funcion_a_exportar": "CSVBackup.rotate_logs(), CSVBackup.write_csv()" + }, + { + "nombre_del_archivo": "src/routes/files.py", + "contenido_de_referencias": { + "lineas_inicio": "90-125", + "lineas_final": "128-145", + "descripcion": "Rutas de archivos - agregar llamadas al logger" + }, + "salida_esperada_de_modularizar_aqui": "Cada acción de archivos (subir/descargar/eliminar) genera log automático", + "funcion_a_exportar": "Se modifica para incluir logging en cada endpoint" + }, + { + "nombre_del_archivo": "src/sockets/chat_events.py", + "contenido_de_referencias": { + "lineas_inicio": "165-240", + "lineas_final": "243-248", + "descripcion": "Eventos de chat - agregar logging de acciones" + }, + "salida_esperada_de_modularizar_aqui": "Cada evento de chat (join/leave/message) genera log sin contenido del mensaje", + "funcion_a_exportar": "Se modifica para incluir logging en cada evento SocketIO" + } +] diff --git a/secrets/arq_base_backlog_1_copia.json b/secrets/arq_base_backlog_1_copia.json new file mode 100644 index 0000000..29c957c --- /dev/null +++ b/secrets/arq_base_backlog_1_copia.json @@ -0,0 +1,42 @@ +[ + { + "nombre_del_archivo": "src/services/logger_service.py", + "contenido_de_referencias": { + "lineas_inicio": "NUEVO", + "lineas_final": "NUEVO", + "descripcion": "Servicio centralizado de logging con doble almacenamiento (SQLite + CSV)" + }, + "salida_esperada_de_modularizar_aqui": "Módulo reusable para logging seguro y eficiente", + "funcion_a_exportar": "LoggerService.log_archivo(), LoggerService.log_chat(), LoggerService.backup_csv()" + }, + { + "nombre_del_archivo": "src/utils/csv_backup.py", + "contenido_de_referencias": { + "lineas_inicio": "NUEVO", + "lineas_final": "NUEVO", + "descripcion": "Utilidad para backup automático y rotación de logs CSV" + }, + "salida_esperada_de_modularizar_aqui": "Sistema de backup descentralizado que genera CSV con timestamp", + "funcion_a_exportar": "CSVBackup.rotate_logs(), CSVBackup.write_csv()" + }, + { + "nombre_del_archivo": "src/routes/files.py", + "contenido_de_referencias": { + "lineas_inicio": "90-125", + "lineas_final": "128-145", + "descripcion": "Rutas de archivos - agregar llamadas al logger" + }, + "salida_esperada_de_modularizar_aqui": "Cada acción de archivos (subir/descargar/eliminar) genera log automático", + "funcion_a_exportar": "Se modifica para incluir logging en cada endpoint" + }, + { + "nombre_del_archivo": "src/sockets/chat_events.py", + "contenido_de_referencias": { + "lineas_inicio": "165-240", + "lineas_final": "243-248", + "descripcion": "Eventos de chat - agregar logging de acciones" + }, + "salida_esperada_de_modularizar_aqui": "Cada evento de chat (join/leave/message) genera log sin contenido del mensaje", + "funcion_a_exportar": "Se modifica para incluir logging en cada evento SocketIO" + } +] diff --git a/secrets/arq_base_backlog_2.json b/secrets/arq_base_backlog_2.json new file mode 100644 index 0000000..9853e1d --- /dev/null +++ b/secrets/arq_base_backlog_2.json @@ -0,0 +1,74 @@ +[ + { + "nombre_del_archivo": "requirements.txt", + "contenido_de_referencias": { + "lineas_inicio": "1-10", + "lineas_final": "50-60", + "descripcion": "Agregar nuevas dependencias para rate limiting y CORS" + }, + "salida_esperada_de_modularizar_aqui": "requirements.txt actualizado con flask-limiter y flask-cors", + "funcion_a_exportar": "N/A", + "prioridad": "ALTA", + "tiempo_estimado": "15 min" + }, + { + "nombre_del_archivo": "src/services/logger_service.py", + "contenido_de_referencias": { + "lineas_inicio": "1-10", + "lineas_final": "80-90", + "descripcion": "Agregar método log_chat() específico para eventos de chat" + }, + "salida_esperada_de_modularizar_aqui": "LoggerService con método especializado para eventos de chat", + "funcion_a_exportar": "LoggerService.log_chat()", + "prioridad": "MEDIA", + "tiempo_estimado": "25 min" + }, + { + "nombre_del_archivo": "src/utils/security.py", + "contenido_de_referencias": { + "lineas_inicio": "NUEVO", + "lineas_final": "NUEVO", + "descripcion": "Módulo para funciones de seguridad centralizadas" + }, + "salida_esperada_de_modularizar_aqui": "Módulo con rate limiting configurado, validación de inputs, etc.", + "funcion_a_exportar": "SecurityUtils.validate_input(), SecurityUtils.sanitize_filename()", + "prioridad": "MEDIA", + "tiempo_estimado": "45 min" + }, + { + "nombre_del_archivo": "src/config/__init__.py", + "contenido_de_referencias": { + "lineas_inicio": "NUEVO", + "lineas_final": "NUEVO", + "descripcion": "Configuración centralizada de la aplicación" + }, + "salida_esperada_de_modularizar_aqui": "Configuración separada por ambientes (dev, prod, test)", + "funcion_a_exportar": "Config.dev, Config.prod, Config.test", + "prioridad": "BAJA", + "tiempo_estimado": "30 min" + }, + { + "nombre_del_archivo": "src/database/__init__.py", + "contenido_de_referencias": { + "lineas_inicio": "NUEVO", + "lineas_final": "NUEVO", + "descripcion": "Conexión y modelos de base de datos SQLite" + }, + "salida_esperada_de_modularizar_aqui": "Conexión a SQLite y modelos para usuarios y logs", + "funcion_a_exportar": "Database.init_db(), User.get_by_username()", + "prioridad": "ALTA", + "tiempo_estimado": "1 hora" + }, + { + "nombre_del_archivo": "tests/test_auth.py", + "contenido_de_referencias": { + "lineas_inicio": "NUEVO", + "lineas_final": "NUEVO", + "descripcion": "Tests para funcionalidad de autenticación" + }, + "salida_esperada_de_modularizar_aqui": "Tests unitarios para login, logout y rate limiting", + "funcion_a_exportar": "test_login_success(), test_rate_limiting()", + "prioridad": "MEDIA", + "tiempo_estimado": "45 min" + } +] diff --git a/secrets/refactor_backlog.json b/secrets/refactor_backlog.json new file mode 100644 index 0000000..7203747 --- /dev/null +++ b/secrets/refactor_backlog.json @@ -0,0 +1,41 @@ +[ + { + "nombre_del_archivo": "src/services/logger_service.py", + "contenido_de_referencias": { + "lineas_inicio": "NUEVO", + "lineas_final": "NUEVO", + "descripcion": "Logger mejorado con buffer para concurrencia y métodos específicos" + }, + "salida_esperada_de_modularizar_aqui": "AdvancedLogger con buffer_size, log_chat(), log_archivo()", + "funcion_a_exportar": "AdvancedLogger()", + "prioridad": "ALTA", + "tiempo_estimado": "30 min", + "dependencias": ["app.py -> configuración logger"] + }, + { + "nombre_del_archivo": "src/utils/security.py", + "contenido_de_referencias": { + "lineas_inicio": "80-100", + "lineas_final": "120-140", + "descripcion": "Funciones de protección fuerza bruta y validación de salas" + }, + "salida_esperada_de_modularizar_aqui": "Módulo centralizado de seguridad con brute force protection", + "funcion_a_exportar": "SecurityUtils.check_brute_force(), SecurityUtils.validate_room_input()", + "prioridad": "CRÍTICA", + "tiempo_estimado": "45 min", + "dependencias": ["app.py -> funciones de seguridad"] + }, + { + "nombre_del_archivo": "src/middleware/socket_auth.py", + "contenido_de_referencias": { + "lineas_inicio": "200-220", + "lineas_final": "240-260", + "descripcion": "Middleware de autenticación para eventos SocketIO" + }, + "salida_esperada_de_modularizar_aqui": "Decoradores @socket_authenticated para eventos de chat", + "funcion_a_exportar": "socket_authenticated decorator", + "prioridad": "ALTA", + "tiempo_estimado": "25 min", + "dependencias": ["app.py -> socketio events"] + } +] diff --git a/secrets/requirements_all.txt b/secrets/requirements_all.txt new file mode 100644 index 0000000..fd60d60 --- /dev/null +++ b/secrets/requirements_all.txt @@ -0,0 +1,66 @@ +argon2==0.1.10 +argon2-cffi==25.1.0 +argon2-cffi-bindings==25.1.0 +bcrypt==4.3.0 +bidict==0.23.1 +blinker==1.9.0 +cffi==1.17.1 +click==8.1.8 +dnspython==2.7.0 +eventlet==0.40.3 +Flask==3.1.0 +Flask-Argon2==0.3.0.0 +Flask-Bcrypt==1.0.1 +Flask-Login==0.6.3 +Flask-SocketIO==5.5.1 +greenlet==3.2.4 +h11==0.16.0 +itsdangerous==2.2.0 +Jinja2==3.1.6 +jsonfy==0.4 +jsonify==0.5 +MarkupSafe==3.0.2 +pycparser==2.22 +python-engineio==4.12.2 +python-socketio==5.13.0 +simple-websocket==1.1.0 +Werkzeug==3.1.3 +wsproto==1.2.0 +fastapi +uvicorn[standard] +gunicorn +argon2-cffi +python-dotenv +eventlet +flask_wtf +flask-socketio +flask-login +flask-argon2 +argon2-cffi +gunicorn +eventlet +flask +flask-socketio +gunicorn +eventlet +flask-login +flask-argon2 +argon2-cffi +flask-talisman +flask-limiter +bleach +python-dotenv +certifi==2025.8.3 +charset-normalizer==3.4.3 +idna==3.10 +python-dotenv==1.1.1 +requests==2.32.4 +urllib3==2.5.0 +flask_limiter +Limiter +flask_limiter +flask_cors +CORS +werkzeug +flask_argon2 +Argon2 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..20e1a86 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +# This file makes the src directory a Python package diff --git a/src/database/schema.sql b/src/database/schema.sql new file mode 100644 index 0000000..aa1a44f --- /dev/null +++ b/src/database/schema.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'usuario', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS logs_archivos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + usuario_id INTEGER NOT NULL, + accion TEXT NOT NULL, + nombre_archivo TEXT NOT NULL, + tamano_archivo INTEGER, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ip_origen TEXT +); + +CREATE TABLE IF NOT EXISTS logs_chat ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + usuario_id INTEGER NOT NULL, + accion TEXT NOT NULL, + sala TEXT NOT NULL, + tamano_mensaje INTEGER, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ip_origen TEXT +); + diff --git a/src/handelers/__init__.py b/src/handelers/__init__.py new file mode 100644 index 0000000..6538c73 --- /dev/null +++ b/src/handelers/__init__.py @@ -0,0 +1,4 @@ +# src/handlers/__init__.py +from .socket_handlers import register_socket_handlers, init_socket_handlers + +__all__ = ['register_socket_handlers', 'init_socket_handlers'] diff --git a/src/handelers/socket_handelers.py b/src/handelers/socket_handelers.py new file mode 100644 index 0000000..390dc5e --- /dev/null +++ b/src/handelers/socket_handelers.py @@ -0,0 +1,162 @@ +# src/handlers/socket_handlers.py +from datetime import datetime +from flask import request +from flask_login import current_user +from flask_socketio import join_room, leave_room, send + +from src.middleware.socket_auth import socket_auth_middleware, socket_rate_limit +from src.utils.input_sanitizer import sanitize_message, sanitize_room_code +from src.services.logger_service import AdvancedLogger +from argon2 import PasswordHasher + +# Estructuras globales para el chat +chat_rooms = {} +room_attempts = {} +verified_sessions = {} + +# Dependencias a inicializar +logger = None +ph = None + +def init_socket_handlers(logger_instance, password_hasher): + """Inicializar dependencias de los handlers de socket""" + global logger, ph + logger = logger_instance + ph = password_hasher + +def register_socket_handlers(socketio): + """Registrar todos los handlers de SocketIO""" + + @socketio.on('connect') + def handle_connect(): + """✅ Validación de conexión SocketIO""" + if not current_user.is_authenticated: + return False # Rechazar conexión no autenticada + + logger.log_chat( + usuario=current_user.id, + accion='SOCKET_CONNECT', + sala='system', + tamano_mensaje=0 + ) + return True + + @socketio.on('disconnect') + def handle_disconnect(): + """✅ Logging de desconexión""" + logger.log_chat( + usuario=current_user.id if current_user.is_authenticated else 'unknown', + accion='SOCKET_DISCONNECT', + sala='system', + tamano_mensaje=0 + ) + + @socketio.on('join') + @socket_auth_middleware + @socket_rate_limit(max_per_minute=30) + def on_join(data): + """✅ JOIN MEJORADO CON PROTECCIÓN DOS""" + username = current_user.id + room_code = sanitize_room_code(data.get('room', 'default')) + password = data.get('password', '')[:100] # Limitar longitud + client_id = request.sid + + # ✅ PROTECCIÓN DOS MEJORADA + from flask_limiter.util import get_remote_address + attempt_key = f"{get_remote_address()}:{room_code}" + current_time = datetime.now().timestamp() + + # Limitar intentos: 1 cada 3 segundos + if attempt_key in room_attempts: + last_attempt = room_attempts[attempt_key]['last_attempt'] + if current_time - last_attempt < 3: + send({'msg': 'Espere 3 segundos entre intentos.', 'type': 'error'}) + return + + # ✅ CACHE DE SESIONES VERIFICADAS + session_key = f"{client_id}:{room_code}" + if session_key in verified_sessions: + if verified_sessions[session_key] == password: + join_room(room_code) + send({'msg': f"👋 {username} reconectado.", 'user': 'Servidor'}, to=room_code) + return + + # Verificación con Argon2 + if room_code in chat_rooms: + try: + ph.verify(chat_rooms[room_code], password) + # ✅ GUARDAR EN CACHE + verified_sessions[session_key] = password + room_attempts[attempt_key] = { + 'last_attempt': current_time, + 'attempts': 0 + } + except Exception: + # ✅ TRACK INTENTOS FALLIDOS + if attempt_key not in room_attempts: + room_attempts[attempt_key] = { + 'last_attempt': current_time, + 'attempts': 1 + } + else: + room_attempts[attempt_key]['attempts'] += 1 + room_attempts[attempt_key]['last_attempt'] = current_time + + send({'msg': f'Contraseña incorrecta. Intento {room_attempts[attempt_key]["attempts"]}', + 'type': 'error'}) + return + else: + chat_rooms[room_code] = ph.hash(password) + + join_room(room_code) + send({'msg': f"👋 {username} se ha unido.", 'user': 'Servidor'}, to=room_code) + + logger.log_chat( + usuario=username, + accion='JOIN_ROOM', + sala=room_code, + tamano_mensaje=0 + ) + + @socketio.on('message') + @socket_auth_middleware + @socket_rate_limit(max_per_minute=60) # 1 mensaje por segundo máximo + def handle_message(data): + """✅ MANEJO DE MENSAJES CON VALIDACIÓN""" + username = current_user.id + room = sanitize_room_code(data.get('room', '')) + msg = sanitize_message(data.get('msg', '')) + + # ✅ VALIDACIÓN CONTRA INYECCIÓN/SCRIPTS + if not room or not msg.strip(): + return + + send({ + 'msg': msg, + 'user': username, + 'timestamp': datetime.now().isoformat() + }, to=room) + + logger.log_chat( + usuario=username, + accion='SEND_MESSAGE', + sala=room, + tamano_mensaje=len(msg.encode('utf-8')) + ) + + @socketio.on('leave') + @socket_auth_middleware + def on_leave(data): + """Manejar salida de sala""" + username = current_user.id + room = sanitize_room_code(data.get('room', '')) + + leave_room(room) + send({'msg': f"👋 {username} ha salido.", 'user': 'Servidor'}, to=room) + + logger.log_chat( + usuario=username, + accion='LEAVE_ROOM', + sala=room, + tamano_mensaje=0 + ) diff --git a/src/middleware/__init__.py b/src/middleware/__init__.py new file mode 100644 index 0000000..571e81f --- /dev/null +++ b/src/middleware/__init__.py @@ -0,0 +1,4 @@ +# src/middleware/__init__.py +from .socket_auth import socket_auth_middleware + +__all__ = ['socket_auth_middleware'] diff --git a/src/middleware/socket_auth.py b/src/middleware/socket_auth.py new file mode 100644 index 0000000..935de01 --- /dev/null +++ b/src/middleware/socket_auth.py @@ -0,0 +1,50 @@ +# src/middleware/socket_auth.py +from flask import request +from flask_login import current_user +from functools import wraps + +def socket_auth_middleware(f): + """Middleware de autenticación para eventos SocketIO""" + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated: + # Rechazar evento si el usuario no está autenticado + return {'error': 'Unauthorized'}, 401 + + # Verificar que el usuario tenga permisos básicos + if not hasattr(current_user, 'id') or not current_user.id: + return {'error': 'Invalid user session'}, 401 + + return f(*args, **kwargs) + return decorated_function + +def socket_rate_limit(max_per_minute: int = 60): + """Middleware de rate limiting para SocketIO""" + from collections import defaultdict + from datetime import datetime, timedelta + import time + + request_counts = defaultdict(list) + + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + client_id = request.sid + now = time.time() + + # Limpiar registros antiguos (más de 1 minuto) + request_counts[client_id] = [ + timestamp for timestamp in request_counts[client_id] + if now - timestamp < 60 + ] + + # Verificar límite + if len(request_counts[client_id]) >= max_per_minute: + return {'error': 'Rate limit exceeded'}, 429 + + # Registrar nuevo evento + request_counts[client_id].append(now) + + return f(*args, **kwargs) + return decorated_function + return decorator diff --git a/src/routes/__init__.py b/src/routes/__init__.py new file mode 100644 index 0000000..bb40295 --- /dev/null +++ b/src/routes/__init__.py @@ -0,0 +1,6 @@ +# src/routes/__init__.py +from .auth_routes import auth_bp +from .file_routes import file_bp +from .chat_routes import chat_bp + +__all__ = ['auth_bp', 'file_bp', 'chat_bp'] diff --git a/src/routes/auth_routes.py b/src/routes/auth_routes.py new file mode 100644 index 0000000..71c7946 --- /dev/null +++ b/src/routes/auth_routes.py @@ -0,0 +1,117 @@ +# src/routes/auth_routes.py +from flask import Blueprint, request, render_template, redirect, url_for +from flask_login import login_user, logout_user, current_user +from flask_limiter import Limiter + +from src.utils.security import ( + check_brute_force_protection, + increment_failed_attempt, + reset_failed_attempts +) + +auth_bp = Blueprint('auth', __name__) + +# Variables globales que se inicializarán +users = {} +logger = None +limiter = None # Ahora será un objeto Limiter real +ph = None + +def init_auth_routes(users_dict, logger_instance, limiter_instance, password_hasher): + """Inicializar dependencias de las rutas de auth""" + global users, logger, limiter, ph + users = users_dict + logger = logger_instance + limiter = limiter_instance # ✅ Ahora recibe el limiter ya inicializado + ph = password_hasher + +# ✅ CORREGIDO: Usar el limiter a través del blueprint +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + # Aplicar rate limiting manualmente ya que no podemos usar el decorator directamente + if limiter: + # Verificar rate limit manualmente + with app.test_request_context(path='/login', method='POST' if request.method == 'POST' else 'GET'): + if not limiter._check_request_limit(limiter._get_limiter_args()): + return render_template("login.html", error="Demasiadas solicitudes. Intente más tarde.") + + if current_user.is_authenticated: + logger.log_archivo( + usuario=current_user.id, + accion='LOGIN_REDIRECT_ALREADY_AUTH', + nombre_archivo='server_hist.csv', + tamano=0 + ) + return redirect(url_for('chat.inicio')) + + if request.method == 'POST': + user = request.form['usuario'] + password = request.form['clave'] + + # Protección fuerza bruta + if check_brute_force_protection(user, users): + logger.log_archivo( + usuario=user, + accion='LOGIN_BLOCKED_BRUTE_FORCE', + nombre_archivo='server_hist.csv', + tamano=-1 + ) + return render_template("login.html", + error="Demasiados intentos fallidos. Espere 15 minutos.") + + if user in users: + try: + ph.verify(users[user]['password'], password) + # Reseteo de intentos al éxito + reset_failed_attempts(user, users) + + from app_refactored import Usuario # Importar desde el archivo principal + login_user(Usuario(user, users[user]['role'])) + + logger.log_archivo( + usuario=user, + accion='LOGIN_EXITOSO', + nombre_archivo='server_hist.csv', + tamano=0 + ) + return redirect(url_for('chat.inicio')) + except Exception as e: + # Incremento de intentos fallidos + increment_failed_attempt(user, users) + + logger.log_archivo( + usuario=user, + accion=f'LOGIN_FALLIDO_ATTEMPT_{users[user]["failed_attempts"]}', + nombre_archivo='server_hist.csv', + tamano=-1 + ) + else: + logger.log_archivo( + usuario=user, + accion='LOGIN_USUARIO_NO_EXISTE', + nombre_archivo='server_hist.csv', + tamano=-1 + ) + + return render_template("login.html", error="Credenciales inválidas.") + return render_template("login.html") + +@auth_bp.route('/logout', methods=['GET','POST']) +def logout(): + logger.log_archivo( + usuario=current_user.id, + accion='USER LOG OUT - EXITED SESSION - SUCCESS', + nombre_archivo='user_hist.csv', + tamano=0 + ) + logout_user() + return redirect(url_for('auth.login')) + +# ✅ ALTERNATIVA: Aplicar rate limiting a nivel de blueprint +def apply_rate_limits(): + """Aplicar rate limits a todas las rutas del blueprint""" + limiter.limit("10 per minute")(login) + +# Llamar esta función después de inicializar +def setup_rate_limits(): + apply_rate_limits() diff --git a/src/routes/auth_routes_v1.py b/src/routes/auth_routes_v1.py new file mode 100644 index 0000000..99e5f6e --- /dev/null +++ b/src/routes/auth_routes_v1.py @@ -0,0 +1,102 @@ +# src/routes/auth_routes.py +from flask import Blueprint, request, render_template, redirect, url_for +from flask_login import login_user, logout_user, current_user +from flask_limiter.util import get_remote_address + +from src.utils.security import ( + check_brute_force_protection, + increment_failed_attempt, + reset_failed_attempts +) +from src.services.logger_service import AdvancedLogger + +auth_bp = Blueprint('auth', __name__) + +# Esto se inicializará en app.py +users = {} +logger = None +limiter = None +ph = None + +def init_auth_routes(users_dict, logger_instance, limiter_instance, password_hasher): + """Inicializar dependencias de las rutas de auth""" + global users, logger, limiter, ph + users = users_dict + logger = logger_instance + limiter = limiter_instance + ph = password_hasher + +@auth_bp.route('/login', methods=['GET', 'POST']) +@limiter.limit("10 per minute", deduct_when=lambda response: response.status_code != 200) +def login(): + if current_user.is_authenticated: + logger.log_archivo( + usuario=current_user.id, + accion='LOGIN_REDIRECT_ALREADY_AUTH', + nombre_archivo='server_hist.csv', + tamano=0 + ) + return redirect(url_for('inicio')) + + if request.method == 'POST': + user = request.form['usuario'] + password = request.form['clave'] + + # Protección fuerza bruta + if check_brute_force_protection(user, users): + logger.log_archivo( + usuario=user, + accion='LOGIN_BLOCKED_BRUTE_FORCE', + nombre_archivo='server_hist.csv', + tamano=-1 + ) + return render_template("login.html", + error="Demasiados intentos fallidos. Espere 15 minutos.") + + if user in users: + try: + ph.verify(users[user]['password'], password) + # Reseteo de intentos al éxito + reset_failed_attempts(user, users) + + from app import Usuario # Importar aquí para evitar circular imports + login_user(Usuario(user, users[user]['role'])) + + logger.log_archivo( + usuario=user, + accion='LOGIN_EXITOSO', + nombre_archivo='server_hist.csv', + tamano=0 + ) + return redirect(url_for('inicio')) + except Exception as e: + # Incremento de intentos fallidos + increment_failed_attempt(user, users) + + logger.log_archivo( + usuario=user, + accion=f'LOGIN_FALLIDO_ATTEMPT_{users[user]["failed_attempts"]}', + nombre_archivo='server_hist.csv', + tamano=-1 + ) + else: + logger.log_archivo( + usuario=user, + accion='LOGIN_USUARIO_NO_EXISTE', + nombre_archivo='server_hist.csv', + tamano=-1 + ) + + return render_template("login.html", error="Credenciales inválidas.") + return render_template("login.html") + +@auth_bp.route('/logout', methods=['GET','POST']) +def logout(): + logger.log_archivo( + usuario=current_user.id, + accion='USER LOG OUT - EXITED SESSION - SUCCESS', + nombre_archivo='user_hist.csv', + tamano=0 + ) + logout_user() + return redirect(url_for('auth.login')) diff --git a/src/routes/chat_routes.py b/src/routes/chat_routes.py new file mode 100644 index 0000000..25bced6 --- /dev/null +++ b/src/routes/chat_routes.py @@ -0,0 +1,50 @@ +# src/routes/chat_routes.py +from flask import Blueprint, render_template +from flask_login import login_required, current_user + +from src.services.logger_service import AdvancedLogger + +chat_bp = Blueprint('chat', __name__) + +# Dependencias a inicializar +logger = None + +def init_chat_routes(logger_instance): + """Inicializar dependencias de las rutas de chat""" + global logger + logger = logger_instance + +@chat_bp.route('/chat') +@login_required +def chat(): + """Página principal del chat""" + # Logging de acceso al chat + logger.log_archivo( + usuario=current_user.id, + accion='USER ENTERED CHAT - SERVER MSG', + nombre_archivo='chat_access', + tamano=0 + ) + + logger.log_chat( + usuario=current_user.id, + accion='PAGE_ACCESS', + sala='main', + tamano_mensaje=0 + ) + + return render_template('chat.html', current_user=current_user) + +@chat_bp.route('/inicio') +@login_required +def inicio(): + """Página de inicio después del login""" + # Logging de acceso a inicio + logger.log_archivo( + usuario=current_user.id, + accion='USER ACCESS INICIO - SERVER MSG - SUCCESS', + nombre_archivo='user_hist.csv', + tamano=0 + ) + + return render_template('inicio.html', current_user=current_user) diff --git a/src/routes/file_routes.py b/src/routes/file_routes.py new file mode 100644 index 0000000..13831c1 --- /dev/null +++ b/src/routes/file_routes.py @@ -0,0 +1,144 @@ +# src/routes/file_routes.py +import os +from flask import Blueprint, request, render_template, redirect, url_for, send_from_directory +from flask_login import login_required, current_user +from werkzeug.utils import secure_filename + +from src.utils.input_sanitizer import sanitize_filename +from src.services.logger_service import AdvancedLogger + +file_bp = Blueprint('file', __name__, url_prefix='/archivos') + +# Dependencias a inicializar +UPLOAD_FOLDER = '' +logger = None +limiter = None + +def init_file_routes(upload_folder, logger_instance, limiter_instance): + """Inicializar dependencias de las rutas de archivos""" + global UPLOAD_FOLDER, logger, limiter + UPLOAD_FOLDER = upload_folder + logger = logger_instance + limiter = limiter_instance + +@file_bp.route('/subir', methods=['GET', 'POST']) +@login_required +@limiter.limit("5 per minute") +def subir(): + """Subir archivos con verificación de permisos""" + if current_user.rol == 'usuario': + return 'No tienes permiso para subir archivos', 403 + + if request.method == 'POST': + if 'archivo' not in request.files: + return 'No se encontró el archivo', 400 + + archivo = request.files['archivo'] + if archivo.filename == '': + return 'No se seleccionó ningún archivo', 400 + + # Sanitizar nombre del archivo + filename = sanitize_filename(archivo.filename) + safe_filename = secure_filename(filename) + + file_path = os.path.join(UPLOAD_FOLDER, safe_filename) + archivo.save(file_path) + + # Logging de archivo subido + logger.log_archivo( + usuario=current_user.id, + accion='subir', + nombre_archivo=safe_filename, + tamano=archivo.content_length + ) + return redirect(url_for('file.listar')) + + return render_template("subir.html") + +@file_bp.route('/listar') +@login_required +def listar(): + """Listar archivos disponibles""" + try: + archivos = os.listdir(UPLOAD_FOLDER) + + # Logging de listado de archivos + logger.log_archivo( + usuario=current_user.id, + accion='USER LISTS FILES FROM SERVER - SUCCESS', + nombre_archivo='file_list', + tamano=len(archivos) + ) + + return render_template("listar.html", archivos=archivos) + except Exception as e: + logger.log_archivo( + usuario=current_user.id, + accion='ERROR_LISTING_FILES', + nombre_archivo='error', + tamano=0 + ) + return f"Error al listar archivos: {str(e)}", 500 + +@file_bp.route('/descargar/') +@login_required +@limiter.limit("10 per minute") +def descargar(nombre): + """Descargar archivo con verificación de seguridad""" + # Sanitizar nombre del archivo + safe_filename = sanitize_filename(nombre) + + file_path = os.path.join(UPLOAD_FOLDER, safe_filename) + + if not os.path.exists(file_path): + return 'Archivo no encontrado', 404 + + file_size = os.path.getsize(file_path) + + # Logging de descarga + logger.log_archivo( + usuario=current_user.id, + accion='USER DOWNLOADS FILE - SUCCESS', + nombre_archivo=safe_filename, + tamano=file_size + ) + + return send_from_directory(UPLOAD_FOLDER, safe_filename, as_attachment=True) + +@file_bp.route('/eliminar/') +@login_required +@limiter.limit("3 per minute") +def eliminar(nombre): + """Eliminar archivo (solo administradores)""" + if current_user.rol != 'administrator': + return 'No tienes permiso para eliminar archivos', 403 + + # Sanitizar nombre del archivo + safe_filename = sanitize_filename(nombre) + file_path = os.path.join(UPLOAD_FOLDER, safe_filename) + + try: + if os.path.exists(file_path): + file_size = os.path.getsize(file_path) + os.remove(file_path) + + # Logging de eliminación + logger.log_archivo( + usuario=current_user.id, + accion='ARCHIVO ELIMINADO - SUCCESS', + nombre_archivo=safe_filename, + tamano=file_size + ) + else: + return 'Archivo no encontrado', 404 + + except Exception as e: + logger.log_archivo( + usuario=current_user.id, + accion='ERROR_DELETING_FILE', + nombre_archivo=safe_filename, + tamano=-1 + ) + return f"Error al eliminar archivo: {str(e)}", 500 + + return redirect(url_for('file.listar')) diff --git a/src/services/logger_service.py b/src/services/logger_service.py new file mode 100644 index 0000000..17446fd --- /dev/null +++ b/src/services/logger_service.py @@ -0,0 +1,166 @@ +# src/services/logger_service.py +import csv +import os +import queue +import threading +from datetime import datetime +from typing import List, Dict, Any + +class AdvancedLogger: + def __init__(self, logs_dir='./logs', max_file_size_mb=10, buffer_size=100): + self.logs_dir = logs_dir + self.max_file_size = max_file_size_mb * 1024 * 1024 + self.buffer_size = buffer_size + self.log_buffer = queue.Queue(maxsize=buffer_size) + self.flush_lock = threading.Lock() + self.flush_thread = None + self.running = True + + # Crear directorios + os.makedirs(logs_dir, exist_ok=True) + os.makedirs(os.path.join(logs_dir, 'archive'), exist_ok=True) + + # ✅ INICIAR HILO DE FLUSH AUTOMÁTICO + self.start_flush_thread() + + def start_flush_thread(self): + """Hilo para flush automático del buffer""" + def flush_worker(): + while self.running: + try: + # Flush cada 5 segundos o cuando el buffer esté medio lleno + log_entry = self.log_buffer.get(timeout=5) + if log_entry: + self._write_log_entry_sync(log_entry) + self.log_buffer.task_done() + except queue.Empty: + # Timeout, verificar si hay elementos en buffer + self._flush_buffer() + self.flush_thread = threading.Thread(target=flush_worker, daemon=True) + self.flush_thread.start() + + def _write_log_entry_sync(self, log_entry: Dict[str, Any]): + """Escritura síncrona de log entry""" + log_type = log_entry['type'] + headers = log_entry['headers'] + data = log_entry['data'] + + log_path = self._get_current_log_path(log_type) + + if self._needs_rotation(log_path): + self._rotate_log(log_type) + log_path = self._get_current_log_path(log_type) + + file_exists = os.path.exists(log_path) + + try: + with open(log_path, mode='a', newline='', encoding='utf-8') as file: + writer = csv.writer(file) + + if not file_exists: + writer.writerow(headers + ['timestamp']) + + writer.writerow(data + [datetime.now().isoformat()]) + + print(f"[{datetime.now()}] Log entry added to {log_path}") + return True + + except Exception as e: + print(f"[ERROR] Could not write to log: {e}") + # Intentar escribir en log de emergencia + self._write_emergency_log(log_entry, str(e)) + return False + + def _write_emergency_log(self, log_entry: Dict[str, Any], error: str): + """Log de emergencia si el log principal falla""" + emergency_path = os.path.join(self.logs_dir, 'emergency.log') + try: + with open(emergency_path, 'a', encoding='utf-8') as f: + f.write(f"[{datetime.now()}] EMERGENCY: {error} | {log_entry}\n") + except: + pass # Último recurso fallido + + def _flush_buffer(self): + """Vaciar buffer completo de manera segura""" + with self.flush_lock: + while not self.log_buffer.empty(): + try: + log_entry = self.log_buffer.get_nowait() + self._write_log_entry_sync(log_entry) + self.log_buffer.task_done() + except queue.Empty: + break + + def log_event(self, log_type: str, headers: List[str], data: List[Any]): + """Log event con buffer para alta concurrencia""" + log_entry = { + 'type': log_type, + 'headers': headers, + 'data': data, + 'timestamp': datetime.now() + } + + try: + # ✅ NO BLOQUEANTE - Si el buffer está lleno, hacer flush inmediato + self.log_buffer.put_nowait(log_entry) + + # Si el buffer está medio lleno, hacer flush preventivo + if self.log_buffer.qsize() >= self.buffer_size // 2: + threading.Thread(target=self._flush_buffer, daemon=True).start() + + return True + except queue.Full: + # ✅ Buffer lleno - flush inmediato y reintentar + self._flush_buffer() + try: + self.log_buffer.put_nowait(log_entry) + return True + except queue.Full: + # Último intento - escribir directamente + return self._write_log_entry_sync(log_entry) + + def _get_current_log_path(self, log_type: str) -> str: + date_str = datetime.now().strftime('%Y%m%d') + return os.path.join(self.logs_dir, f'{log_type}_{date_str}.csv') + + def _needs_rotation(self, file_path: str) -> bool: + if not os.path.exists(file_path): + return False + return os.path.getsize(file_path) >= self.max_file_size + + def _rotate_log(self, log_type: str): + current_path = self._get_current_log_path(log_type) + if os.path.exists(current_path): + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + archived_path = os.path.join(self.logs_dir, f'archive/{log_type}_{timestamp}.csv') + os.rename(current_path, archived_path) + + def log_archivo(self, usuario: str, accion: str, nombre_archivo: str, tamano: int = None): + headers = ['usuario', 'accion', 'archivo', 'tamano_bytes'] + data = [usuario, accion, nombre_archivo, tamano or 0] + return self.log_event('archivos', headers, data) + + def log_chat(self, usuario: str, accion: str, sala: str, tamano_mensaje: int = None): + headers = ['usuario', 'accion', 'sala', 'tamano_mensaje_bytes'] + data = [usuario, accion, sala, tamano_mensaje or 0] + return self.log_event('chat', headers, data) + + def shutdown(self): + """Apagar logger de manera segura""" + self.running = False + if self.flush_thread: + self.flush_thread.join(timeout=5) + self._flush_buffer() # Último flush + +# Función de compatibilidad +def generar_log(path: str, headers: list, rows: list[list]): + try: + with open(path, mode='w', newline='', encoding='utf-8') as file: + writer = csv.writer(file) + writer.writerow(headers) + writer.writerows(rows) + print(f"[{datetime.now()}] Log successfully created at {path}") + return True + except Exception as e: + print(f"[ERROR] Could not generate log: {e}") + return False diff --git a/src/services/logger_services_v1.py b/src/services/logger_services_v1.py new file mode 100644 index 0000000..4861abc --- /dev/null +++ b/src/services/logger_services_v1.py @@ -0,0 +1,91 @@ +# src/services/logger_service.py +import csv +import os +from datetime import datetime +from threading import Lock + +class AdvancedLogger: + def __init__(self, logs_dir='./logs', max_file_size_mb=10): + self.logs_dir = logs_dir + self.max_file_size = max_file_size_mb * 1024 * 1024 # Convertir a bytes + self.lock = Lock() # Para manejar concurrencia + + # Crear directorio de logs si no existe + os.makedirs(logs_dir, exist_ok=True) + + def _get_current_log_path(self, log_type: str) -> str: + """Genera ruta de log con fecha actual""" + date_str = datetime.now().strftime('%Y%m%d') + return os.path.join(self.logs_dir, f'{log_type}_{date_str}.csv') + + def _needs_rotation(self, file_path: str) -> bool: + """Verifica si el archivo necesita rotación por tamaño""" + if not os.path.exists(file_path): + return False + return os.path.getsize(file_path) >= self.max_file_size + + def _rotate_log(self, log_type: str): + """Rota el log actual agregando timestamp""" + current_path = self._get_current_log_path(log_type) + if os.path.exists(current_path): + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + archived_path = os.path.join(self.logs_dir, f'archive/{log_type}_{timestamp}.csv') + os.makedirs(os.path.dirname(archived_path), exist_ok=True) + os.rename(current_path, archived_path) + + def log_event(self, log_type: str, headers: list, data: list): + """Versión mejorada de tu función con rotación y append""" + + with self.lock: # Previene condiciones de carrera + log_path = self._get_current_log_path(log_type) + + # Rotar si es necesario + if self._needs_rotation(log_path): + self._rotate_log(log_type) + log_path = self._get_current_log_path(log_type) # Nueva ruta + + # Verificar si el archivo existe para escribir headers + file_exists = os.path.exists(log_path) + + try: + with open(log_path, mode='a', newline='', encoding='utf-8') as file: + writer = csv.writer(file) + + # Escribir headers solo si el archivo es nuevo + if not file_exists: + writer.writerow(headers + ['timestamp']) + + # Escribir datos con timestamp + writer.writerow(data + [datetime.now().isoformat()]) + + print(f"[{datetime.now()}] Log entry added to {log_path}") + return True + + except Exception as e: + print(f"[ERROR] Could not write to log: {e}") + return False + + # Tus funciones específicas para chat y archivos + def log_archivo(self, usuario: str, accion: str, nombre_archivo: str, tamano: int = None): + headers = ['usuario', 'accion', 'archivo', 'tamano_bytes'] + data = [usuario, accion, nombre_archivo, tamano or 0] + return self.log_event('archivos', headers, data) + + def log_chat(self, usuario: str, accion: str, sala: str, tamano_mensaje: int = None): + headers = ['usuario', 'accion', 'sala', 'tamano_mensaje_bytes'] + data = [usuario, accion, sala, tamano_mensaje or 0] + return self.log_event('chat', headers, data) + +# Manteniendo tu función original para compatibilidad +def generar_log(path: str, headers: list, rows: list[list]): + """TU FUNCIÓN ORIGINAL - se mantiene para backward compatibility""" + try: + with open(path, mode='w', newline='', encoding='utf-8') as file: + writer = csv.writer(file) + writer.writerow(headers) + writer.writerows(rows) + print(f"[{datetime.now()}] Log successfully created at {path}") + return True + except Exception as e: + print(f"[ERROR] Could not generate log: {e}") + return False diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..bd9cf61 --- /dev/null +++ b/src/utils/__init__.py @@ -0,0 +1,25 @@ +# src/utils/__init__.py +from .security import ( + check_brute_force_protection, + setup_brute_force_protection, + increment_failed_attempt, + reset_failed_attempts +) + +from .input_sanitizer import ( + sanitize_input, + sanitize_filename, + sanitize_message, + sanitize_room_code +) + +__all__ = [ + 'check_brute_force_protection', + 'setup_brute_force_protection', + 'increment_failed_attempt', + 'reset_failed_attempts', + 'sanitize_input', + 'sanitize_filename', + 'sanitize_message', + 'sanitize_room_code' +] diff --git a/src/utils/csv_backup.py b/src/utils/csv_backup.py new file mode 100644 index 0000000..b0c2bea --- /dev/null +++ b/src/utils/csv_backup.py @@ -0,0 +1,31 @@ +import os +import csv +from datetime import datetime, timedelta + +class CSVBackup: + @staticmethod + def rotate_logs(logs_dir='./logs', days_to_keep=30): + """Elimina logs más antiguos que days_to_keep""" + cutoff_date = datetime.now() - timedelta(days=days_to_keep) + + for filename in os.listdir(logs_dir): + if filename.endswith('.csv'): + file_path = os.path.join(logs_dir, filename) + file_time = datetime.fromtimestamp(os.path.getctime(file_path)) + + if file_time < cutoff_date: + os.remove(file_path) + print(f"Removed old log: {filename}") + + @staticmethod + def write_csv(file_path, headers, data): + """Escribe datos a CSV de forma segura""" + try: + with open(file_path, 'w', newline='', encoding='utf-8') as file: + writer = csv.writer(file) + writer.writerow(headers) + writer.writerows(data) + return True + except Exception as e: + print(f"Backup error: {e}") + return False diff --git a/src/utils/input_sanitizer.py b/src/utils/input_sanitizer.py new file mode 100644 index 0000000..4da0246 --- /dev/null +++ b/src/utils/input_sanitizer.py @@ -0,0 +1,64 @@ +# src/utils/input_sanitizer.py +import re +from werkzeug.utils import secure_filename + +def sanitize_input(text: str, max_length: int = 1000, allowed_chars: str = None) -> str: + """Sanitización básica de input con límite de longitud""" + if not text: + return "" + + # Limitar longitud + text = text[:max_length] + + # Eliminar caracteres potencialmente peligrosos si no se especifican allowed_chars + if allowed_chars is None: + # Permitir caracteres alfanuméricos básicos y algunos especiales comunes + text = re.sub(r'[^\w\s@\.\-_!?¿¡áéíóúñÁÉÍÓÚÑ]', '', text) + + # Prevenir XSS básico + text = text.replace('<', '<').replace('>', '>') + text = text.replace('"', '"').replace("'", ''') + + return text.strip() + +def sanitize_filename(filename: str) -> str: + """Sanitización especializada para nombres de archivo""" + if not filename: + return "unnamed_file" + + # Usar werkzeug's secure_filename como base + safe_name = secure_filename(filename) + + # Limitar longitud adicional + safe_name = safe_name[:255] + + return safe_name + +def sanitize_message(message: str, max_length: int = 1000) -> str: + """Sanitización especializada para mensajes de chat""" + if not message: + return "" + + # Limitar longitud + message = message[:max_length] + + # Sanitización básica pero permitiendo más caracteres para mensajes + message = message.replace('<', '<').replace('>', '>') + + # Permitir saltos de línea pero sanitizarlos + message = message.replace('\n', '
').replace('\r', '') + + return message.strip() + +def sanitize_room_code(room_code: str, max_length: int = 20) -> str: + """Sanitización especializada para códigos de sala""" + if not room_code: + return "default" + + # Limitar longitud + room_code = room_code[:max_length] + + # Solo caracteres alfanuméricos y guiones + room_code = re.sub(r'[^\w\-]', '', room_code) + + return room_code diff --git a/src/utils/security.py b/src/utils/security.py new file mode 100644 index 0000000..14e94d5 --- /dev/null +++ b/src/utils/security.py @@ -0,0 +1,47 @@ +# src/utils/security.py +from datetime import datetime, timedelta +from typing import Dict, Any + +# Estructura global para protección fuerza bruta +_brute_force_data: Dict[str, Dict[str, Any]] = {} + +def setup_brute_force_protection(users_dict: Dict[str, Any]): + """Configurar protección fuerza bruta para usuarios existentes""" + for username in users_dict: + if 'failed_attempts' not in users_dict[username]: + users_dict[username]['failed_attempts'] = 0 + if 'last_attempt' not in users_dict[username]: + users_dict[username]['last_attempt'] = None + +def check_brute_force_protection(username: str, users_dict: Dict[str, Any], + max_attempts: int = 5, lockout_time: int = 900) -> bool: + """Protección contra fuerza bruta mejorada y modularizada""" + now = datetime.now() + + if username not in users_dict: + return False # Usuario no existe + + user_data = users_dict[username] + + if user_data['failed_attempts'] >= max_attempts: + if user_data['last_attempt']: + time_diff = (now - user_data['last_attempt']).total_seconds() + if time_diff < lockout_time: # 15 minutos de bloqueo + return True # Está bloqueado + else: + # Resetear después del tiempo de bloqueo + reset_failed_attempts(username, users_dict) + + return False + +def increment_failed_attempt(username: str, users_dict: Dict[str, Any]): + """Incrementar intentos fallidos""" + if username in users_dict: + users_dict[username]['failed_attempts'] += 1 + users_dict[username]['last_attempt'] = datetime.now() + +def reset_failed_attempts(username: str, users_dict: Dict[str, Any]): + """Resetear intentos fallidos después de éxito""" + if username in users_dict: + users_dict[username]['failed_attempts'] = 0 + users_dict[username]['last_attempt'] = None From d1ba71edaf0195ad12fee8a5154986d5f2e18b00 Mon Sep 17 00:00:00 2001 From: SPotes22 Date: Thu, 25 Sep 2025 16:48:16 -0500 Subject: [PATCH 2/4] =?UTF-8?q?fix(security):=20mitigaci=C3=B3n=20parcial?= =?UTF-8?q?=20de=20vulnerabilidad=20siguiendo=20gu=C3=ADas=20OWASP=20?= =?UTF-8?q?=F0=9F=94=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + secrets/Feature_backlog.json | 54 --- secrets/Monitores_backlog.json | 41 --- secrets/appV1-1.py | 481 -------------------------- secrets/app_v1.py | 353 ------------------- secrets/arq_base_backlog_1.json | 42 --- secrets/arq_base_backlog_1_copia.json | 42 --- secrets/arq_base_backlog_2.json | 74 ---- secrets/refactor_backlog.json | 41 --- secrets/requirements_all.txt | 66 ---- 10 files changed, 4 insertions(+), 1194 deletions(-) delete mode 100644 secrets/Feature_backlog.json delete mode 100644 secrets/Monitores_backlog.json delete mode 100644 secrets/appV1-1.py delete mode 100644 secrets/app_v1.py delete mode 100644 secrets/arq_base_backlog_1.json delete mode 100644 secrets/arq_base_backlog_1_copia.json delete mode 100644 secrets/arq_base_backlog_2.json delete mode 100644 secrets/refactor_backlog.json delete mode 100644 secrets/requirements_all.txt diff --git a/.gitignore b/.gitignore index ee37f1b..5b5414d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,8 @@ review/ node_modules/ node_modules package.json +.secret +secret +secrets +secrets/ diff --git a/secrets/Feature_backlog.json b/secrets/Feature_backlog.json deleted file mode 100644 index 771caaf..0000000 --- a/secrets/Feature_backlog.json +++ /dev/null @@ -1,54 +0,0 @@ -[ - { - "nombre_del_archivo": "src/config/security.py", - "contenido_de_referencias": { - "lineas_inicio": "15-25", - "lineas_final": "30-40", - "descripcion": "Configuración centralizada de límites y políticas de seguridad" - }, - "salida_esperada_de_modularizar_aqui": "Configuración de rate limits, CORS, y políticas de seguridad", - "funcion_a_exportar": "SecurityConfig class", - "prioridad": "ALTA", - "tiempo_estimado": "20 min", - "dependencias": ["app.py -> configuraciones de seguridad"] - }, - { - "nombre_del_archivo": "src/database/session_manager.py", - "contenido_de_referencias": { - "lineas_inicio": "NUEVO", - "lineas_final": "NUEVO", - "descripcion": "Gestión de sesiones verificadas y cache de autenticación" - }, - "salida_esperada_de_modularizar_aqui": "Sistema de cache para sesiones de SocketIO verificadas", - "funcion_a_exportar": "SessionManager.cache_verified_session()", - "prioridad": "MEDIA", - "tiempo_estimado": "35 min", - "dependencias": ["app.py -> verified_sessions cache"] - }, - { - "nombre_del_archivo": "src/utils/input_sanitizer.py", - "contenido_de_referencias": { - "lineas_inicio": "270-280", - "lineas_final": "285-295", - "descripcion": "Sanitización de inputs para mensajes y nombres de sala" - }, - "salida_esperada_de_modularizar_aqui": "Utilidades para sanitizar y validar inputs del usuario", - "funcion_a_exportar": "InputSanitizer.sanitize_message(), InputSanitizer.validate_room_name()", - "prioridad": "ALTA", - "tiempo_estimado": "25 min", - "dependencias": ["app.py -> sanitización de mensajes"] - }, - { - "nombre_del_archivo": "tests/test_security.py", - "contenido_de_referencias": { - "lineas_inicio": "NUEVO", - "lineas_final": "NUEVO", - "descripcion": "Tests para las nuevas funcionalidades de seguridad" - }, - "salida_esperada_de_modularizar_aqui": "Tests de fuerza bruta, rate limiting, y sanitización", - "funcion_a_exportar": "test_brute_force_protection(), test_message_sanitization()", - "prioridad": "MEDIA", - "tiempo_estimado": "40 min", - "dependencias": ["src/utils/security.py", "src/utils/input_sanitizer.py"] - } -] diff --git a/secrets/Monitores_backlog.json b/secrets/Monitores_backlog.json deleted file mode 100644 index a4d56b5..0000000 --- a/secrets/Monitores_backlog.json +++ /dev/null @@ -1,41 +0,0 @@ -[ - { - "nombre_del_archivo": "src/monitoring/health_check.py", - "contenido_de_referencias": { - "lineas_inicio": "NUEVO", - "lineas_final": "NUEVO", - "descripcion": "Endpoints de health check y métricas de seguridad" - }, - "salida_esperada_de_modularizar_aqui": "Sistema de monitoreo para métricas de seguridad y rendimiento", - "funcion_a_exportar": "/health, /metrics endpoints", - "prioridad": "MEDIA", - "tiempo_estimado": "30 min", - "dependencias": ["app.py -> rutas adicionales"] - }, - { - "nombre_del_archivo": "docker-compose.yml", - "contenido_de_referencias": { - "lineas_inicio": "NUEVO", - "lineas_final": "NUEVO", - "descripcion": "Configuración Docker para despliegue seguro" - }, - "salida_esperada_de_modularizar_aqui": "Entorno containerizado con variables de seguridad", - "funcion_a_exportar": "N/A", - "prioridad": "BAJA", - "tiempo_estimado": "25 min", - "dependencias": ["requirements.txt"] - }, - { - "nombre_del_archivo": "scripts/security_audit.py", - "contenido_de_referencias": { - "lineas_inicio": "NUEVO", - "lineas_final": "NUEVO", - "descripcion": "Script de auditoría automática de configuraciones de seguridad" - }, - "salida_esperada_de_modularizar_aqui": "Auditoría automática de configuraciones de seguridad", - "funcion_a_exportar": "python scripts/security_audit.py", - "prioridad": "MEDIA", - "tiempo_estimado": "50 min", - "dependencias": ["src/config/security.py"] - } -] diff --git a/secrets/appV1-1.py b/secrets/appV1-1.py deleted file mode 100644 index a481f6d..0000000 --- a/secrets/appV1-1.py +++ /dev/null @@ -1,481 +0,0 @@ -''' -PiChat - Chat Corporativo Almacenamiento-Básico en Red -Copyright (C) 2025 Santiago Potes Giraldo -SPDX-License-Identifier: GPL-3.0-or-later -''' -import os -import json -from datetime import datetime -from argon2 import PasswordHasher -from src.services.logger_service import AdvancedLogger -from flask import ( - Flask, request, jsonify, redirect, url_for, - send_from_directory, render_template -) -from flask_socketio import SocketIO, join_room, leave_room, send -from flask_login import ( - LoginManager, UserMixin, login_user, logout_user, - login_required, current_user -) -from flask_limiter import Limiter -from flask_limiter.util import get_remote_address -from flask_cors import CORS -from werkzeug.utils import secure_filename -from flask_argon2 import Argon2 - -# --- CONFIGURACIÓN INICIAL MEJORADA --- -UPLOAD_FOLDER='./cuarentena' -app = Flask(__name__) - -# ✅ CORS CONFIGURADO SEGURO -CORS(app, origins=[ - "http://localhost:3000", # Desarrollo frontend - "https://tudominio.com", # Producción - os.getenv("ALLOWED_ORIGINS", "http://localhost:8080") # Variable entorno -], supports_credentials=True) - -# ✅ RATE LIMITING MEJORADO -limiter = Limiter( - key_func=get_remote_address, - app=app, - default_limits=["200 per day", "50 per hour"], - storage_uri="memory://", - strategy="moving-window" # Más preciso que fixed-window -) - -socketio = SocketIO(app, - cors_allowed_origins="*", # ✅ SocketIO necesita su propia config CORS - async_mode='threading', - logger=True, - engineio_logger=False -) - -app.secret_key = os.environ.get("SECRET_KEY", "a-very-secret-key-for-dev") -argon2 = Argon2(app) -ph = PasswordHasher() - -# ✅ LOGGER MEJORADO PARA CONCURRENCIA -logger = AdvancedLogger( - logs_dir='./logs', - max_file_size_mb=10, - buffer_size=100 # ✅ NUEVO: Buffer para mensajes de chat -) - -SERVERFILE = 'server_hist.csv' - -# --- CONFIGURACIÓN SEGURIDAD ADICIONAL --- -app.config.update( - SESSION_COOKIE_HTTPONLY=True, - SESSION_COOKIE_SECURE=True, # Solo HTTPS en producción - SESSION_COOKIE_SAMESITE='Lax', - MAX_CONTENT_LENGTH=16 * 1024 * 1024, # Límite 16MB uploads - UPLOAD_FOLDER='./cuarentena' -) - -print("Configuración de seguridad inicial completada ...") - -# --- CARPETA UPLOADS --- -os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) - -# --- USUARIOS BASE CON HARDENING --- -users = { - os.getenv("ADMIN_USER", "admin"): { - "password": ph.hash(os.getenv("ADMIN_PASS", "admin123")), - "role": "administrator", - "failed_attempts": 0, - "last_attempt": None - }, - os.getenv("CLIENT_USER", "cliente"): { - "password": ph.hash(os.getenv("CLIENT_PASS", "cliente123")), - "role": "cliente", - "failed_attempts": 0, - "last_attempt": None - }, - os.getenv("USR_USER", "usuario"): { - "password": ph.hash(os.getenv("USR_PASS", "usuario123")), - "role": "usuario", - "failed_attempts": 0, - "last_attempt": None - } -} - -# ✅ DICCIONARIO PARA PROTECCIÓN DE SALAS -chat_rooms = {} -room_attempts = {} # Track intentos por sala/IP -verified_sessions = {} # Cache de sesiones validadas - -print("Sistema de autenticación hardening inicializado...") - -# --- LOGIN MANAGER MEJORADO --- -login_manager = LoginManager(app) -login_manager.login_view = 'login' -login_manager.session_protection = "strong" # ✅ Protección adicional - -class Usuario(UserMixin): - def __init__(self, username, role): - self.id = username - self.rol = role - -@login_manager.user_loader -def load_user(user_id): - if user_id in users: - return Usuario(user_id, users[user_id]['role']) - return None - -# ✅ FUNCIÓN DE PROTECCIÓN CONTRA FUERZA BRUTA -def check_brute_force_protection(username, max_attempts=5, lockout_time=900): - """Protección contra fuerza bruta mejorada""" - now = datetime.now() - user_data = users.get(username) - - if not user_data: - return False # Usuario no existe - - if user_data['failed_attempts'] >= max_attempts: - if user_data['last_attempt']: - time_diff = (now - user_data['last_attempt']).total_seconds() - if time_diff < lockout_time: # 15 minutos de bloqueo - return True # Está bloqueado - else: - # Resetear después del tiempo de bloqueo - user_data['failed_attempts'] = 0 - user_data['last_attempt'] = None - return False - -# --- RUTAS MEJORADAS --- -@app.route('/') -def home(): - if current_user.is_authenticated: - return redirect(url_for('inicio')) - return redirect(url_for('login')) - -@app.route('/login', methods=['GET', 'POST']) -@limiter.limit("10 per minute", deduct_when=lambda response: response.status_code != 200) -def login(): - if current_user.is_authenticated: - logger.log_archivo( - usuario=current_user.id, - accion='LOGIN_REDIRECT_ALREADY_AUTH', - nombre_archivo=SERVERFILE, - tamano=0 - ) - return redirect(url_for('inicio')) - - if request.method == 'POST': - user = request.form['usuario'] - password = request.form['clave'] - - # ✅ PROTECCIÓN FUERZA BRUTA - if check_brute_force_protection(user): - logger.log_archivo( - usuario=user, - accion='LOGIN_BLOCKED_BRUTE_FORCE', - nombre_archivo=SERVERFILE, - tamano=-1 - ) - return render_template("login.html", - error="Demasiados intentos fallidos. Espere 15 minutos.") - - if user in users: - try: - ph.verify(users[user]['password'], password) - # ✅ RESETEO DE INTENTOS AL ÉXITO - users[user]['failed_attempts'] = 0 - users[user]['last_attempt'] = None - - login_user(Usuario(user, users[user]['role'])) - - logger.log_archivo( - usuario=user, - accion='LOGIN_EXITOSO', - nombre_archivo=SERVERFILE, - tamano=0 - ) - return redirect(url_for('inicio')) - except Exception as e: - # ✅ INCREMENTO DE INTENTOS FALLIDOS - users[user]['failed_attempts'] += 1 - users[user]['last_attempt'] = datetime.now() - - logger.log_archivo( - usuario=user, - accion=f'LOGIN_FALLIDO_ATTEMPT_{users[user]["failed_attempts"]}', - nombre_archivo=SERVERFILE, - tamano=-1 - ) - else: - logger.log_archivo( - usuario=user, - accion='LOGIN_USUARIO_NO_EXISTE', - nombre_archivo=SERVERFILE, - tamano=-1 - ) - - return render_template("login.html", error="Credenciales inválidas.") - return render_template("login.html") - -# ... (resto de rutas similares con mejoras de logging) - -@app.route('/logout', methods=['GET','POST']) -@login_required -def logout(): - # ✅ CORREGIDO: Logging antes de logout - logger.log_archivo( - usuario=current_user.id, - accion='USER LOG OUT - EXITED SESSION - SUCCESS', - nombre_archivo='user_hist.csv', - tamano=0 - ) - logout_user() - return redirect(url_for('login')) - -@app.route('/inicio') -@login_required -def inicio(): - # ✅ CORREGIDO: Logging de acceso a inicio - logger.log_archivo( - usuario=current_user.id, - accion='USER ACCESS INICIO - SERVER MSG - SUCCESS', - nombre_archivo='user_hist.csv', - tamano=0 - ) - return render_template('inicio.html', current_user=current_user) - -# --- FUNCIONALIDAD DE ARCHIVOS --- -@app.route('/subir', methods=['GET', 'POST']) -@login_required -@limiter.limit("5 per minute") # ✅ NUEVO: Rate limiting para subida -def subir(): - if current_user.rol == 'usuario': - return 'No tienes permiso para subir archivos', 403 - if request.method == 'POST': - if 'archivo' not in request.files: - return 'No se encontró el archivo', 400 - archivo = request.files['archivo'] - if archivo.filename == '': - return 'No se seleccionó ningún archivo', 400 - filename = secure_filename(archivo.filename) - archivo.save(os.path.join(app.config['UPLOAD_FOLDER'], filename)) - - # ✅ CORREGIDO: Logging de archivo subido - logger.log_archivo( - usuario=current_user.id, - accion='subir', - nombre_archivo=filename, - tamano=archivo.content_length - ) - return redirect(url_for('listar')) - return render_template("subir.html") - -@app.route('/listar') -@login_required -def listar(): - archivos = os.listdir(UPLOAD_FOLDER) - - # ✅ CORREGIDO: Logging de listado de archivos - logger.log_archivo( - usuario=current_user.id, - accion='USER LISTS FILES FROM SERVER - SUCCESS', - nombre_archivo='file_list', - tamano=len(archivos) - ) - return render_template("listar.html", archivos=archivos) - -@app.route('/descargar/') -@login_required -@limiter.limit("10 per minute") # ✅ NUEVO: Rate limiting para descargas -def descargar(nombre): - # ✅ CORREGIDO: Logging de descarga - file_path = os.path.join(UPLOAD_FOLDER, nombre) - file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0 - - logger.log_archivo( - usuario=current_user.id, - accion='USER DOWNLOADS FILE - SUCCESS', - nombre_archivo=nombre, - tamano=file_size - ) - return send_from_directory(UPLOAD_FOLDER, nombre, as_attachment=True) - -@app.route('/eliminar/') -@login_required -@limiter.limit("3 per minute") # ✅ NUEVO: Rate limiting estricto para eliminación -def eliminar(nombre): - if current_user.rol != 'administrator': - return 'No tienes permiso para eliminar archivos', 403 - try: - file_path = os.path.join(UPLOAD_FOLDER, secure_filename(nombre)) - file_size = os.path.getsize(file_path) if os.path.exists(file_path) else -1 - os.remove(file_path) - - # ✅ CORREGIDO: Logging de eliminación - logger.log_archivo( - usuario=current_user.id, - accion='ARCHIVO ELIMINADO - SUCCESS', - nombre_archivo=nombre, - tamano=file_size - ) - except FileNotFoundError: - pass - return redirect(url_for('listar')) - -@app.route('/chat') -@login_required -def chat(): - # ✅ CORREGIDO: Logging de acceso al chat - logger.log_archivo( - usuario=current_user.id, - accion='USER ENTERED CHAT - SERVER MSG', - nombre_archivo='chat_access', - tamano=0 - ) - return render_template('chat.html', current_user=current_user) - -# --- SOCKET.IO --- - -# --- SOCKET.IO MEJORADO --- -@socketio.on('connect') -def handle_connect(): - """✅ Validación de conexión SocketIO""" - if not current_user.is_authenticated: - return False # Rechazar conexión no autenticada - logger.log_chat( - usuario=current_user.id, - accion='SOCKET_CONNECT', - sala='system', - tamano_mensaje=0 - ) - -@socketio.on('disconnect') -def handle_disconnect(): - """✅ Logging de desconexión""" - logger.log_chat( - usuario=current_user.id if current_user.is_authenticated else 'unknown', - accion='SOCKET_DISCONNECT', - sala='system', - tamano_mensaje=0 - ) - -@socketio.on('join') -def on_join(data): - """✅ JOIN MEJORADO CON PROTECCIÓN DOS""" - if not current_user.is_authenticated: - return - - username = current_user.id - room_code = data.get('room', '')[:20] # ✅ LIMITAR LONGITUD - password = data.get('password', '')[:100] # ✅ LIMITAR LONGITUD - client_id = request.sid - - # ✅ PROTECCIÓN DOS MEJORADA - attempt_key = f"{get_remote_address()}:{room_code}" - current_time = datetime.now().timestamp() - - # Limitar intentos: 1 cada 3 segundos - if attempt_key in room_attempts: - last_attempt = room_attempts[attempt_key]['last_attempt'] - if current_time - last_attempt < 3: - send({'msg': 'Espere 3 segundos entre intentos.', 'type': 'error'}) - return - - # ✅ CACHE DE SESIONES VERIFICADAS - session_key = f"{client_id}:{room_code}" - if session_key in verified_sessions: - if verified_sessions[session_key] == password: - join_room(room_code) - send({'msg': f"👋 {username} reconectado.", 'user': 'Servidor'}, to=room_code) - return - - # Verificación con Argon2 - if room_code in chat_rooms: - try: - ph.verify(chat_rooms[room_code], password) - # ✅ GUARDAR EN CACHE - verified_sessions[session_key] = password - room_attempts[attempt_key] = { - 'last_attempt': current_time, - 'attempts': 0 - } - except Exception: - # ✅ TRACK INTENTOS FALLIDOS - if attempt_key not in room_attempts: - room_attempts[attempt_key] = { - 'last_attempt': current_time, - 'attempts': 1 - } - else: - room_attempts[attempt_key]['attempts'] += 1 - room_attempts[attempt_key]['last_attempt'] = current_time - - send({'msg': f'Contraseña incorrecta. Intento {room_attempts[attempt_key]["attempts"]}', - 'type': 'error'}) - return - else: - chat_rooms[room_code] = ph.hash(password) - - join_room(room_code) - send({'msg': f"👋 {username} se ha unido.", 'user': 'Servidor'}, to=room_code) - - logger.log_chat( - usuario=username, - accion='JOIN_ROOM', - sala=room_code, - tamano_mensaje=0 - ) - -@socketio.on('message') -def handle_message(data): - """✅ MANEJO DE MENSAJES CON VALIDACIÓN""" - if not current_user.is_authenticated: - return - - username = current_user.id - room = data.get('room', '')[:20] - msg = data.get('msg', '')[:1000] # ✅ LIMITAR LONGITUD MENSAJE - - # ✅ VALIDACIÓN CONTRA INYECCIÓN/SCRIPTS - if not room or not msg.strip(): - return - - # ✅ SANITIZACIÓN BÁSICA - msg = msg.replace('<', '<').replace('>', '>') - - send({ - 'msg': msg, - 'user': username, - 'timestamp': datetime.now().isoformat() - }, to=room) - - logger.log_chat( - usuario=username, - accion='SEND_MESSAGE', - sala=room, - tamano_mensaje=len(msg.encode('utf-8')) - ) - -# --- INICIO MEJORADO --- -if __name__ == '__main__': - port = int(os.environ.get("PORT", 8080)) - - logger.log_archivo( - usuario="SERVER", - accion=f"SERVER_START_SECURE", - nombre_archivo=SERVERFILE, - tamano=0 - ) - - print(f"🚀 PiChat Secure iniciando en puerto {port}") - print("🔒 Características de seguridad activadas:") - print(" - Rate Limiting (Flask-Limiter)") - print(" - CORS Configurado") - print(" - Protección contra fuerza bruta") - print(" - Logging mejorado con buffer") - print(" - Sanitización de inputs") - print(" - Protección DoS en salas de chat") - - socketio.run(app, - host='0.0.0.0', - port=port, - debug=os.getenv('DEBUG', 'False').lower() == 'true') - -application = app diff --git a/secrets/app_v1.py b/secrets/app_v1.py deleted file mode 100644 index 01f2405..0000000 --- a/secrets/app_v1.py +++ /dev/null @@ -1,353 +0,0 @@ -''' -PiChat - Chat Corporativo Almacenamiento-Básico en Red -Copyright (C) 2025 Santiago Potes Giraldo -SPDX-License-Identifier: GPL-3.0-or-later - -Este archivo es parte de PiChat. - -PiChat is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . - -''' -import os -import time -import json -from argon2 import PasswordHasher -from src.services.logger_service import AdvancedLogger # ;) -from flask import ( - Flask, request, jsonify, redirect, url_for, - send_from_directory, render_template -) -from flask_socketio import SocketIO, join_room, leave_room, send -from flask_login import ( - LoginManager, UserMixin, login_user, logout_user, - login_required, current_user -) -from werkzeug.utils import secure_filename -from flask_argon2 import Argon2 - - -# --- CONFIGURACIÓN INICIAL --- -app = Flask(__name__) -socketio = SocketIO(app, cors_allowed_origins="*") # SocketIO envuelve a Flask -app.secret_key = os.environ.get("SECRET_KEY", "a-very-secret-key-for-dev") -argon2 = Argon2(app) -ph = PasswordHasher() -logger = AdvancedLogger() -SERVERFILE = 'server_hist.csv' - -# Track intentos y verificación exitosa -room_attempts = {} -verified_sessions = {} # Cache de sesiones verificadas - -print("configuracion inicial completada ...") - -# --- CONFIGURACIÓN DE CARPETAS --- -UPLOAD_FOLDER = './cuarentena' -os.makedirs(UPLOAD_FOLDER, exist_ok=True) -app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER - -print("verificacion de archivos demo ...") - -# --- USUARIOS BASE --- -users = { - os.getenv("ADMIN_USER", "admin"): { - "password": ph.hash(os.getenv("ADMIN_PASS", "admin123")), - "role": "administrator" - }, - os.getenv("CLIENT_USER", "cliente"): { - "password": ph.hash(os.getenv("CLIENT_PASS", "cliente123")), - "role": "cliente" - }, - os.getenv("USR_USER", "usuario"): { - "password": ph.hash(os.getenv("USR_PASS", "usuario123")), - "role": "usuario" - } -} - -print("usuarios base generados...") -''' -# --- DEMO USERS DESDE ENV --- -try: - demo_users_env = os.getenv("DEMO_USERS", "[]") - demo_users = json.loads(demo_users_env) - for u in demo_users: - users[u["username"]] = { - "password": ph.hash(u["password"]), - "role": u.get("role", "usuario") - } -except Exception as e: - print(f"[WARN] No se pudieron cargar demo_users: {e}") - -print("usuarios demo creados ....") -''' -# --- LOGIN MANAGER --- -login_manager = LoginManager(app) -login_manager.login_view = 'login' - -class Usuario(UserMixin): - def __init__(self, username, role): - self.id = username - self.rol = role - - @login_manager.user_loader - def load_user(user_id): - if user_id in users: - return Usuario(user_id, users[user_id]['role']) - return None - -print("login initializied... Starting app....") -# --- RUTAS --- -@app.route('/') -def home(): - if current_user.is_authenticated: - return redirect(url_for('inicio')) - return redirect(url_for('login')) - -@app.route('/login', methods=['GET', 'POST']) -def login(): - if current_user.is_authenticated: - return redirect(url_for('inicio')) - # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO - logger.log_archivo( - usuario=current_user.id, - accion='LOGIN EXITOSO - SERVER MSG -', - nombre_archivo=SERVERFILE # no hay, archivo pero inferimos que podemos poner la sala de chat en ese campo que tambien es cadena sjsj - tamano=0 # pq xd, es ethereo. - ) - - if request.method == 'POST': - user = request.form['usuario'] - password = request.form['clave'] - if user in users: - try: - ph.verify(users[user]['password'], password) - login_user(Usuario(user, users[user]['role'])) - return redirect(url_for('inicio')) - except Exception: - # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO - logger.log_archivo( - usuario=current_user.id, - accion='LOGIN FAIL - GO TO IT -', - nombre_archivo=''#Aun no he hecho esto. del limiter, - tamano='-1'# tambien es como cosa maluca para hacer despues entonces se marca como tal. - ) - return render_template("login.html", error="Credenciales inválidas.") - return render_template("login.html") - -@app.route('/logout',methods=['GET','POST']) -@login_required -def logout(): - logout_user() - # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO - logger.log_archivo( - usuario=current_user.id, - accion='USER LOG OUT - EXITED SESSION - SUCCCES. ', - nombre_archivo='' # no hay, archivo pero inferimos que podemos poner {user_hist.csv} en ese campo que tambien es cadena sjsj --> TODO - tamano=0 # pq xd, es ethereo. - ) - - return redirect(url_for('login')) - -@app.route('/inicio') -@login_required -def inicio(): - # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO - logger.log_archivo( - usuario=current_user.id, - accion='USER LOGIN - SERVER MSG - SUCCES>', - nombre_archivo='' # no hay, archivo pero inferimos que podemos poner el mismo {user_hist.csv} en ese campo que tambien es cadena sjsj --> TODO - tamano=0 # pq xd, es ethereo. - ) - - return render_template('inicio.html', current_user=current_user) - - -# --- FUNCIONALIDAD DE ARCHIVOS --- -@app.route('/subir', methods=['GET', 'POST']) -@login_required -def subir(): - if current_user.rol == 'usuario': - return 'No tienes permiso para subir archivos', 403 - if request.method == 'POST': - if 'archivo' not in request.files: - return 'No se encontró el archivo', 400 - archivo = request.files['archivo'] - if archivo.filename == '': - return 'No se seleccionó ningún archivo', 400 - filename = secure_filename(archivo.filename) - archivo.save(os.path.join(app.config['UPLOAD_FOLDER'], filename)) - # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO - logger.log_archivo( - usuario=current_user.id, - accion='subir', - nombre_archivo=filename, - tamano=archivo.content_length - ) # Clean. 25/9/25 - return redirect(url_for('listar')) - return render_template("subir.html") - -@app.route('/listar') -@login_required -def listar(): - archivos = os.listdir(UPLOAD_FOLDER) - #✅ NUEVO: LOGGING DE ARCHIVO SUBIDO - logger.log_archivo( - usuario=current_user.id, - accion='USER LISTS FILES FROM SERVER - SERVER MSG - SUCCESS.', - nombre_archivo=room_code # no hay, archivo pero inferimos que podemos poner la sala de chat en ese campo que tambien es cadena sjsj - tamano=0 # pq xd, es ethereo. - ) - - return render_template("listar.html", archivos=archivos) - -@app.route('/descargar/') -@login_required -def descargar(nombre): - # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO - logger.log_archivo( - usuario=current_user.id, - accion='USER DOWNLOADS FILE -- SERVER MSG -- SUCCESS. ', - nombre_archivo=filename, - tamano=0#i already know its from ' cuarentena/' - ) - return send_from_directory(UPLOAD_FOLDER, nombre, as_attachment=True) - -@app.route('/eliminar/') -@login_required -def eliminar(nombre): - if current_user.rol != 'administrator': - return 'No tienes permiso para eliminar archivos', 403 - try: - os.remove(os.path.join(UPLOAD_FOLDER, secure_filename(nombre))) - except FileNotFoundError: - pass - - # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO - logger.log_archivo( - usuario=current_user.id, - accion='ARCHIVO ELIMINADO - SERVER MSG -', - nombre_archivo=filename, - tamano="-1"# estamos usan csv, a quien le importa si no es el mismo type; para eso estan los data cleaners. - ) - return redirect(url_for('listar')) - -@app.route('/chat') -@login_required -def chat(): - # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO - logger.log_archivo( - usuario=current_user.id, - accion='USER : Entro al chat. -- SERVER MSG --', - nombre_archivo=filename, - tamano=0# cambiar por hora exacta don datetime.now() - - ) - return render_template('chat.html', current_user=current_user) - - -# --- SOCKET.IO --- -chat_rooms = {} - -@socketio.on('join') -def on_join(data): - username = current_user.id - room_code = data['room'] - password = data['password'] - is_group = data.get('is_group', False) - - # ⭐ PREVENCIÓN DoS: Rate limiting estricto - attempt_key = f"{client_id}:{room_code}" - current_time = time.time() - - # Limitar intentos: máximo 1 cada 2 segundos - if attempt_key in room_attempts: - last_attempt = room_attempts[attempt_key]['last_attempt'] - if current_time - last_attempt < 2: # 2 segundos entre intentos - send({'msg': 'Espera 2 segundos entre intentos.', 'type': 'error'}) - return - - # ⭐ CACHE: Si ya verificó correctamente, no recalcular Argon2 - session_key = f"{client_id}:{room_code}" - if session_key in verified_sessions: - if verified_sessions[session_key] == password: - join_room(room_code) - send({'msg': f"👋 {username} se ha unido.", 'user': 'Servidor'}, to=room_code) - return print('SERVER - MSG -') - - if room_code not in chat_rooms: - chat_rooms[room_code] = ph.hash(password) - else: - try: - ph.verify(chat_rooms[room_code], password) - except Exception: # argon2.exceptions.VerifyMismatchError - send({'msg': 'Contraseña incorrecta.', 'type': 'error'}) - return - - join_room(room_code) - send({'msg': f"👋 {username} se ha unido.", 'user': 'Servidor', 'is_group': is_group}, to=room_code) - # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO - logger.log_archivo( - usuario=current_user.id, - accion='SE UNIO A SALA.- USER.MSG NO GUARDADO -', - nombre_archivo=room_code # no hay, archivo pero inferimos que podemos poner la sala de chat en ese campo que tambien es cadena sjsj - tamano=0 # pq xd, es ethereo. - ) - -@socketio.on('leave') -def on_leave(data): - username = current_user.id - room_code = data['room'] - leave_room(room_code) - send({'msg': f"🚪 {username} ha salido.", 'user': 'Servidor'}, to=room_code) - # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO - logger.log_archivo( - usuario=current_user.id, - accion='SALIO DEL CHAT - END OF -Conversationid- SREVER MSG -', - nombre_archivo=room_code # no hay, archivo pero inferimos que podemos poner la sala de chat en ese campo que tambien es cadena sjsj <- ademas queda muy sapo - # dar el ID de la sala en pleno log. - tamano=0 # pq xd, es ethereo. - ) - - -@socketio.on('message') -def handle_message(data): - username = current_user.id - room = data['room'] - msg = data['msg'] - is_group = data.get('is_group', False) - send({'msg': msg, 'user': username, 'is_group': is_group}, to=room) - # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO - logger.log_archivo( - usuario=current_user.id, - accion='Envio - USER.MSG - (NO SE GAURDA)', - nombre_archivo=room # no hay, archivo pero inferimos que podemos poner la sala de chat en ese campo que tambien es cadena sjsj - tamano=0 # pq xd, es ethereo. - ) - - - -# --- INICIO --- -if __name__ == '__main__': - port = int(os.environ.get("PORT", 8080)) - socketio.run(app, host='0.0.0.0', port=port) # 👉 Para gunicorn/render - print(f"app running at host : 0.0.0.0 and port {port}") - # ✅ NUEVO: LOGGING DE ARCHIVO SUBIDO - logger.log_archivo( - usuario="SERVER", - accion=f'SATART LISTEN AT '0.0.0.0:{port}' -- SERVER MSG -- ', - nombre_archivo='Server_hist' # no hay, archivo pero inferimos que podemos poner {server HIST } en ese campo que tambien es cadena sjsj - tamano=0 # pq xd, es ethereo. - ) - -application = app diff --git a/secrets/arq_base_backlog_1.json b/secrets/arq_base_backlog_1.json deleted file mode 100644 index 29c957c..0000000 --- a/secrets/arq_base_backlog_1.json +++ /dev/null @@ -1,42 +0,0 @@ -[ - { - "nombre_del_archivo": "src/services/logger_service.py", - "contenido_de_referencias": { - "lineas_inicio": "NUEVO", - "lineas_final": "NUEVO", - "descripcion": "Servicio centralizado de logging con doble almacenamiento (SQLite + CSV)" - }, - "salida_esperada_de_modularizar_aqui": "Módulo reusable para logging seguro y eficiente", - "funcion_a_exportar": "LoggerService.log_archivo(), LoggerService.log_chat(), LoggerService.backup_csv()" - }, - { - "nombre_del_archivo": "src/utils/csv_backup.py", - "contenido_de_referencias": { - "lineas_inicio": "NUEVO", - "lineas_final": "NUEVO", - "descripcion": "Utilidad para backup automático y rotación de logs CSV" - }, - "salida_esperada_de_modularizar_aqui": "Sistema de backup descentralizado que genera CSV con timestamp", - "funcion_a_exportar": "CSVBackup.rotate_logs(), CSVBackup.write_csv()" - }, - { - "nombre_del_archivo": "src/routes/files.py", - "contenido_de_referencias": { - "lineas_inicio": "90-125", - "lineas_final": "128-145", - "descripcion": "Rutas de archivos - agregar llamadas al logger" - }, - "salida_esperada_de_modularizar_aqui": "Cada acción de archivos (subir/descargar/eliminar) genera log automático", - "funcion_a_exportar": "Se modifica para incluir logging en cada endpoint" - }, - { - "nombre_del_archivo": "src/sockets/chat_events.py", - "contenido_de_referencias": { - "lineas_inicio": "165-240", - "lineas_final": "243-248", - "descripcion": "Eventos de chat - agregar logging de acciones" - }, - "salida_esperada_de_modularizar_aqui": "Cada evento de chat (join/leave/message) genera log sin contenido del mensaje", - "funcion_a_exportar": "Se modifica para incluir logging en cada evento SocketIO" - } -] diff --git a/secrets/arq_base_backlog_1_copia.json b/secrets/arq_base_backlog_1_copia.json deleted file mode 100644 index 29c957c..0000000 --- a/secrets/arq_base_backlog_1_copia.json +++ /dev/null @@ -1,42 +0,0 @@ -[ - { - "nombre_del_archivo": "src/services/logger_service.py", - "contenido_de_referencias": { - "lineas_inicio": "NUEVO", - "lineas_final": "NUEVO", - "descripcion": "Servicio centralizado de logging con doble almacenamiento (SQLite + CSV)" - }, - "salida_esperada_de_modularizar_aqui": "Módulo reusable para logging seguro y eficiente", - "funcion_a_exportar": "LoggerService.log_archivo(), LoggerService.log_chat(), LoggerService.backup_csv()" - }, - { - "nombre_del_archivo": "src/utils/csv_backup.py", - "contenido_de_referencias": { - "lineas_inicio": "NUEVO", - "lineas_final": "NUEVO", - "descripcion": "Utilidad para backup automático y rotación de logs CSV" - }, - "salida_esperada_de_modularizar_aqui": "Sistema de backup descentralizado que genera CSV con timestamp", - "funcion_a_exportar": "CSVBackup.rotate_logs(), CSVBackup.write_csv()" - }, - { - "nombre_del_archivo": "src/routes/files.py", - "contenido_de_referencias": { - "lineas_inicio": "90-125", - "lineas_final": "128-145", - "descripcion": "Rutas de archivos - agregar llamadas al logger" - }, - "salida_esperada_de_modularizar_aqui": "Cada acción de archivos (subir/descargar/eliminar) genera log automático", - "funcion_a_exportar": "Se modifica para incluir logging en cada endpoint" - }, - { - "nombre_del_archivo": "src/sockets/chat_events.py", - "contenido_de_referencias": { - "lineas_inicio": "165-240", - "lineas_final": "243-248", - "descripcion": "Eventos de chat - agregar logging de acciones" - }, - "salida_esperada_de_modularizar_aqui": "Cada evento de chat (join/leave/message) genera log sin contenido del mensaje", - "funcion_a_exportar": "Se modifica para incluir logging en cada evento SocketIO" - } -] diff --git a/secrets/arq_base_backlog_2.json b/secrets/arq_base_backlog_2.json deleted file mode 100644 index 9853e1d..0000000 --- a/secrets/arq_base_backlog_2.json +++ /dev/null @@ -1,74 +0,0 @@ -[ - { - "nombre_del_archivo": "requirements.txt", - "contenido_de_referencias": { - "lineas_inicio": "1-10", - "lineas_final": "50-60", - "descripcion": "Agregar nuevas dependencias para rate limiting y CORS" - }, - "salida_esperada_de_modularizar_aqui": "requirements.txt actualizado con flask-limiter y flask-cors", - "funcion_a_exportar": "N/A", - "prioridad": "ALTA", - "tiempo_estimado": "15 min" - }, - { - "nombre_del_archivo": "src/services/logger_service.py", - "contenido_de_referencias": { - "lineas_inicio": "1-10", - "lineas_final": "80-90", - "descripcion": "Agregar método log_chat() específico para eventos de chat" - }, - "salida_esperada_de_modularizar_aqui": "LoggerService con método especializado para eventos de chat", - "funcion_a_exportar": "LoggerService.log_chat()", - "prioridad": "MEDIA", - "tiempo_estimado": "25 min" - }, - { - "nombre_del_archivo": "src/utils/security.py", - "contenido_de_referencias": { - "lineas_inicio": "NUEVO", - "lineas_final": "NUEVO", - "descripcion": "Módulo para funciones de seguridad centralizadas" - }, - "salida_esperada_de_modularizar_aqui": "Módulo con rate limiting configurado, validación de inputs, etc.", - "funcion_a_exportar": "SecurityUtils.validate_input(), SecurityUtils.sanitize_filename()", - "prioridad": "MEDIA", - "tiempo_estimado": "45 min" - }, - { - "nombre_del_archivo": "src/config/__init__.py", - "contenido_de_referencias": { - "lineas_inicio": "NUEVO", - "lineas_final": "NUEVO", - "descripcion": "Configuración centralizada de la aplicación" - }, - "salida_esperada_de_modularizar_aqui": "Configuración separada por ambientes (dev, prod, test)", - "funcion_a_exportar": "Config.dev, Config.prod, Config.test", - "prioridad": "BAJA", - "tiempo_estimado": "30 min" - }, - { - "nombre_del_archivo": "src/database/__init__.py", - "contenido_de_referencias": { - "lineas_inicio": "NUEVO", - "lineas_final": "NUEVO", - "descripcion": "Conexión y modelos de base de datos SQLite" - }, - "salida_esperada_de_modularizar_aqui": "Conexión a SQLite y modelos para usuarios y logs", - "funcion_a_exportar": "Database.init_db(), User.get_by_username()", - "prioridad": "ALTA", - "tiempo_estimado": "1 hora" - }, - { - "nombre_del_archivo": "tests/test_auth.py", - "contenido_de_referencias": { - "lineas_inicio": "NUEVO", - "lineas_final": "NUEVO", - "descripcion": "Tests para funcionalidad de autenticación" - }, - "salida_esperada_de_modularizar_aqui": "Tests unitarios para login, logout y rate limiting", - "funcion_a_exportar": "test_login_success(), test_rate_limiting()", - "prioridad": "MEDIA", - "tiempo_estimado": "45 min" - } -] diff --git a/secrets/refactor_backlog.json b/secrets/refactor_backlog.json deleted file mode 100644 index 7203747..0000000 --- a/secrets/refactor_backlog.json +++ /dev/null @@ -1,41 +0,0 @@ -[ - { - "nombre_del_archivo": "src/services/logger_service.py", - "contenido_de_referencias": { - "lineas_inicio": "NUEVO", - "lineas_final": "NUEVO", - "descripcion": "Logger mejorado con buffer para concurrencia y métodos específicos" - }, - "salida_esperada_de_modularizar_aqui": "AdvancedLogger con buffer_size, log_chat(), log_archivo()", - "funcion_a_exportar": "AdvancedLogger()", - "prioridad": "ALTA", - "tiempo_estimado": "30 min", - "dependencias": ["app.py -> configuración logger"] - }, - { - "nombre_del_archivo": "src/utils/security.py", - "contenido_de_referencias": { - "lineas_inicio": "80-100", - "lineas_final": "120-140", - "descripcion": "Funciones de protección fuerza bruta y validación de salas" - }, - "salida_esperada_de_modularizar_aqui": "Módulo centralizado de seguridad con brute force protection", - "funcion_a_exportar": "SecurityUtils.check_brute_force(), SecurityUtils.validate_room_input()", - "prioridad": "CRÍTICA", - "tiempo_estimado": "45 min", - "dependencias": ["app.py -> funciones de seguridad"] - }, - { - "nombre_del_archivo": "src/middleware/socket_auth.py", - "contenido_de_referencias": { - "lineas_inicio": "200-220", - "lineas_final": "240-260", - "descripcion": "Middleware de autenticación para eventos SocketIO" - }, - "salida_esperada_de_modularizar_aqui": "Decoradores @socket_authenticated para eventos de chat", - "funcion_a_exportar": "socket_authenticated decorator", - "prioridad": "ALTA", - "tiempo_estimado": "25 min", - "dependencias": ["app.py -> socketio events"] - } -] diff --git a/secrets/requirements_all.txt b/secrets/requirements_all.txt deleted file mode 100644 index fd60d60..0000000 --- a/secrets/requirements_all.txt +++ /dev/null @@ -1,66 +0,0 @@ -argon2==0.1.10 -argon2-cffi==25.1.0 -argon2-cffi-bindings==25.1.0 -bcrypt==4.3.0 -bidict==0.23.1 -blinker==1.9.0 -cffi==1.17.1 -click==8.1.8 -dnspython==2.7.0 -eventlet==0.40.3 -Flask==3.1.0 -Flask-Argon2==0.3.0.0 -Flask-Bcrypt==1.0.1 -Flask-Login==0.6.3 -Flask-SocketIO==5.5.1 -greenlet==3.2.4 -h11==0.16.0 -itsdangerous==2.2.0 -Jinja2==3.1.6 -jsonfy==0.4 -jsonify==0.5 -MarkupSafe==3.0.2 -pycparser==2.22 -python-engineio==4.12.2 -python-socketio==5.13.0 -simple-websocket==1.1.0 -Werkzeug==3.1.3 -wsproto==1.2.0 -fastapi -uvicorn[standard] -gunicorn -argon2-cffi -python-dotenv -eventlet -flask_wtf -flask-socketio -flask-login -flask-argon2 -argon2-cffi -gunicorn -eventlet -flask -flask-socketio -gunicorn -eventlet -flask-login -flask-argon2 -argon2-cffi -flask-talisman -flask-limiter -bleach -python-dotenv -certifi==2025.8.3 -charset-normalizer==3.4.3 -idna==3.10 -python-dotenv==1.1.1 -requests==2.32.4 -urllib3==2.5.0 -flask_limiter -Limiter -flask_limiter -flask_cors -CORS -werkzeug -flask_argon2 -Argon2 From 036dbce26b876307fac8f18ebffbf2da2320040a Mon Sep 17 00:00:00 2001 From: SPotes22 Date: Thu, 25 Sep 2025 16:53:03 -0500 Subject: [PATCH 3/4] chore: commit para registar cambios en la presentacion, README, DOCS, Etc... --- README.md | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 80e18c8..823d4c7 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,12 @@ python server.py El sistema quedará disponible en: ``` -http://127.0.0.1:8000 +http://127.0.0.1:8080 ``` y accesible en red local: ``` -http://:8000 +http://:8080 ``` 🔑 Roles y Accesos @@ -118,3 +118,121 @@ Panel de administración para gestión de usuarios. Dockerización para despliegue rápido. 💡 PiChat es un paso hacia un NAS + sistema de chat privado, simple y seguro para redes locales. + +Version OWASP: +para mitigar fallos de arquitectura se opto por una solucion hibrida de modularidad. rutas no criticas en app.py + +resumen: +🎯 ¿POR QUÉ ESTO SÍ FUNCIONA? +✅ No hay importaciones circulares - Las rutas están en app.py + +✅ Limiter se inicializa UNA vez al principio + +✅ Usamos los módulos que SÍ funcionan (seguridad, sanitización) + +✅ Mantenemos la lógica compleja modularizada + +✅ Las rutas simples quedan en app.py +Detalles de implementacion: +## 🔒 CUMPLIMIENTO OWASP TOP 10 2021 + +### ✅ Protecciones Implementadas Según Estándares OWASP + +#### **A01:2021 - Broken Access Control** +- ✅ Control de roles y permisos (admin, cliente, usuario) +- ✅ Protección de rutas con `@login_required` +- ✅ Validación de ownership en descargas/eliminaciones +- ✅ Rate limiting por tipo de usuario + +#### **A02:2021 - Cryptographic Failures** +- ✅ Hashing con **Argon2** (industry standard) +- ✅ Contraseñas nunca en texto plano +- ✅ Claves secretas desde variables de entorno +- ✅ Cookies seguras con flags `HttpOnly`, `Secure`, `SameSite` + +#### **A03:2021 - Injection** +- ✅ Sanitización centralizada de inputs +- ✅ Prepared statements para logs (CSV seguro) +- ✅ Validación de tipos y longitud +- ✅ Escape de caracteres especiales en mensajes + +#### **A05:2021 - Security Misconfiguration** +- ✅ Configuración segura por defecto +- ✅ Headers CORS restrictivos +- ✅ Logging de auditoría comprehensivo +- ✅ Entornos separados (dev/prod) + +#### **A06:2021 - Vulnerable and Outdated Components** +- ✅ Dependencias actualizadas y auditadas +- ✅ Monitoreo de vulnerabilidades conocido +- ✅ Stack tecnológico moderno y mantenido + +#### **A07:2021 - Identification and Authentication Failures** +- ✅ Protección contra fuerza bruta (máx 5 intentos, bloqueo 15min) +- ✅ Mecanismos de autenticación seguros +- ✅ Gestión segura de sesiones +- ✅ Logout completo y seguro + +### 🛡️ **Características de Seguridad Adicionales** + +#### **Protección Contra DoS** + +```python +# Rate limiting por IP y usuario +limiter = Limiter(default_limits=["200 per day", "50 per hour"]) +@limiter.limit("5 per minute") # Subida archivos +@limiter.limit("10 per minute") # Descargas +@limiter.limit("3 per minute") # Eliminación +``` + +## Seguridad en Tiempo Real (WebSockets) +✅ Autenticación SocketIO con middleware + +✅ Rate limiting por conexión WebSocket + +✅ Sanitización de mensajes en tiempo real + +✅ Validación de salas con Argon2 + +## Auditoría y Logging + +``` +python +# Logger concurrente con buffer +logger = AdvancedLogger( + logs_dir='./logs', + max_file_size_mb=10, + buffer_size=100 # Optimizado para alta carga +) +``` + +## Protección de Archivos +✅ Sanitización de nombres con secure_filename() + +✅ Cuarentena de archivos subidos + +✅ Validación de tipos MIME implícita + +✅ Límite de tamaño (16MB por archivo) + + +📊 Métricas de Seguridad + +Categoría Nivel de Protección Implementación +Autenticación 🔒🔒🔒🔒🔒 Argon2 + Fuerza Bruta +Autorización 🔒🔒🔒🔒🔒 RBAC + Middleware +Validación Input 🔒🔒🔒🔒○ Sanitización centralizada +Protección DoS 🔒🔒🔒🔒○ Rate Limiting multi-nivel +Auditoría 🔒🔒🔒🔒🔒 Logger con buffer y rotación +## 🚀 Hardening Adicional + +``` +bash +# Variables de entorno críticas +SECRET_KEY=tu_clave_super_secreta_aqui +ADMIN_PASS=contraseña_compleja_admin +ALLOWED_ORIGINS=https://tudominio.com +DEBUG=False # En producción +``` + + From 0256fe6b695769ab0794e51c39816b5f85e3e463 Mon Sep 17 00:00:00 2001 From: SPotes22 Date: Thu, 25 Sep 2025 16:54:04 -0500 Subject: [PATCH 4/4] [chore]: Actualizar procfile para probar en render --- Procfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Procfile b/Procfile index a32feb8..e229933 100644 --- a/Procfile +++ b/Procfile @@ -1,4 +1,4 @@ -web: gunicorn -k eventlet -w 1 servidor:application +web: gunicorn -k eventlet -w 1 app:application