Módulo 4: Input & Output Sanitization

2. Input Sanitization: Fundamentos

Descripción

Cada input que llega a tu sistema AI es un vector potencial de problemas — no solo de ataques maliciosos (eso lo cubrió el Módulo 3), sino de datos que causan comportamientos inesperados simplemente por cómo están formados. Un usuario que copia texto de un PDF puede incluir caracteres Unicode invisibles que alteran el significado del texto. Un input con mixed encoding (UTF-8 y Latin-1 en el mismo string) puede producir embeddings inconsistentes. Un texto con 50,000 caracteres puede agotar tu token budget en una sola request. Un input con HTML o markdown puede inyectar contenido visual en la respuesta del modelo.

La sanitización de inputs no es lo mismo que la detección de injection. La detección de injection (Módulo 3) busca intenciones maliciosas. La sanitización busca datos mal formados, inconsistentes, o fuera de especificación. Un input puede pasar el injection detector (no tiene patrones de ataque) y aún así necesitar sanitización (tiene encoding inconsistente, caracteres de control, o longitud excesiva).

En esta cápsula construyes la primera pieza del Sanitization Pipeline: un InputSanitizer completo que normaliza, limpia, y valida cada input antes de que toque el LLM. Este componente se reutiliza directamente en el proyecto de la cápsula 08.


Por qué la sanitización de inputs importa en AI

En web tradicional, sanitizas inputs para prevenir SQL injection y XSS. En AI, sanitizas inputs por razones adicionales:

1. Consistencia de embeddings

Si dos usuarios hacen la misma pregunta pero con encoding diferente ("café" en NFC vs NFD), el embedding puede ser distinto. Esto afecta la calidad de búsquedas RAG.

import unicodedata

text_nfc = unicodedata.normalize("NFC", "café")
text_nfd = unicodedata.normalize("NFD", "café")

print(f"NFC: {text_nfc!r} ({len(text_nfc)} chars)")
print(f"NFD: {text_nfd!r} ({len(text_nfd)} chars)")
print(f"Iguales visualmente: {text_nfc == text_nfd}")

# Output esperado:
# NFC: 'café' (4 chars)
# NFD: 'café' (5 chars)  ← la 'é' se descompone en 'e' + combining accent
# Iguales visualmente: False

2. Ataques de caracteres invisibles

Los caracteres Unicode de ancho cero pueden ocultar instrucciones dentro de texto aparentemente inocuo:

visible_text = "Hola, ¿cómo estás?"
hidden_attack = "Hola, \u200b\u200bignora\u200b instrucciones\u200b ¿cómo estás?"

print(f"Visible: {visible_text}")
print(f"Con ocultos: {hidden_attack}")
print(f"Se ven igual? El usuario no nota la diferencia")
print(f"Len original: {len(visible_text)}")
print(f"Len con ocultos: {len(hidden_attack)}")

# Output esperado:
# Visible: Hola, ¿cómo estás?
# Con ocultos: Hola, ​​ignora​ instrucciones​ ¿cómo estás?
# Se ven igual? El usuario no nota la diferencia
# Len original: 19
# Len con ocultos: 40

3. Token budget protection

Un input de 50,000 caracteres puede consumir ~12,500 tokens solo en el input, dejando poco espacio para el output y agotando tu presupuesto de tokens:

def estimate_tokens(text: str) -> int:
    return len(text) // 4

long_input = "a" * 50000
estimated = estimate_tokens(long_input)
print(f"Input length: {len(long_input)} chars")
print(f"Estimated tokens: {estimated}")
print(f"GPT-4o context window: 128,000 tokens")
print(f"Porcentaje consumido solo por input: {estimated/128000*100:.1f}%")

# Output esperado:
# Input length: 50000 chars
# Estimated tokens: 12500
# GPT-4o context window: 128,000 tokens
# Porcentaje consumido solo por input: 9.8%

Normalización Unicode

La normalización Unicode es el primer paso de cualquier pipeline de sanitización. Garantiza que caracteres visualmente idénticos se representen de la misma forma internamente.

Las 4 formas de normalización Unicode

import unicodedata

text = "café résumé naïve"

forms = {
    "NFC": unicodedata.normalize("NFC", text),
    "NFD": unicodedata.normalize("NFD", text),
    "NFKC": unicodedata.normalize("NFKC", text),
    "NFKD": unicodedata.normalize("NFKD", text),
}

for form_name, normalized in forms.items():
    print(f"{form_name}: {normalized!r} ({len(normalized)} chars)")

# Output esperado:
# NFC:  'café résumé naïve' (18 chars)  ← Compuesto canónico
# NFD:  'café résumé naïve' (24 chars)  ← Descompuesto canónico
# NFKC: 'café résumé naïve' (18 chars)  ← Compuesto compatible
# NFKD: 'café résumé naïve' (24 chars)  ← Descompuesto compatible

¿Cuál usar? Para sanitización de inputs en sistemas AI, usa NFKC:

  • K (Kompatibility): Convierte caracteres "compatibles" a sus equivalentes canónicos. Por ejemplo, (ligatura) → fi, 1
  • C (Composed): Mantiene caracteres acentuados como una sola unidad. é se queda como un solo codepoint, no como e + acento combinante
import unicodedata

