Jose Salazar Claude Opus 5 commited on
Commit
5af9605
·
1 Parent(s): f651345

No tragar errores de programación al leer la sesión

Browse files

leer_sesion capturaba (BadSignature, Exception). El `Exception` hacía redundante
el `BadSignature` y, sobre todo, convertía cualquier error interno en «no hay
sesión»: un bug en la capa de sesión salía como un 401 silencioso en vez de
propagarse, que es justo lo que no se quiere en el camino de autenticación.

Se captura BadData, el padre común de:
- BadSignature firma inválida o falsificada
- SignatureExpired sesión caducada (subclase de BadSignature)
- BadPayload token corrupto
Las tres son «cookie no válida» → sin sesión. Un TypeError o un fallo de
configuración ahora se propagan.

Nota: (BadSignature, SignatureExpired) —la corrección que se propuso primero—
habría repetido el mismo error en pequeño, porque SignatureExpired ya es
subclase de BadSignature.

Se añaden tests de sesión (no había ninguno), incluido el caso negativo que
motiva el cambio: un error interno debe propagarse, no devolver None.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

backend/app/security/session.py CHANGED
@@ -9,7 +9,7 @@ from __future__ import annotations
9
 
10
  import secrets
11
 
12
- from itsdangerous import BadSignature, URLSafeTimedSerializer
13
 
14
  from ..config import obtener_config
15
 
@@ -34,7 +34,11 @@ def leer_sesion(token: str | None) -> dict | None:
34
  cfg = obtener_config()
35
  try:
36
  return _serializer().loads(token, max_age=cfg.session_max_age_s)
37
- except (BadSignature, Exception): # noqa: BLE001
 
 
 
 
38
  return None
39
 
40
 
 
9
 
10
  import secrets
11
 
12
+ from itsdangerous import BadData, URLSafeTimedSerializer
13
 
14
  from ..config import obtener_config
15
 
 
34
  cfg = obtener_config()
35
  try:
36
  return _serializer().loads(token, max_age=cfg.session_max_age_s)
37
+ except BadData:
38
+ # BadData es el padre de BadSignature (firma inválida o falsificada), de
39
+ # SignatureExpired (sesión caducada) y de BadPayload (token corrupto): las tres son
40
+ # "cookie no válida" → sin sesión. NO se captura Exception: un TypeError o un fallo de
41
+ # configuración deben propagarse, no disfrazarse de 401 y ocultar el bug real.
42
  return None
43
 
44
 
backend/tests/test_session.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sesiones firmadas por cookie.
2
+
3
+ El caso importante aquí es el NEGATIVO: `leer_sesion` capturaba `(BadSignature, Exception)`, de
4
+ modo que cualquier error de programación se convertía en «no hay sesión» y salía como un 401
5
+ silencioso, ocultando el bug real. Estos tests fijan qué se traga y qué debe propagarse.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import pytest
11
+
12
+ from app.security import session as ses
13
+
14
+
15
+ def test_ida_y_vuelta():
16
+ token = ses.firmar_sesion({"email": "vet@example.com"})
17
+ assert ses.leer_sesion(token) == {"email": "vet@example.com"}
18
+
19
+
20
+ @pytest.mark.parametrize(
21
+ "token",
22
+ [
23
+ None,
24
+ "",
25
+ "no-es-un-token",
26
+ "a.b.c",
27
+ "eyJlbWFpbCI6ICJhdGFjYW50ZUBleGFtcGxlLmNvbSJ9.falsificado.firma",
28
+ ],
29
+ )
30
+ def test_tokens_invalidos_devuelven_none(token):
31
+ """Firma inválida, token corrupto o ausente → sin sesión, sin excepción."""
32
+ assert ses.leer_sesion(token) is None
33
+
34
+
35
+ def test_firma_con_otro_secreto_no_valida(monkeypatch):
36
+ """Una cookie firmada con otro secreto (p. ej. el fallback de dev) no debe abrir sesión:
37
+ es exactamente el escenario de suplantación que evita MORPHOS_SESSION_SECRET."""
38
+ token = ses.firmar_sesion({"email": "vet@example.com"})
39
+
40
+ ses.obtener_config.cache_clear()
41
+ monkeypatch.setenv("MORPHOS_SESSION_SECRET", "otro-secreto-completamente-distinto-1234567890")
42
+ try:
43
+ assert ses.leer_sesion(token) is None
44
+ finally:
45
+ ses.obtener_config.cache_clear()
46
+
47
+
48
+ def test_sesion_caducada_devuelve_none(monkeypatch):
49
+ token = ses.firmar_sesion({"email": "vet@example.com"})
50
+
51
+ ses.obtener_config.cache_clear()
52
+ # -1 y no 0: itsdangerous compara `edad > max_age`, así que un token recién creado (edad 0)
53
+ # con max_age=0 todavía es válido. Con -1 se dispara SignatureExpired, subclase de BadData,
54
+ # que es la rama que interesa comprobar.
55
+ monkeypatch.setenv("MORPHOS_SESSION_MAX_AGE_S", "-1")
56
+ try:
57
+ assert ses.leer_sesion(token) is None
58
+ finally:
59
+ ses.obtener_config.cache_clear()
60
+
61
+
62
+ def test_los_errores_de_programacion_se_propagan(monkeypatch):
63
+ """El fallo que motiva estos tests: un error interno NO puede disfrazarse de «sin sesión».
64
+ Si `loads` revienta por un bug, debe propagarse para que se vea, no devolver None."""
65
+
66
+ class SerializadorRoto:
67
+ def loads(self, *_args, **_kwargs):
68
+ raise RuntimeError("fallo interno, no es una cookie inválida")
69
+
70
+ monkeypatch.setattr(ses, "_serializer", lambda: SerializadorRoto())
71
+ with pytest.raises(RuntimeError):
72
+ ses.leer_sesion("cualquier-token")