|
| 1 | +"""Input sanitization to detect and block obfuscated prompt injection attempts. |
| 2 | +
|
| 3 | +Addresses pentest finding OFFSEC-307 (LCORE-2749, CVSS 9.6 Critical): |
| 4 | +attackers can bypass content filters by encoding malicious instructions |
| 5 | +in unusual Unicode blocks (Elder Futhark, Mathematical Alphanumeric |
| 6 | +Symbols) or binary/hex representation. |
| 7 | +
|
| 8 | +This module provides: |
| 9 | +- Unicode NFC normalization |
| 10 | +- Detection of obfuscation techniques (unusual Unicode blocks, binary |
| 11 | + encoding, hex encoding, XML tag injection patterns) |
| 12 | +
|
| 13 | +All checks are CPU-only stdlib operations with negligible latency (< 1ms). |
| 14 | +""" |
| 15 | + |
| 16 | +import re |
| 17 | +import unicodedata |
| 18 | +from typing import Optional |
| 19 | + |
| 20 | +from log import get_logger |
| 21 | + |
| 22 | +logger = get_logger(__name__) |
| 23 | + |
| 24 | +# Rejection message shown to the user when obfuscation is detected. |
| 25 | +OBFUSCATION_REJECTION_MESSAGE = ( |
| 26 | + "Your input contains characters or encoding patterns that cannot be " |
| 27 | + "processed. Please rephrase your question in plain text." |
| 28 | +) |
| 29 | + |
| 30 | +# --------------------------------------------------------------------------- |
| 31 | +# Unicode block ranges considered obfuscation vectors |
| 32 | +# --------------------------------------------------------------------------- |
| 33 | +# Each tuple is (start, end, label) inclusive. |
| 34 | +_SUSPICIOUS_UNICODE_RANGES: list[tuple[int, int, str]] = [ |
| 35 | + # Runic block — includes Elder Futhark (used in OFFSEC-307) |
| 36 | + (0x16A0, 0x16FF, "Runic"), |
| 37 | + # Mathematical Alphanumeric Symbols — bold/italic/script variants |
| 38 | + # of Latin letters that visually resemble ASCII but bypass filters |
| 39 | + (0x1D400, 0x1D7FF, "Mathematical Alphanumeric Symbols"), |
| 40 | + # Enclosed Alphanumerics / Enclosed Alphanumeric Supplement |
| 41 | + (0x2460, 0x24FF, "Enclosed Alphanumerics"), |
| 42 | + (0x1F100, 0x1F1FF, "Enclosed Alphanumeric Supplement"), |
| 43 | + # Fullwidth Latin letters — visually similar to ASCII |
| 44 | + (0xFF01, 0xFF5E, "Fullwidth Forms"), |
| 45 | +] |
| 46 | + |
| 47 | +# --------------------------------------------------------------------------- |
| 48 | +# Regex patterns for binary/hex encoding detection |
| 49 | +# --------------------------------------------------------------------------- |
| 50 | +# Binary: 8+ groups of 8 binary digits (space-separated bytes) |
| 51 | +_BINARY_PATTERN = re.compile(r"(?:[01]{8}[\s]+){3,}[01]{8}") |
| 52 | + |
| 53 | +# Hex escape sequences: \x41\x42 or 0x41 0x42 patterns |
| 54 | +_HEX_ESCAPE_PATTERN = re.compile(r"(?:\\x[0-9a-fA-F]{2}){4,}") |
| 55 | +_HEX_PREFIX_PATTERN = re.compile(r"(?:0x[0-9a-fA-F]{2}[\s,]+){4,}") |
| 56 | + |
| 57 | +# --------------------------------------------------------------------------- |
| 58 | +# XML/markup injection patterns (per OffSec recommendation) |
| 59 | +# --------------------------------------------------------------------------- |
| 60 | +_XML_INJECTION_PATTERN = re.compile( |
| 61 | + r"<\s*/?(?:ac:|invoke|function_call|tool_call|system|assistant)[^>]*>", |
| 62 | + re.IGNORECASE, |
| 63 | +) |
| 64 | + |
| 65 | + |
| 66 | +def normalize_unicode(text: str) -> str: |
| 67 | + """Normalize text to Unicode NFC form. |
| 68 | +
|
| 69 | + NFC normalization ensures that composed and decomposed Unicode |
| 70 | + representations are treated identically. For example, 'é' as a |
| 71 | + single codepoint (U+00E9) and 'e' + combining accent (U+0065 |
| 72 | + U+0301) are normalized to the same form. |
| 73 | +
|
| 74 | + Parameters: |
| 75 | + text: The input text to normalize. |
| 76 | +
|
| 77 | + Returns: |
| 78 | + NFC-normalized text. |
| 79 | + """ |
| 80 | + return unicodedata.normalize("NFC", text) |
| 81 | + |
| 82 | + |
| 83 | +def _check_suspicious_unicode(text: str) -> Optional[str]: |
| 84 | + """Check for characters from Unicode blocks used for obfuscation. |
| 85 | +
|
| 86 | + Parameters: |
| 87 | + text: The input text to check. |
| 88 | +
|
| 89 | + Returns: |
| 90 | + Description of the detected block, or None if clean. |
| 91 | + """ |
| 92 | + for char in text: |
| 93 | + codepoint = ord(char) |
| 94 | + for start, end, label in _SUSPICIOUS_UNICODE_RANGES: |
| 95 | + if start <= codepoint <= end: |
| 96 | + return ( |
| 97 | + f"Input contains characters from the {label} Unicode " |
| 98 | + f"block (U+{codepoint:04X}), which may be used to " |
| 99 | + f"obfuscate instructions." |
| 100 | + ) |
| 101 | + return None |
| 102 | + |
| 103 | + |
| 104 | +def _check_binary_encoding(text: str) -> Optional[str]: |
| 105 | + """Check for binary-encoded content (sequences of 0s and 1s). |
| 106 | +
|
| 107 | + Parameters: |
| 108 | + text: The input text to check. |
| 109 | +
|
| 110 | + Returns: |
| 111 | + Description if binary encoding is detected, or None if clean. |
| 112 | + """ |
| 113 | + if _BINARY_PATTERN.search(text): |
| 114 | + return "Input appears to contain binary-encoded content." |
| 115 | + return None |
| 116 | + |
| 117 | + |
| 118 | +def _check_hex_encoding(text: str) -> Optional[str]: |
| 119 | + """Check for hex-encoded content (escape sequences or hex prefixes). |
| 120 | +
|
| 121 | + Parameters: |
| 122 | + text: The input text to check. |
| 123 | +
|
| 124 | + Returns: |
| 125 | + Description if hex encoding is detected, or None if clean. |
| 126 | + """ |
| 127 | + if _HEX_ESCAPE_PATTERN.search(text): |
| 128 | + return "Input appears to contain hex-encoded escape sequences." |
| 129 | + if _HEX_PREFIX_PATTERN.search(text): |
| 130 | + return "Input appears to contain hex-encoded content." |
| 131 | + return None |
| 132 | + |
| 133 | + |
| 134 | +def _check_xml_injection(text: str) -> Optional[str]: |
| 135 | + """Check for XML/markup tag patterns used for tool-call injection. |
| 136 | +
|
| 137 | + Parameters: |
| 138 | + text: The input text to check. |
| 139 | +
|
| 140 | + Returns: |
| 141 | + Description if suspicious XML tags are detected, or None if clean. |
| 142 | + """ |
| 143 | + match = _XML_INJECTION_PATTERN.search(text) |
| 144 | + if match: |
| 145 | + return ( |
| 146 | + f"Input contains suspicious XML/markup tags: " |
| 147 | + f"'{match.group()}'." |
| 148 | + ) |
| 149 | + return None |
| 150 | + |
| 151 | + |
| 152 | +def detect_obfuscation(text: str) -> Optional[str]: |
| 153 | + """Check input text for obfuscation techniques. |
| 154 | +
|
| 155 | + Runs all detection checks and returns the first match. |
| 156 | +
|
| 157 | + Parameters: |
| 158 | + text: The input text to check. |
| 159 | +
|
| 160 | + Returns: |
| 161 | + Description of detected obfuscation, or None if the input is clean. |
| 162 | + """ |
| 163 | + checks = [ |
| 164 | + _check_suspicious_unicode, |
| 165 | + _check_binary_encoding, |
| 166 | + _check_hex_encoding, |
| 167 | + _check_xml_injection, |
| 168 | + ] |
| 169 | + for check in checks: |
| 170 | + result = check(text) |
| 171 | + if result is not None: |
| 172 | + return result |
| 173 | + return None |
| 174 | + |
| 175 | + |
| 176 | +def sanitize_input(text: str) -> tuple[str, Optional[str]]: |
| 177 | + """Normalize and check input text for obfuscation. |
| 178 | +
|
| 179 | + First normalizes the text to Unicode NFC form, then runs |
| 180 | + obfuscation detection checks. |
| 181 | +
|
| 182 | + Parameters: |
| 183 | + text: The raw user input text. |
| 184 | +
|
| 185 | + Returns: |
| 186 | + Tuple of (normalized_text, rejection_reason). |
| 187 | + If rejection_reason is None, the input is clean and |
| 188 | + normalized_text should be used for further processing. |
| 189 | + If rejection_reason is not None, the input should be |
| 190 | + rejected with the given reason. |
| 191 | + """ |
| 192 | + normalized = normalize_unicode(text) |
| 193 | + rejection_reason = detect_obfuscation(normalized) |
| 194 | + |
| 195 | + if rejection_reason: |
| 196 | + logger.warning( |
| 197 | + "Input rejected by sanitization: %s", |
| 198 | + rejection_reason, |
| 199 | + ) |
| 200 | + |
| 201 | + return normalized, rejection_reason |
0 commit comments