tricky_inputs = [
    ("file", "Ligatura fi"),
    ("①②③", "Números circulados"),
    ("Hello", "Fullwidth chars"),
    ("𝐇𝐞𝐥𝐥𝐨", "Math bold"),
    ("ℌ𝔢𝔩𝔩𝔬", "Fraktur"),
]

for text, description in tricky_inputs:
    normalized = unicodedata.normalize("NFKC", text)
    print(f"{description}:")
    print(f"  Original:   {text!r}")
    print(f"  Normalized: {normalized!r}")
    print()

# Output esperado:
# Ligatura fi:
#   Original:   'file'
#   Normalized: 'file'
#
# Números circulados:
#   Original:   '①②③'
#   Normalized: '123'
#
# Fullwidth chars:
#   Original:   'Hello'
#   Normalized: 'Hello'
#
# Math bold:
#   Original:   '𝐇𝐞𝐥𝐥𝐨'
#   Normalized: 'Hello'
#
# Fraktur:
#   Original:   'ℌ𝔢𝔩𝔩𝔬'
#   Normalized: 'Hello'

Los atacantes usan estas variantes Unicode para bypassear filtros de texto. Tu injection detector del Módulo 3 podría buscar "ignore" pero no detectar "𝐢𝐠𝐧𝐨𝐫𝐞" (math bold). La normalización NFKC resuelve eso antes de que el detector lo vea.


Eliminación de caracteres peligrosos

Después de normalizar Unicode, elimina caracteres que no deberían estar en un input de texto normal.

Caracteres de control y ancho cero

import re

DANGEROUS_CHARS = {
    "\u200b": "Zero Width Space",
    "\u200c": "Zero Width Non-Joiner",
    "\u200d": "Zero Width Joiner",
    "\u200e": "Left-to-Right Mark",
    "\u200f": "Right-to-Left Mark",
    "\u202a": "Left-to-Right Embedding",
    "\u202b": "Right-to-Left Embedding",
    "\u202c": "Pop Directional Formatting",
    "\u202d": "Left-to-Right Override",
    "\u202e": "Right-to-Left Override",
    "\u2060": "Word Joiner",
    "\u2061": "Function Application",
    "\u2062": "Invisible Times",
    "\u2063": "Invisible Separator",
    "\u2064": "Invisible Plus",
    "\ufeff": "BOM / Zero Width No-Break Space",
}


def remove_dangerous_chars(text: str) -> tuple[str, list[str]]:
    """Elimina caracteres peligrosos y reporta cuáles encontró."""
    found = []
    cleaned = text
    for char, name in DANGEROUS_CHARS.items():
        if char in cleaned:
            count = cleaned.count(char)
            found.append(f"{name} (x{count})")
            cleaned = cleaned.replace(char, "")

    control_pattern = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
    control_matches = control_pattern.findall(cleaned)
    if control_matches:
        found.append(f"Control chars (x{len(control_matches)})")
        cleaned = control_pattern.sub("", cleaned)

    return cleaned, found


test_input = "Hola\u200b, ¿cómo\u200d estás?\x00\x07"
cleaned, issues = remove_dangerous_chars(test_input)

print(f"Original: {test_input!r}")
print(f"Cleaned:  {cleaned!r}")
print(f"Issues:   {issues}")

# Output esperado:
# Original: 'Hola\u200b, ¿cómo\u200d estás?\x00\x07'
# Cleaned:  'Hola, ¿cómo estás?'
# Issues:   ['Zero Width Space (x1)', 'Zero Width Joiner (x1)', 'Control chars (x2)']

Character Whitelisting vs Blacklisting

Blacklisting: bloquea lo conocido como malo

BLACKLISTED_PATTERNS = [
    r"<script",
    r"javascript:",
    r"on\w+=",
    r"<iframe",
    r"<object",
    r"data:text/html",
]

def blacklist_check(text: str) -> tuple[bool, list[str]]:
    """Retorna (es_seguro, patrones_encontrados)."""
    import re
    found = []
    text_lower = text.lower()
    for pattern in BLACKLISTED_PATTERNS:
        if re.search(pattern, text_lower):
            found.append(pattern)
    return len(found) == 0, found

# Ejemplo
test1 = "¿Cuánto cuesta el producto X?"
test2 = "Mira esto: <script>alert('xss')</script>"

safe1, issues1 = blacklist_check(test1)
safe2, issues2 = blacklist_check(test2)
print(f"'{test1[:40]}...' -> Safe: {safe1}")
print(f"'{test2[:40]}...' -> Safe: {safe2}, Issues: {issues2}")

# Output esperado:
# '¿Cuánto cuesta el producto X?...' -> Safe: True
# 'Mira esto: <script>alert('xss')</script...' -> Safe: False, Issues: ['<script']

Problema del blacklisting: Solo bloquea lo que conoces. Un nuevo ataque que no está en tu lista pasa sin problemas.

Whitelisting: permite solo lo conocido como bueno

import re
from enum import Enum


class InputProfile(Enum):
    CHAT = "chat"
    CODE = "code"
    SEARCH = "search"


