fix(security): CR-injection vCard/iCal, guard anti-CSRF y permisos 0600 de la cache PII

- _vcard_escape elimina el retorno de carro crudo: cubre tanto el vCard como
  SUMMARY/LOCATION del VEVENT (que reusan este escape), cerrando la inyeccion de
  propiedades iCal/vCard via un \r sin \n.
- Middleware que rechaza las peticiones mutantes marcadas cross-site por el
  navegador (Sec-Fetch-Site), cerrando el CSRF residual de los POST simples sin
  preflight (p.ej. /api/refresh) que el TrustedHost no filtra.
- La cache DAV en disco (.cache/*.json, contiene PII) se crea con permisos 0600
  via O_CREAT 0600 + os.chmod, sin depender del umask del proceso.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 11:19:55 +02:00
parent 6b7fa621d6
commit f5d15a9f7b
+33 -3
View File
@@ -300,14 +300,19 @@ def _write_disk_cache(path: str, ctag: str, items: list) -> None:
sirviendo y el disco se reintentará en el siguiente refresco.
"""
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
# La caché contiene PII (contactos, posibles DNIs en osint{}): se crea con
# permisos 0600 para que ningún otro usuario local pueda leerla, sin
# depender del umask del proceso.
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(
{"ctag": ctag, "items": items, "saved_at": time.time()},
fh,
ensure_ascii=False,
)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
except OSError:
pass
@@ -1900,9 +1905,18 @@ def _contact_body(notas: Optional[str]) -> str:
def _vcard_escape(value: str) -> str:
"""Escapa un valor de texto para una línea vCard (RFC 6350)."""
"""Escapa un valor de texto para una línea vCard (RFC 6350).
El retorno de carro ``\\r`` crudo se ELIMINA (no se escapa): solo, sin un
``\\n`` que lo siga, sobreviviría al escape de ``\\n`` y quedaría como carácter
de control. ``_unfold_lines`` normaliza ``\\r`` a ``\\n``, así que un ``\\r``
crudo en un valor permitiría inyectar propiedades nuevas (p. ej.
``X-OSINT-DNI``) en la tarjeta o, al reutilizarse esta función para SUMMARY/
LOCATION del VEVENT, en el VCALENDAR. Eliminarlo cierra ese vector.
"""
return (
value.replace("\\", "\\\\")
.replace("\r", "")
.replace("\n", "\\n")
.replace(",", "\\,")
.replace(";", "\\;")
@@ -2254,6 +2268,22 @@ def create_app(vault_dir: str) -> FastAPI:
TrustedHostMiddleware,
allowed_hosts=["127.0.0.1", "localhost", "testserver"],
)
# Anti-CSRF de navegador: rechaza las peticiones mutantes que el navegador
# marca como cross-site (header Sec-Fetch-Site). Cierra el hueco de las
# peticiones "simples" (POST sin preflight CORS, p.ej. /api/refresh) que el
# TrustedHost no filtra porque su Host sigue siendo 127.0.0.1. El frontend
# mismo-origen y los clientes server-to-server no envían 'cross-site'.
@app.middleware("http")
async def _reject_cross_site(request, call_next):
if request.method in ("POST", "PUT", "PATCH", "DELETE"):
if request.headers.get("sec-fetch-site") == "cross-site":
return JSONResponse(
status_code=403,
content={"status": "error", "error": "petición cross-site rechazada"},
)
return await call_next(request)
app.state.vault = state
# -- Vault --