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:
+33
-3
@@ -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 --
|
||||
|
||||
Reference in New Issue
Block a user