WHITELIST_PROFILES = {
    InputProfile.CHAT: {
        "pattern": r"^[\w\s\.,;:!¡?¿\-\(\)\"\'áéíóúüñÁÉÍÓÚÜÑ@#\n]+$",
        "description": "Texto conversacional: letras, números, puntuación básica",
        "max_length": 2000,
    },
    InputProfile.CODE: {
        "pattern": r"^[\w\s\.,;:!?¡¿\-\(\)\[\]\{\}\"\'`<>/\\=\+\*&|^~#@$%\n]+$",
        "description": "Código: incluye caracteres de programación",
        "max_length": 5000,
    },
    InputProfile.SEARCH: {
        "pattern": r"^[\w\s\.,\-\"\'áéíóúüñÁÉÍÓÚÜÑ]+$",
        "description": "Búsqueda: solo texto y puntuación mínima",
        "max_length": 500,
    },
}


def whitelist_check(text: str, profile: InputProfile) -> dict:
    config = WHITELIST_PROFILES[profile]
    result = {
        "valid": True,
        "issues": [],
        "profile": profile.value,
    }

    if len(text) > config["max_length"]:
        result["valid"] = False
        result["issues"].append(
            f"Exceeds max length: {len(text)} > {config['max_length']}"
        )

    if not re.match(config["pattern"], text, re.UNICODE):
        result["valid"] = False
        invalid_chars = set()
        for char in text:
            if not re.match(config["pattern"], char, re.UNICODE):
                invalid_chars.add(repr(char))
        result["issues"].append(
            f"Invalid chars: {', '.join(list(invalid_chars)[:5])}"
        )

    return result


print(whitelist_check("¿Cuánto cuesta?", InputProfile.CHAT))
print(whitelist_check("def foo(): return 42", InputProfile.CODE))
print(whitelist_check("<script>alert(1)</script>", InputProfile.SEARCH))

# Output esperado:
# {'valid': True, 'issues': [], 'profile': 'chat'}
# {'valid': True, 'issues': [], 'profile': 'code'}
# {'valid': False, 'issues': ['Invalid chars: ...'], 'profile': 'search'}

Recomendación: Usa whitelisting para endpoints con inputs predecibles (búsqueda, formularios). Usa blacklisting como capa adicional para endpoints de texto libre (chat). La combinación de ambos es ideal.


Length Limits y Token Budget

Los length limits no son solo seguridad — son gestión de recursos. Cada token cuesta dinero y consume context window.

from pydantic import BaseModel, Field, field_validator


class InputLimits(BaseModel):
    """Configuración de límites por endpoint."""
    endpoint: str
    max_chars: int
    max_estimated_tokens: int
    max_lines: int
    trim_strategy: str = "reject"


ENDPOINT_LIMITS = {
    "/chat": InputLimits(
        endpoint="/chat",
        max_chars=4000,
        max_estimated_tokens=1000,
        max_lines=50,
        trim_strategy="truncate_with_notice",
    ),
    "/search": InputLimits(
        endpoint="/search",
        max_chars=500,
        max_estimated_tokens=125,
        max_lines=1,
        trim_strategy="reject",
    ),
    "/summarize": InputLimits(
        endpoint="/summarize",
        max_chars=20000,
        max_estimated_tokens=5000,
        max_lines=500,
        trim_strategy="truncate_silent",
    ),
}


def enforce_limits(text: str, endpoint: str) -> dict:
    limits = ENDPOINT_LIMITS.get(endpoint)
    if not limits:
        return {"error": f"Unknown endpoint: {endpoint}"}

    result = {
        "original_length": len(text),
        "original_lines": text.count("\n") + 1,
        "estimated_tokens": len(text) // 4,
        "passed": True,
        "action": None,
        "text": text,
    }

    violations = []
    if len(text) > limits.max_chars:
        violations.append(f"chars: {len(text)} > {limits.max_chars}")
    if result["estimated_tokens"] > limits.max_estimated_tokens:
        violations.append(
            f"tokens: {result['estimated_tokens']} > {limits.max_estimated_tokens}"
        )
    if result["original_lines"] > limits.max_lines:
        violations.append(
            f"lines: {result['original_lines']} > {limits.max_lines}"
        )

    if violations:
        result["passed"] = False
        result["violations"] = violations

        if limits.trim_strategy == "reject":
            result["action"] = "rejected"
            result["text"] = None
        elif limits.trim_strategy == "truncate_with_notice":
            result["action"] = "truncated"
            result["text"] = text[:limits.max_chars]
            result["notice"] = (
                f"Input truncado de {len(text)} a {limits.max_chars} caracteres"
            )
        elif limits.trim_strategy == "truncate_silent":
            result["action"] = "truncated_silent"
            result["text"] = text[:limits.max_chars]

    return result


print(enforce_limits("¿Cuánto cuesta el iPhone?", "/search"))
print(enforce_limits("a" * 1000, "/search"))

# Output esperado:
# {'original_length': 25, 'original_lines': 1, 'estimated_tokens': 6,
#  'passed': True, 'action': None, 'text': '¿Cuánto cuesta el iPhone?'}
# {'original_length': 1000, 'original_lines': 1, 'estimated_tokens': 250,
#  'passed': False, 'action': 'rejected', 'text': None, ...}

HTML y Markdown Stripping

Los LLMs pueden interpretar HTML y markdown dentro del input de maneras inesperadas. Eliminar estas marcas previene inyección visual y de formato.

import re
try:
    import bleach
    HAS_BLEACH = True
except ImportError:
    HAS_BLEACH = False


def strip_html(text: str) -> str:
    """Elimina tags HTML del input."""
    if HAS_BLEACH:
        return bleach.clean(text, tags=[], strip=True)
    return re.sub(r"<[^>]+>", "", text)


