fix(security): build_vcard neutraliza el retorno de carro crudo (anti CR-injection vCard)

El escape de valores vCard solo escapaba el salto de linea, no el retorno de
carro crudo. Un \r sin \n sobrevivia al escape y los parsers que lo normalizan
a salto de linea (como _unfold_lines de osint_web) leian propiedades inyectadas
(p.ej. X-OSINT-DNI), burlando el control de no exponer datos OSINT al movil.
Ahora _vcard_escape elimina el \r, en paridad con el escape iCal. Test de
regresion anadido.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 11:19:43 +02:00
parent 40400c0b88
commit b8ec97e477
2 changed files with 34 additions and 0 deletions
+8
View File
@@ -23,9 +23,17 @@ def _vcard_escape(value: str) -> str:
Reglas: ``\\`` -> ``\\\\``, salto de linea -> ``\\n``, ``,`` -> ``\\,``,
``;`` -> ``\\;``. Se aplica al contenido de cada propiedad, NO a los
separadores estructurales del ADR.
El retorno de carro ``\\r`` crudo se ELIMINA (no se escapa): un ``\\r`` solo,
sin ``\\n`` que lo siga, sobrevive al escape de ``\\n`` y queda como carácter de
control dentro del valor. Varios parsers de vCard (y el propio ``_unfold_lines``
de osint_web, que normaliza ``\\r`` a ``\\n``) lo tratan como un separador de
línea, lo que permitiría inyectar propiedades nuevas (p. ej. ``X-OSINT-DNI``)
en la tarjeta. Eliminarlo cierra ese vector, en paridad con el escape iCal.
"""
return (
value.replace("\\", "\\\\")
.replace("\r", "")
.replace("\n", "\\n")
.replace(",", "\\,")
.replace(";", "\\;")
+26
View File
@@ -70,3 +70,29 @@ def test_claves_ingles_y_espanol_equivalentes():
def test_falta_uid_y_slug_lanza_valueerror():
with pytest.raises(ValueError):
build_vcard({"fn": "Sin identificador"})
def test_cr_crudo_no_inyecta_propiedades():
"""Un '\\r' crudo en un valor no debe poder inyectar una propiedad nueva.
Sin neutralizar el '\\r', un parser que normalice '\\r' a salto de línea (como
el _unfold_lines de osint_web) leería 'X-OSINT-DNI' / 'X-EVIL' como propiedades
legítimas, burlando el control de "no exponer X-OSINT-* al móvil". El escape
debe eliminar el '\\r' para que el valor quede en una sola línea física.
"""
vcard = build_vcard(
{
"uid": "victima",
"fn": "Bob\rX-OSINT-DNI:11111111H\rX-EVIL:pwned",
"tels": ["911\rNOTE:leak"],
}
)
# Simula el unfold de osint_web: '\r\n' y '\r' sueltos pasan a salto de línea.
physical_lines = vcard.replace("\r\n", "\n").replace("\r", "\n").split("\n")
inyectadas = [
ln for ln in physical_lines if ln.startswith(("X-OSINT-DNI", "X-EVIL", "NOTE"))
]
assert inyectadas == [], f"propiedades inyectadas via CR: {inyectadas}"
# El '\r' no debe sobrevivir en el texto serializado salvo como CRLF de línea.
assert "\rX-OSINT" not in vcard
assert "\rNOTE" not in vcard