Skip to content

Commit 2894e3e

Browse files
author
Adam Lanicek
committed
Add input sanitization to block obfuscated prompt injection
Addresses pentest finding OFFSEC-307 (LCORE-2749, CVSS 9.6 Critical): attackers bypass content filters by encoding malicious instructions in unusual Unicode blocks or binary/hex representation. New module src/utils/input_sanitization.py provides: - Unicode NFC normalization on all input - Detection of obfuscation Unicode blocks (Runic/Elder Futhark, Mathematical Alphanumeric Symbols, Fullwidth Forms, Enclosed Alphanumerics) - Binary encoding detection (space-separated byte sequences) - Hex encoding detection (\x escape sequences, 0x-prefixed bytes) - XML/markup tag injection detection (<invoke>, <system>, <function_call>, <ac:>, <assistant>) Sanitization runs as the first step inside run_shield_moderation_v2() in src/utils/shields.py, before any shield evaluation. All four endpoints that use v2 moderation are automatically protected. All checks are CPU-only stdlib operations (< 1ms latency). 38 unit tests cover normalization, each detection type, false positive avoidance, and the orchestration function. RSPEED-3398
1 parent 6722d66 commit 2894e3e

3 files changed

Lines changed: 488 additions & 0 deletions

File tree

src/utils/input_sanitization.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
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

src/utils/shields.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Utility helpers for shield override validation and moderation."""
22

3+
import uuid
34
from typing import Optional
45

56
from fastapi import HTTPException
@@ -15,6 +16,7 @@
1516
UnprocessableEntityResponse,
1617
)
1718
from models.common.moderation import (
19+
ShieldModerationBlocked,
1820
ShieldModerationPassed,
1921
ShieldModerationResult,
2022
)
@@ -27,6 +29,7 @@
2729
PiiRedactionCapability,
2830
)
2931
from utils.agents.error_handler import map_agent_inference_error
32+
from utils.input_sanitization import OBFUSCATION_REJECTION_MESSAGE, sanitize_input
3033
from utils.otel_tracing import SpanAttributes
3134

3235
logger = get_logger(__name__)
@@ -86,6 +89,19 @@ async def run_shield_moderation_v2(
8689
Returns:
8790
Result indicating if content was blocked or passed.
8891
"""
92+
# Sanitize input before running any shields (OFFSEC-307 / LCORE-2749).
93+
# Normalizes Unicode and rejects obfuscated content (unusual Unicode
94+
# blocks, binary/hex encoding, XML injection patterns).
95+
normalized_text, rejection_reason = sanitize_input(input_text)
96+
if rejection_reason:
97+
logger.warning("Input blocked by sanitization: %s", rejection_reason)
98+
return ShieldModerationBlocked(
99+
decision="blocked",
100+
message=OBFUSCATION_REJECTION_MESSAGE,
101+
moderation_id=str(uuid.uuid4()),
102+
)
103+
input_text = normalized_text
104+
89105
selected_shield_configs = get_shields_for_request(
90106
shield_configs, selected_shield_ids
91107
)

0 commit comments

Comments
 (0)