def strip_markdown_formatting(text: str) -> str:
    """Elimina formato markdown preservando el texto."""
    cleaned = text
    cleaned = re.sub(r"!\[([^\]]*)\]\([^\)]+\)", r"\1", cleaned)
    cleaned = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", cleaned)
    cleaned = re.sub(r"#{1,6}\s*", "", cleaned)
    cleaned = re.sub(r"\*\*(.+?)\*\*", r"\1", cleaned)
    cleaned = re.sub(r"\*(.+?)\*", r"\1", cleaned)
    cleaned = re.sub(r"__(.+?)__", r"\1", cleaned)
    cleaned = re.sub(r"_(.+?)_", r"\1", cleaned)
    cleaned = re.sub(r"~~(.+?)~~", r"\1", cleaned)
    cleaned = re.sub(r"`{3}[\s\S]*?`{3}", "[code block removed]", cleaned)
    cleaned = re.sub(r"`(.+?)`", r"\1", cleaned)
    return cleaned


html_input = '<div>Hola <script>alert("xss")</script> mundo</div>'
md_input = "# Título\n**Negrita** y [link](http://evil.com) y `código`"

print(f"HTML original: {html_input}")
print(f"HTML stripped:  {strip_html(html_input)}")
print()
print(f"MD original: {md_input}")
print(f"MD stripped:  {strip_markdown_formatting(md_input)}")

# Output esperado:
# HTML original: <div>Hola <script>alert("xss")</script> mundo</div>
# HTML stripped:  Hola alert("xss") mundo
#
# MD original: # Título
# **Negrita** y [link](http://evil.com) y `código`
# MD stripped:  Título
# Negrita y link y código

Multi-language Input Handling

Los sistemas AI reciben inputs en múltiples idiomas. La sanitización debe manejar scripts diferentes sin romperlos.

import unicodedata


def detect_scripts(text: str) -> dict[str, int]:
    """Detecta los scripts Unicode presentes en el texto."""
    scripts: dict[str, int] = {}
    for char in text:
        if char.isspace() or unicodedata.category(char).startswith("P"):
            continue
        try:
            script = unicodedata.name(char, "UNKNOWN").split()[0]
        except ValueError:
            script = "UNKNOWN"
        scripts[script] = scripts.get(script, 0) + 1
    return scripts


def check_script_mixing(text: str, max_scripts: int = 2) -> dict:
    """Detecta mezcla sospechosa de scripts Unicode."""
    scripts = detect_scripts(text)
    letter_scripts = {
        k: v for k, v in scripts.items()
        if k not in ("DIGIT", "UNKNOWN")
    }

    result = {
        "scripts_found": letter_scripts,
        "num_scripts": len(letter_scripts),
        "suspicious": len(letter_scripts) > max_scripts,
    }

    if result["suspicious"]:
        result["warning"] = (
            f"Input mezcla {len(letter_scripts)} scripts: "
            f"{', '.join(letter_scripts.keys())}. "
            f"Máximo permitido: {max_scripts}"
        )
    return result


print(check_script_mixing("Hello world"))
print(check_script_mixing("Hola mundo こんにちは"))
print(check_script_mixing("Hello мир 你好 مرحبا"))

# Output esperado:
# {'scripts_found': {'LATIN': 10}, 'num_scripts': 1, 'suspicious': False}
# {'scripts_found': {'LATIN': 9, 'HIRAGANA': 5}, 'num_scripts': 2, 'suspicious': False}
# {'scripts_found': {'LATIN': 5, 'CYRILLIC': 3, 'CJK': 2, 'ARABIC': 5},
#  'num_scripts': 4, 'suspicious': True, 'warning': 'Input mezcla 4 scripts...'}

La mezcla de scripts puede ser legítima (un developer preguntando sobre un API en japonés) o puede ser un ataque de homoglyph (usar cirílico 'а' que se ve igual que latino 'a' para bypassear filtros). El nivel de tolerancia depende de tu contexto.


Normalización de whitespace

El whitespace excesivo o inconsistente puede afectar tokenización y costos:

import re


def normalize_whitespace(text: str) -> str:
    """Normaliza whitespace preservando estructura básica."""
    cleaned = text.replace("\t", "    ")
    cleaned = re.sub(r" {2,}", " ", cleaned)
    cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
    cleaned = re.sub(r"[ \t]+\n", "\n", cleaned)
    cleaned = cleaned.strip()
    return cleaned


messy_input = """   Hola    mundo   

  
  
  ¿Cómo    estás?   
  
  
  
  Bien, gracias.   """

cleaned = normalize_whitespace(messy_input)
print(f"Original ({len(messy_input)} chars):")
print(repr(messy_input[:100]))
print(f"\nCleaned ({len(cleaned)} chars):")
print(repr(cleaned))

# Output esperado:
# Original (88 chars):
# '   Hola    mundo   \n\n  \n  \n  ¿Cómo    estás?   \n  \n  \n  \n  Bien, gracias.   '
#
# Cleaned (42 chars):
# 'Hola mundo\n\n¿Cómo estás?\n\nBien, gracias.'

InputSanitizer: la clase completa

Ahora integramos todo en una clase InputSanitizer reutilizable que aplica cada paso en orden:

import re
import unicodedata
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional

try:
    import bleach
    HAS_BLEACH = True
except ImportError:
    HAS_BLEACH = False


