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 --