Skip to content

Commit d5c187b

Browse files
committed
hotfix: OSWASP was to much
1 parent fefe6d8 commit d5c187b

1 file changed

Lines changed: 125 additions & 66 deletions

File tree

servidor.py

Lines changed: 125 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -19,68 +19,114 @@
1919
along with this program. If not, see <https://www.gnu.org/licenses/>.
2020
2121
'''
22+
import os
23+
import json
24+
from datetime import timedelta
25+
from urllib.parse import urlparse
26+
2227
from flask import Flask, request, jsonify, redirect, url_for, send_from_directory, render_template
2328
from flask_socketio import SocketIO, join_room, leave_room, send
2429
from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
2530
from werkzeug.utils import secure_filename
2631
from flask_argon2 import Argon2
27-
import os, json
2832
from argon2 import PasswordHasher
2933
import logging
3034
from logging.handlers import RotatingFileHandler
31-
from datetime import timedelta
3235
from flask_talisman import Talisman
3336
from flask_limiter import Limiter
3437
from flask_limiter.util import get_remote_address
3538
import bleach
3639
from flask_wtf import CSRFProtect
3740

38-
39-
# --- CONFIGURACIÓN INICIAL ---
40-
41-
ALLOWED_TAGS = [] # sin HTML permitido
41+
# ---------------------------
42+
# Utiles para sanitizar / orígenes
43+
# ---------------------------
44+
def normalize_origin(o: str):
45+
if not o:
46+
return None
47+
o = o.strip()
48+
# quitar path si lo puso (p.ej. "https://mi.app/chat")
49+
if '://' not in o:
50+
# permitir que pasen dominios sin esquema
51+
o = 'https://' + o
52+
parsed = urlparse(o)
53+
netloc = parsed.netloc or parsed.path
54+
scheme = parsed.scheme or 'https'
55+
return f"{scheme}://{netloc}"
56+
57+
# ---------------------------
58+
# CONFIG INICIAL
59+
# ---------------------------
60+
ALLOWED_TAGS = []
4261
ALLOWED_ATTRS = {}
4362
ALLOWED_PROTOCOLS = ['http', 'https']
4463

4564
app = Flask(__name__)
46-
#CSRFProtect(app)
47-
48-
csrf = CSRFProtect()
49-
csrf.init_app(app)
50-
51-
# Excluir sockets del CSRF
52-
csrf.exempt(socketio_app)
53-
54-
#socketio = SocketIO(app, cors_allowed_origins="*") # SocketIO envuelve a Flask
55-
app.secret_key = os.environ.get("SECRET_KEY")
65+
app.secret_key = os.environ.get("SECRET_KEY", "dev_secret")
5666

5767
app.config.update(
58-
SESSION_COOKIE_SECURE=True, # solo por HTTPS
59-
SESSION_COOKIE_HTTPONLY=True, # no accesible por JS
60-
SESSION_COOKIE_SAMESITE=None, # o "Strict" si no integras con otros dominios
68+
SESSION_COOKIE_SECURE=True,
69+
SESSION_COOKIE_HTTPONLY=True,
70+
SESSION_COOKIE_SAMESITE=None, # importante para que cookies se envíen con websocket handshakes cross-site
6171
REMEMBER_COOKIE_HTTPONLY=True,
6272
PERMANENT_SESSION_LIFETIME=timedelta(hours=8),
63-
MAX_CONTENT_LENGTH=25 * 1024 * 1024, # 25MB uploads
73+
MAX_CONTENT_LENGTH=25 * 1024 * 1024,
6474
)
6575

66-
# Limitar orígenes del socket (quitar el "*")
67-
ALLOWED_ORIGINS = os.environ.get("ALLOWED_ORIGINS", "https://pichat-k0bi.onrender.com/chat").split(",")
68-
socketio = SocketIO(app, cors_allowed_origins=ALLOWED_ORIGINS) # evita CSRF en websockets
76+
# ---------------------------
77+
# ALLOWED ORIGINS: parsear desde env para no hardcodear paths
78+
# ---------------------------
79+
_default_origins = "https://pichat-k0bi.onrender.com,https://*.railway.app,http://localhost:5000"
80+
origins_env = os.environ.get("ALLOWED_ORIGINS", _default_origins)
81+
ALLOWED_ORIGINS = []
82+
for part in origins_env.split(","):
83+
n = normalize_origin(part)
84+
if n:
85+
ALLOWED_ORIGINS.append(n)
86+
87+
# socketio: allow the parsed origins (no paths)
88+
socketio = SocketIO(app, cors_allowed_origins=ALLOWED_ORIGINS, manage_session=False)
89+
90+
# ---------------------------
91+
# CSRF: init y EXEMPT para socketio (handshake)
92+
# ---------------------------
93+
csrf = CSRFProtect()
94+
csrf.init_app(app)
95+
96+
# intentamos eximir SocketIO del CSRF (lo ideal es eximir la ruta /socket.io)
97+
# Si no funcionara por alguna razón, el try/except evita romper el boot.
98+
try:
99+
csrf.exempt(socketio) # normalmente esto evita que flask-wtf valide el handshake
100+
except Exception:
101+
try:
102+
csrf.exempt('socketio')
103+
except Exception:
104+
pass
69105

106+
# ---------------------------
107+
# TALISMAN / CSP: construir connect-src dinámicamente a partir de ALLOWED_ORIGINS
108+
# ---------------------------
109+
wss_origins = []
110+
for o in ALLOWED_ORIGINS:
111+
if o.startswith("https://"):
112+
wss_origins.append(o.replace("https://", "wss://"))
113+
elif o.startswith("http://"):
114+
wss_origins.append(o.replace("http://", "ws://"))
115+
else:
116+
wss_origins.append(o)
70117

71-
argon2 = Argon2(app)
72-
ph = PasswordHasher()
118+
connect_src_value = "'self'"
119+
if wss_origins:
120+
connect_src_value += " " + " ".join(wss_origins)
73121

74-
# Content Security Policy estricta (ajústala a tus assets/CDNs reales)
75122
csp = {
76123
'default-src': "'self'",
77124
'img-src': "'self' data:",
78-
'style-src': "'self' 'unsafe-inline'", # mejor sin 'unsafe-inline' si usas sólo archivos .css
79-
'script-src': "'self'", # sin inline scripts
80-
'connect-src': "'self' wss://pichat-k0bi.onrender.com",
125+
'style-src': "'self' 'unsafe-inline'",
126+
'script-src': "'self'",
127+
'connect-src': connect_src_value,
81128
}
82129

83-
# Fuerza HTTPS + headers seguros
84130
Talisman(
85131
app,
86132
content_security_policy=csp,
@@ -93,15 +139,29 @@
93139
content_security_policy_nonce_in=['script-src'],
94140
)
95141

96-
# Rate limiting global y por endpoint
142+
# ---------------------------
143+
# RATE LIMITER
144+
# ---------------------------
97145
limiter = Limiter(get_remote_address, app=app, default_limits=["200 per minute"])
98146

99-
# --- CONFIGURACIÓN DE CARPETAS ---
147+
# ---------------------------
148+
# ARCHIVOS / UPLOAD
149+
# ---------------------------
100150
UPLOAD_FOLDER = './cuarentena'
101151
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
102152
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
153+
ALLOWED_EXTENSIONS = {'.txt', '.pdf', '.png', '.jpg', '.jpeg', '.gif'}
154+
155+
def allowed_file(filename: str) -> bool:
156+
_, ext = os.path.splitext(filename.lower())
157+
return ext in ALLOWED_EXTENSIONS
158+
159+
# ---------------------------
160+
# AUTH / USERS (demo)
161+
# ---------------------------
162+
argon2 = Argon2(app)
163+
ph = PasswordHasher()
103164

104-
# --- USUARIOS BASE ---
105165
users = {
106166
os.getenv("ADMIN_USER", "admin"): {
107167
"password": ph.hash(os.getenv("ADMIN_PASS", "admin123")),
@@ -117,7 +177,7 @@
117177
}
118178
}
119179

120-
# --- DEMO USERS DESDE ENV ---
180+
# demo users desde env (JSON)
121181
try:
122182
demo_users_env = os.getenv("DEMO_USERS", "[]")
123183
demo_users = json.loads(demo_users_env)
@@ -129,7 +189,6 @@
129189
except Exception as e:
130190
print(f"[WARN] No se pudieron cargar demo_users: {e}")
131191

132-
# --- LOGIN MANAGER ---
133192
login_manager = LoginManager(app)
134193
login_manager.login_view = 'login'
135194

@@ -144,15 +203,17 @@ def load_user(user_id):
144203
return Usuario(user_id, users[user_id]['role'])
145204
return None
146205

147-
# --- RUTAS ---
206+
# ---------------------------
207+
# RUTAS / ENDPOINTS HTTP
208+
# ---------------------------
148209
@app.route('/')
149210
def home():
150211
if current_user.is_authenticated:
151212
return redirect(url_for('inicio'))
152213
return redirect(url_for('login'))
153214

154215
@app.route('/login', methods=['GET', 'POST'])
155-
@limiter.limit("5/minute; 20/hour") # fuerza bruta
216+
@limiter.limit("5/minute; 20/hour")
156217
def login():
157218
if current_user.is_authenticated:
158219
return redirect(url_for('inicio'))
@@ -169,7 +230,6 @@ def login():
169230
except Exception:
170231
pass
171232

172-
# mensaje genérico: no reveles si el usuario existe
173233
return render_template("login.html", error="Credenciales inválidas.")
174234
return render_template("login.html")
175235

@@ -184,13 +244,6 @@ def logout():
184244
def inicio():
185245
return render_template('inicio.html', current_user=current_user)
186246

187-
# --- FUNCIONALIDAD DE ARCHIVOS ---
188-
ALLOWED_EXTENSIONS = {'.txt', '.pdf', '.png', '.jpg', '.jpeg', '.gif'}
189-
190-
def allowed_file(filename: str) -> bool:
191-
_, ext = os.path.splitext(filename.lower())
192-
return ext in ALLOWED_EXTENSIONS
193-
194247
@app.route('/subir', methods=['GET', 'POST'])
195248
@login_required
196249
@limiter.limit("20/hour")
@@ -211,7 +264,6 @@ def subir():
211264
return redirect(url_for('listar'))
212265
return render_template("subir.html")
213266

214-
215267
@app.route('/listar')
216268
@login_required
217269
def listar():
@@ -223,7 +275,7 @@ def listar():
223275
def descargar(nombre):
224276
return send_from_directory(UPLOAD_FOLDER, nombre, as_attachment=True)
225277

226-
@app.route('/eliminar/<nombre>', methods=['POST']) # evita GET peligrosos
278+
@app.route('/eliminar/<nombre>', methods=['POST'])
227279
@login_required
228280
def eliminar(nombre):
229281
if current_user.rol != 'administrator':
@@ -238,19 +290,25 @@ def eliminar(nombre):
238290
pass
239291
return redirect(url_for('listar'))
240292

241-
242293
@app.route('/chat')
243294
@login_required
244295
def chat():
245296
return render_template('chat.html', current_user=current_user)
246297

247-
# --- SOCKET.IO ---
298+
# ---------------------------
299+
# SOCKET.IO EVENTS
300+
# ---------------------------
248301
chat_rooms = {}
249302

250303
@socketio.on('join')
251304
@limiter.limit("1/minute")
252305
def on_join(data):
253-
username = current_user.id
306+
# current_user debería venir vía cookie de sesión si SameSite y CORS correctos
307+
username = getattr(current_user, "id", None)
308+
if not username:
309+
send({'msg': 'Usuario no autenticado.', 'type': 'error'})
310+
return
311+
254312
room_code = (data.get('room') or "")[:64]
255313
password = (data.get('password') or "")[:128]
256314
is_group = bool(data.get('is_group', False))
@@ -269,28 +327,28 @@ def on_join(data):
269327
join_room(room_code)
270328
send({'msg': f"👋 {username} se ha unido.", 'user': 'Servidor', 'is_group': is_group}, to=room_code)
271329

272-
273330
@socketio.on('leave')
274331
def on_leave(data):
275-
username = current_user.id
276-
room_code = data['room']
277-
leave_room(room_code)
278-
send({'msg': f"🚪 {username} ha salido.", 'user': 'Servidor'}, to=room_code)
332+
username = getattr(current_user, "id", None) or "Anon"
333+
room_code = data.get('room')
334+
if room_code:
335+
leave_room(room_code)
336+
send({'msg': f"🚪 {username} ha salido.", 'user': 'Servidor'}, to=room_code)
279337

280338
@socketio.on('message')
281-
@limiter.limit("1/minute") # evita spam de mensajes
339+
@limiter.limit("1/minute")
282340
def handle_message(data):
283-
username = current_user.id
341+
username = getattr(current_user, "id", None) or "Anon"
284342
room = (data.get('room') or "")[:64]
285343
raw_msg = data.get('msg')
286344
msg = clean_text(raw_msg)
287345
is_group = bool(data.get('is_group', False))
288346

289-
# opcional: valida que el usuario esté realmente en esa room
290-
291347
send({'msg': msg, 'user': username, 'is_group': is_group}, to=room)
292348

293-
349+
# ---------------------------
350+
# LOGGING & ERRORES
351+
# ---------------------------
294352
if not app.debug:
295353
handler = RotatingFileHandler('app.log', maxBytes=5_000_000, backupCount=3)
296354
handler.setLevel(logging.INFO)
@@ -306,24 +364,25 @@ def forbidden(e): return "Prohibido", 403
306364
def not_found(e): return "No encontrado", 404
307365

308366
@app.errorhandler(500)
309-
def server_error(e):
367+
def server_error(e):
310368
app.logger.exception("Error 500")
311369
return "Error del servidor", 500
370+
312371
@login_manager.unauthorized_handler
313372
def unauthorized():
314373
return redirect(url_for('login'))
315374

316375
def clean_text(s: str) -> str:
317-
s = (s or "")[:2000] # límite de tamaño de mensaje
376+
s = (s or "")[:2000]
318377
return bleach.clean(s, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS, protocols=ALLOWED_PROTOCOLS, strip=True)
319378

320-
321-
322-
# --- INICIO ---
379+
# ---------------------------
380+
# ENTRYPOINTS
381+
# ---------------------------
323382
if __name__ == '__main__':
324383
port = int(os.environ.get("PORT", 10000))
325384
socketio.run(app, host='0.0.0.0', port=port)
326385

327-
# 👉 Para gunicorn/render
386+
# para Gunicorn / Render / Railway
328387
application = app
329388
socketio_app = socketio

0 commit comments

Comments
 (0)