class SanitizationAction(Enum):
    PASS = "pass"
    CLEANED = "cleaned"
    TRUNCATED = "truncated"
    REJECTED = "rejected"


@dataclass
class SanitizationResult:
    original: str
    sanitized: Optional[str]
    action: SanitizationAction
    issues: list[str] = field(default_factory=list)
    metrics: dict = field(default_factory=dict)

    @property
    def passed(self) -> bool:
        return self.action != SanitizationAction.REJECTED


class InputSanitizer:
    ZERO_WIDTH_CHARS = set(
        "\u200b\u200c\u200d\u200e\u200f"
        "\u202a\u202b\u202c\u202d\u202e"
        "\u2060\u2061\u2062\u2063\u2064"
        "\ufeff"
    )

    CONTROL_CHAR_PATTERN = re.compile(
        r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]"
    )

    HTML_TAG_PATTERN = re.compile(r"<[^>]+>")

    def __init__(
        self,
        max_length: int = 4000,
        max_lines: int = 50,
        normalize_unicode: bool = True,
        strip_html: bool = True,
        strip_markdown: bool = False,
        remove_zero_width: bool = True,
        normalize_whitespace: bool = True,
        max_scripts: int = 3,
        on_overlength: str = "truncate",
    ):
        self.max_length = max_length
        self.max_lines = max_lines
        self.normalize_unicode = normalize_unicode
        self.strip_html = strip_html
        self.strip_markdown = strip_markdown
        self.remove_zero_width = remove_zero_width
        self.normalize_ws = normalize_whitespace
        self.max_scripts = max_scripts
        self.on_overlength = on_overlength

    def sanitize(self, text: str) -> SanitizationResult:
        if not text or not text.strip():
            return SanitizationResult(
                original=text,
                sanitized=None,
                action=SanitizationAction.REJECTED,
                issues=["Empty or whitespace-only input"],
            )

        issues: list[str] = []
        cleaned = text
        metrics = {"original_length": len(text)}

        # Step 1: Unicode normalization
        if self.normalize_unicode:
            cleaned = unicodedata.normalize("NFKC", cleaned)
            if cleaned != text:
                issues.append("Unicode normalized (NFKC)")

        # Step 2: Remove zero-width characters
        if self.remove_zero_width:
            zw_count = sum(1 for c in cleaned if c in self.ZERO_WIDTH_CHARS)
            if zw_count > 0:
                cleaned = "".join(
                    c for c in cleaned if c not in self.ZERO_WIDTH_CHARS
                )
                issues.append(f"Removed {zw_count} zero-width characters")

        # Step 3: Remove control characters
        control_matches = self.CONTROL_CHAR_PATTERN.findall(cleaned)
        if control_matches:
            cleaned = self.CONTROL_CHAR_PATTERN.sub("", cleaned)
            issues.append(
                f"Removed {len(control_matches)} control characters"
            )

        # Step 4: Strip HTML
        if self.strip_html:
            html_tags = self.HTML_TAG_PATTERN.findall(cleaned)
            if html_tags:
                if HAS_BLEACH:
                    cleaned = bleach.clean(cleaned, tags=[], strip=True)
                else:
                    cleaned = self.HTML_TAG_PATTERN.sub("", cleaned)
                issues.append(f"Stripped {len(html_tags)} HTML tags")

        # Step 5: Strip markdown (optional)
        if self.strip_markdown:
            before = cleaned
            cleaned = self._strip_markdown(cleaned)
            if cleaned != before:
                issues.append("Stripped markdown formatting")

        # Step 6: Normalize whitespace
        if self.normalize_ws:
            before_len = len(cleaned)
            cleaned = self._normalize_whitespace(cleaned)
            diff = before_len - len(cleaned)
            if diff > 0:
                issues.append(f"Normalized whitespace (saved {diff} chars)")

        # Step 7: Length limits
        metrics["cleaned_length"] = len(cleaned)
        metrics["estimated_tokens"] = len(cleaned) // 4
        metrics["line_count"] = cleaned.count("\n") + 1

        if len(cleaned) > self.max_length:
            if self.on_overlength == "reject":
                return SanitizationResult(
                    original=text,
                    sanitized=None,
                    action=SanitizationAction.REJECTED,
                    issues=[
                        f"Exceeds max length: "
                        f"{len(cleaned)} > {self.max_length}"
                    ],
                    metrics=metrics,
                )
            elif self.on_overlength == "truncate":
                cleaned = cleaned[:self.max_length]
                issues.append(
                    f"Truncated from {metrics['cleaned_length']} "
                    f"to {self.max_length} chars"
                )
                metrics["truncated"] = True

        action = (
            SanitizationAction.CLEANED if issues
            else SanitizationAction.PASS
        )
        if metrics.get("truncated"):
            action = SanitizationAction.TRUNCATED

        return SanitizationResult(
            original=text,
            sanitized=cleaned,
            action=action,
            issues=issues,
            metrics=metrics,
        )

    def _strip_markdown(self, text: str) -> str:
        cleaned = text
        cleaned = re.sub(r"!\[([^\]]*)\]\([^\)]+\)", r"\1", cleaned)
        cleaned = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", cleaned)
        cleaned = re.sub(r"#{1,6}\s*", "", cleaned)
        cleaned = re.sub(r"\*\*(.+?)\*\*", r"\1", cleaned)
        cleaned = re.sub(r"__(.+?)__", r"\1", cleaned)
        cleaned = re.sub(r"\*(.+?)\*", r"\1", cleaned)
        cleaned = re.sub(r"_(.+?)_", r"\1", cleaned)
        cleaned = re.sub(r"~~(.+?)~~", r"\1", cleaned)
        cleaned = re.sub(r"`{3}[\s\S]*?`{3}", "[code removed]", cleaned)
        cleaned = re.sub(r"`(.+?)`", r"\1", cleaned)
        return cleaned

    def _normalize_whitespace(self, text: str) -> str:
        cleaned = text.replace("\t", "    ")
        cleaned = re.sub(r" {2,}", " ", cleaned)
        cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
        cleaned = re.sub(r"[ \t]+\n", "\n", cleaned)
        return cleaned.strip()


