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.
|
sirviendo y el disco se reintentará en el siguiente refresco.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
||||||
tmp = path + ".tmp"
|
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(
|
json.dump(
|
||||||
{"ctag": ctag, "items": items, "saved_at": time.time()},
|
{"ctag": ctag, "items": items, "saved_at": time.time()},
|
||||||
fh,
|
fh,
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
os.replace(tmp, path)
|
os.replace(tmp, path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
@@ -1900,9 +1905,18 @@ def _contact_body(notas: Optional[str]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _vcard_escape(value: 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 (
|
return (
|
||||||
value.replace("\\", "\\\\")
|
value.replace("\\", "\\\\")
|
||||||
|
.replace("\r", "")
|
||||||
.replace("\n", "\\n")
|
.replace("\n", "\\n")
|
||||||
.replace(",", "\\,")
|
.replace(",", "\\,")
|
||||||
.replace(";", "\\;")
|
.replace(";", "\\;")
|
||||||
@@ -2254,6 +2268,22 @@ def create_app(vault_dir: str) -> FastAPI:
|
|||||||
TrustedHostMiddleware,
|
TrustedHostMiddleware,
|
||||||
allowed_hosts=["127.0.0.1", "localhost", "testserver"],
|
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
|
app.state.vault = state
|
||||||
|
|
||||||
# -- Vault --
|
# -- Vault --
|
||||||
|
|||||||
Reference in New Issue
Block a user