From f5d15a9f7b1537310f14c692b88191dbb1289005 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 13 Jun 2026 11:19:55 +0200 Subject: [PATCH] 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 --- server/main.py | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/server/main.py b/server/main.py index 30843df..611acab 100644 --- a/server/main.py +++ b/server/main.py @@ -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 --