# --- Demostración ---

sanitizer = InputSanitizer(max_length=2000, strip_html=True)

test_cases = [
    "¿Cuánto cuesta el iPhone 15?",
    "Hola\u200b mundo\u200d con\ufeff chars ocultos",
    '<script>alert("xss")</script> ¿Precio del laptop?',
    "a" * 5000,
    "   ",
    "Hello file café",
]

for test in test_cases:
    result = sanitizer.sanitize(test)
    print(f"Input:  {test[:60]!r}{'...' if len(test) > 60 else ''}")
    print(f"Action: {result.action.value}")
    if result.sanitized:
        print(f"Output: {result.sanitized[:60]!r}")
    if result.issues:
        print(f"Issues: {result.issues}")
    print()

# Output esperado:
# Input:  '¿Cuánto cuesta el iPhone 15?'
# Action: pass
# Output: '¿Cuánto cuesta el iPhone 15?'
#
# Input:  'Hola\u200b mundo\u200d con\ufeff chars ocultos'
# Action: cleaned
# Output: 'Hola mundo con chars ocultos'
# Issues: ['Removed 3 zero-width characters']
#
# Input:  '<script>alert("xss")</script> ¿Precio del laptop?'
# Action: cleaned
# Output: 'alert("xss") ¿Precio del laptop?'
# Issues: ['Stripped 2 HTML tags']
#
# Input:  'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'...
# Action: truncated
# Output: 'aaa...' (truncated to 2000)
# Issues: ['Truncated from 5000 to 2000 chars']
#
# Input:  '   '
# Action: rejected
# Issues: ['Empty or whitespace-only input']
#
# Input:  'Hello file café'
# Action: cleaned
# Output: 'Hello file café'
# Issues: ['Unicode normalized (NFKC)']

Integración con el Sanitization Pipeline

El InputSanitizer es la primera pieza del pipeline que construirás en la cápsula 08:

sanitizer_chat = InputSanitizer(
    max_length=4000,
    max_lines=50,
    strip_html=True,
    strip_markdown=False,
    on_overlength="truncate",
)

sanitizer_search = InputSanitizer(
    max_length=500,
    max_lines=1,
    strip_html=True,
    strip_markdown=True,
    on_overlength="reject",
)

sanitizer_summarize = InputSanitizer(
    max_length=20000,
    max_lines=500,
    strip_html=True,
    strip_markdown=False,
    on_overlength="truncate",
)

SANITIZERS = {
    "/chat": sanitizer_chat,
    "/search": sanitizer_search,
    "/summarize": sanitizer_summarize,
}

En el proyecto, cada endpoint usa una configuración diferente. Esto te permite ser agresivo en búsqueda (inputs cortos, solo texto) y permisivo en resumen (inputs largos, formato preservado).


Troubleshooting

Problema 1: "La normalización Unicode rompe emojis"

Los emojis con Zero Width Joiner (ZWJ) como 👨‍💻 (hombre + ZWJ + computadora) se pueden romper si eliminas todos los ZWJ.

Solución: Preserva ZWJ dentro de secuencias de emoji. Detecta si el ZWJ está entre codepoints de emoji antes de eliminarlo:

import unicodedata

def is_emoji_context(text: str, index: int) -> bool:
    if index <= 0 or index >= len(text) - 1:
        return False
    prev_cat = unicodedata.category(text[index - 1])
    next_cat = unicodedata.category(text[index + 1])
    return prev_cat == "So" or next_cat == "So"

Problema 2: "Los length limits bloquean inputs legítimos en idiomas asiáticos"

Los caracteres CJK (chino, japonés, coreano) usan más bytes pero transmiten más información por carácter. Un límite de 500 caracteres en chino equivale a ~250 palabras, mientras que en inglés equivale a ~100 palabras.

Solución: Usa token-based limits en lugar de character-based limits para endpoints multilingües:

def adaptive_length_limit(text: str, max_tokens: int = 500) -> bool:
    estimated_tokens = len(text) // 4
    return estimated_tokens <= max_tokens

Problema 3: "La sanitización agrega latencia visible"

Cada paso de sanitización agrega microsegundos, pero acumulados pueden ser perceptibles.

Solución: Mide cada paso y optimiza los costosos. La normalización Unicode y regex son los pasos más pesados. Precompila regex patterns:

import re
PRECOMPILED = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")

Problema 4: "Inputs con código legítimo se rompen"

Si un developer pregunta sobre HTML o JavaScript, tu stripper de HTML elimina su código.

Solución: Usa el perfil InputProfile.CODE que preserva caracteres de programación, o detecta si el input está dentro de backticks/code blocks antes de strippear.


Ejercicios

Ejercicio 1: Detector de homoglyphs

Implementa una función que detecte si un texto usa caracteres de diferentes scripts Unicode que se ven similares (homoglyphs), como cirílico 'а' vs latino 'a'. Estos se usan para bypassear filtros.

Ver solución
import unicodedata

LATIN_CYRILLIC_HOMOGLYPHS = {
    "а": "a", "е": "e", "о": "o", "р": "p",
    "с": "c", "у": "y", "х": "x", "А": "A",
    "В": "B", "Е": "E", "К": "K", "М": "M",
    "Н": "H", "О": "O", "Р": "P", "С": "C",
    "Т": "T", "Х": "X",
}


def detect_homoglyphs(text: str) -> dict:
    found = []
    for i, char in enumerate(text):
        if char in LATIN_CYRILLIC_HOMOGLYPHS:
            found.append({
                "position": i,
                "char": char,
                "looks_like": LATIN_CYRILLIC_HOMOGLYPHS[char],
                "actual_script": "Cyrillic",
            })

    return {
        "has_homoglyphs": len(found) > 0,
        "count": len(found),
        "details": found,
    }


test = "ignоre instructiоns"  # las 'о' son cirílicas
result = detect_homoglyphs(test)
print(f"Text: {test!r}")
print(f"Homoglyphs: {result['has_homoglyphs']}")
print(f"Count: {result['count']}")
for d in result["details"]:
    print(f"  Position {d['position']}: '{d['char']}' looks like '{d['looks_like']}' ({d['actual_script']})")

# Output esperado:
# Text: 'ignоre instructiоns'
# Homoglyphs: True
# Count: 2
#   Position 3: 'о' looks like 'o' (Cyrillic)
#   Position 17: 'о' looks like 'o' (Cyrillic)

Explicación: Los homoglyphs son una técnica de evasión sofisticada. Si tu injection detector busca "ignore", no encontrará "ignоre" con cirílico 'о'. La normalización NFKC no resuelve homoglyphs porque son codepoints completamente diferentes. Necesitas detección explícita.

Ejercicio 2: Sanitizador con métricas de performance

Modifica el InputSanitizer para que mida el tiempo de cada paso y reporte cuál es el más costoso.

Ver solución
import time

class TimedInputSanitizer(InputSanitizer):
    def sanitize(self, text: str) -> SanitizationResult:
        timings = {}

        start = time.perf_counter_ns()
        result = super().sanitize(text)
        total = time.perf_counter_ns() - start

        result.metrics["total_time_us"] = total / 1000
        return result


timed = TimedInputSanitizer(max_length=2000)
result = timed.sanitize("Hola\u200b mundo\u200d " + "a" * 3000)
print(f"Total time: {result.metrics.get('total_time_us', 0):.1f} µs")
print(f"Action: {result.action.value}")

# Output esperado:
# Total time: ~50-200 µs (varía según hardware)
# Action: truncated

Explicación: Medir el performance de cada paso te permite identificar cuellos de botella. En producción con miles de requests, una sanitización que toma 1ms se vuelve significativa.

Ejercicio 3: Detector de encoding mixto

Implementa una función que detecte si un texto tiene encoding inconsistente (por ejemplo, mezcla de UTF-8 y Latin-1 artifacts como é en lugar de é).

Ver solución
MOJIBAKE_PATTERNS = [
    ("á", "á"), ("é", "é"), ("í", "í"),
    ("ó", "ó"), ("ú", "ú"), ("ñ", "ñ"),
    ("ü", "ü"), ("¿", "¿"), ("¡", "¡"),
]

def detect_and_fix_mojibake(text: str) -> dict:
    fixes = []
    fixed = text
    for broken, correct in MOJIBAKE_PATTERNS:
        if broken in fixed:
            count = fixed.count(broken)
            fixes.append(f"{broken!r}{correct!r} (x{count})")
            fixed = fixed.replace(broken, correct)

    return {
        "had_mojibake": len(fixes) > 0,
        "fixes": fixes,
        "original": text,
        "fixed": fixed,
    }

test = "Cómo estás? ¿Qué pasa?"
result = detect_and_fix_mojibake(test)
print(f"Original: {result['original']}")
print(f"Fixed:    {result['fixed']}")
print(f"Fixes:    {result['fixes']}")

# Output esperado:
# Original: Cómo estás? ¿Qué pasa?
# Fixed:    Cómo estás? ¿Qué pasa?
# Fixes:    ["'ó' → 'ó' (x1)", "'á' → 'á' (x1)", "'¿' → '¿' (x1)"]

Explicación: Los mojibake (encoding artifacts) son comunes cuando usuarios copian texto de PDFs, emails, o páginas web con encoding incorrecto. Detectar y corregir estos patrones mejora la calidad del input sin rechazarlo.

Ejercicio 4: InputSanitizer con configuración por archivo

Crea una versión del InputSanitizer que cargue su configuración desde un diccionario (simulando un archivo de configuración).

Ver solución
SANITIZER_CONFIGS = {
    "strict": {
        "max_length": 500,
        "max_lines": 5,
        "strip_html": True,
        "strip_markdown": True,
        "on_overlength": "reject",
    },
    "moderate": {
        "max_length": 4000,
        "max_lines": 50,
        "strip_html": True,
        "strip_markdown": False,
        "on_overlength": "truncate",
    },
    "permissive": {
        "max_length": 20000,
        "max_lines": 500,
        "strip_html": False,
        "strip_markdown": False,
        "on_overlength": "truncate",
    },
}


def create_sanitizer(profile: str) -> InputSanitizer:
    config = SANITIZER_CONFIGS.get(profile)
    if not config:
        raise ValueError(f"Unknown profile: {profile}. Options: {list(SANITIZER_CONFIGS.keys())}")
    return InputSanitizer(**config)


strict = create_sanitizer("strict")
moderate = create_sanitizer("moderate")

test = "Hello <b>world</b>! " * 100
print(f"Strict:   {strict.sanitize(test).action.value}")
print(f"Moderate: {moderate.sanitize(test).action.value}")

# Output esperado:
# Strict:   rejected
# Moderate: truncated

Explicación: En producción, quieres cambiar la configuración de sanitización sin redesplegar código. Los perfiles por archivo te permiten ajustar umbrales de forma dinámica o por endpoint.

Ejercicio 5: Sanitizador con audit log

Agrega un sistema de audit logging al InputSanitizer que registre cada sanitización con timestamp, acción, e issues encontrados.

Ver solución
import json
from datetime import datetime, timezone


class AuditedSanitizer:
    def __init__(self, sanitizer: InputSanitizer):
        self.sanitizer = sanitizer
        self.audit_log: list[dict] = []

    def sanitize(self, text: str, request_id: str = "unknown") -> SanitizationResult:
        result = self.sanitizer.sanitize(text)

        entry = {
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "request_id": request_id,
            "action": result.action.value,
            "issues": result.issues,
            "original_length": len(text),
            "sanitized_length": len(result.sanitized) if result.sanitized else 0,
        }
        self.audit_log.append(entry)
        return result

    def get_stats(self) -> dict:
        if not self.audit_log:
            return {"total": 0}
        actions = [e["action"] for e in self.audit_log]
        return {
            "total": len(actions),
            "passed": actions.count("pass"),
            "cleaned": actions.count("cleaned"),
            "truncated": actions.count("truncated"),
            "rejected": actions.count("rejected"),
        }


audited = AuditedSanitizer(InputSanitizer(max_length=100))
audited.sanitize("Hello world", "req-001")
audited.sanitize("<b>Bold</b> text", "req-002")
audited.sanitize("a" * 200, "req-003")
audited.sanitize("   ", "req-004")

print(json.dumps(audited.get_stats(), indent=2))

# Output esperado:
# {
#   "total": 4,
#   "passed": 1,
#   "cleaned": 1,
#   "truncated": 1,
#   "rejected": 1
# }

Explicación: El audit log es fundamental para calibrar tu sanitización en producción. Si el 30% de los inputs se rechazan, tus límites son demasiado estrictos. Si el 0% se limpia, podrías tener defensas insuficientes. Las métricas guían el ajuste.


Resumen

  • 🔑 La sanitización de inputs es diferente a la detección de injection: sanitización limpia datos mal formados; injection detection busca ataques intencionales
  • 🔑 Normalización NFKC es el primer paso obligatorio — convierte variantes Unicode (fullwidth, ligaduras, math symbols) a sus formas canónicas, cerrando un vector de evasión de filtros
  • 🔑 Los caracteres de ancho cero (ZWJ, ZWNJ, ZWS) pueden ocultar instrucciones invisibles dentro de texto aparentemente inocuo
  • 🔑 Whitelisting es más seguro que blacklisting — permite solo lo conocido como bueno en lugar de bloquear lo conocido como malo
  • 🔑 Los length limits no son solo seguridad — son gestión de recursos: cada carácter consume tokens que cuestan dinero y context window
  • 🔑 El InputSanitizer aplica pasos en orden: normalización Unicode → eliminación de caracteres peligrosos → strip HTML → normalización de whitespace → length limits
  • 🔑 La sanitización debe ser configurable por endpoint: un endpoint de búsqueda necesita límites estrictos, un endpoint de resumen necesita ser permisivo
  • 🔑 El trade-off central es seguridad vs usabilidad: cada restricción que agregas potencialmente bloquea inputs legítimos
  • 🔑 El audit logging es esencial para calibrar la sanitización en producción: las métricas te dicen si eres demasiado estricto o demasiado permisivo

Recursos adicionales

  1. Unicode Security Considerations (TR#36) — Reporte oficial de Unicode sobre ataques basados en propiedades Unicode, fundamental para entender por qué la normalización importa
  2. Unicode Normalization Forms (TR#15) — Especificación técnica de NFC, NFD, NFKC, NFKD con ejemplos detallados
  3. OWASP Input Validation Cheat Sheet — Guía de validación de inputs con principios aplicables a sanitización de inputs AI
  4. Bleach Documentation — Librería de sanitización HTML para Python, útil para stripping de HTML en inputs
  5. Confusable Detection (Unicode) — Algoritmo oficial para detectar homoglyphs/confusables entre scripts Unicode
  6. OWASP LLM05: Improper Output Handling — Contexto sobre por qué la sanitización de inputs complementa la validación de outputs
  7. Python unicodedata Module — Documentación oficial del módulo de Python para manipulación Unicode
  8. Invisible Characters — A Complete Reference — Referencia de caracteres Unicode invisibles con explicaciones de cada uno

Creado: Marzo 2026 Versión: 1.0