Skip to content

Commit 7caeebe

Browse files
authored
Merge pull request #2476 from madaosik/rspeed-3398-input-sanitization
LCORE-2749: Add input sanitization to block obfuscated prompt injection
2 parents 0fe5bfb + cb1a794 commit 7caeebe

4 files changed

Lines changed: 499 additions & 0 deletions

File tree

src/constants.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,3 +390,9 @@
390390
)
391391
SAVED_PROMPTS_DEFAULT_MAX_CONTENT_LENGTH: Final[int] = 10_000
392392
SAVED_PROMPTS_MAX_CONTENT_LENGTH_UPPER_BOUND: Final[int] = 30_000
393+
394+
# Input sanitization (OFFSEC-307 / LCORE-2749)
395+
OBFUSCATION_REJECTION_MESSAGE: Final[str] = (
396+
"Your input contains characters or encoding patterns that cannot be "
397+
"processed. Please rephrase your question in plain text."
398+
)

src/utils/input_sanitization.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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+
# ---------------------------------------------------------------------------
25+
# Unicode block ranges considered obfuscation vectors
26+
# ---------------------------------------------------------------------------
27+
# Each tuple is (start, end, label) inclusive.
28+
_SUSPICIOUS_UNICODE_RANGES: list[tuple[int, int, str]] = [
29+
# Runic block — includes Elder Futhark (used in OFFSEC-307)
30+
(0x16A0, 0x16FF, "Runic"),
31+
# Mathematical Alphanumeric Symbols — bold/italic/script variants
32+
# of Latin letters that visually resemble ASCII but bypass filters
33+
(0x1D400, 0x1D7FF, "Mathematical Alphanumeric Symbols"),
34+
# Enclosed Alphanumerics (circled digits/letters)
35+
(0x2460, 0x24FF, "Enclosed Alphanumerics"),
36+
# Fullwidth Latin letters only (A-Z, a-z) — visually similar to ASCII.
37+
# Excludes fullwidth punctuation (U+FF01-FF20, U+FF3B-FF40, U+FF5B-FF5E)
38+
# which may appear in legitimate CJK-context text.
39+
(0xFF21, 0xFF3A, "Fullwidth Latin uppercase"),
40+
(0xFF41, 0xFF5A, "Fullwidth Latin lowercase"),
41+
]
42+
43+
# ---------------------------------------------------------------------------
44+
# Regex patterns for binary/hex encoding detection
45+
# ---------------------------------------------------------------------------
46+
# Binary: 8+ groups of 8 binary digits (space-separated bytes)
47+
_BINARY_PATTERN = re.compile(r"(?:[01]{8}[\s]+){3,}[01]{8}")
48+
49+
# Hex escape sequences: \x41\x42 or 0x41 0x42 patterns
50+
_HEX_ESCAPE_PATTERN = re.compile(r"(?:\\x[0-9a-fA-F]{2}){4,}")
51+
_HEX_PREFIX_PATTERN = re.compile(r"(?:0x[0-9a-fA-F]{2}[\s,]+){4,}")
52+
53+
# ---------------------------------------------------------------------------
54+
# XML/markup injection patterns (per OffSec recommendation)
55+
# ---------------------------------------------------------------------------
56+
_XML_INJECTION_PATTERN = re.compile(
57+
r"<\s*/?(?:ac:|invoke|function_call|tool_call|system|assistant)[^>]*>",
58+
re.IGNORECASE,
59+
)
60+
61+
62+
def normalize_unicode(text: str) -> str:
63+
"""Normalize text to Unicode NFC form.
64+
65+
NFC normalization ensures that composed and decomposed Unicode
66+
representations are treated identically. For example, 'é' as a
67+
single codepoint (U+00E9) and 'e' + combining accent (U+0065
68+
U+0301) are normalized to the same form.
69+
70+
Parameters:
71+
text: The input text to normalize.
72+
73+
Returns:
74+
NFC-normalized text.
75+
"""
76+
return unicodedata.normalize("NFC", text)
77+
78+
79+
def _check_suspicious_unicode(text: str) -> Optional[str]:
80+
"""Check for characters from Unicode blocks used for obfuscation.
81+
82+
Parameters:
83+
text: The input text to check.
84+
85+
Returns:
86+
Description of the detected block, or None if clean.
87+
"""
88+
for char in text:
89+
codepoint = ord(char)
90+
for start, end, label in _SUSPICIOUS_UNICODE_RANGES:
91+
if start <= codepoint <= end:
92+
return (
93+
f"Input contains characters from the {label} Unicode "
94+
f"block (U+{codepoint:04X}), which may be used to "
95+
f"obfuscate instructions."
96+
)
97+
return None
98+
99+
100+
def _check_binary_encoding(text: str) -> Optional[str]:
101+
"""Check for binary-encoded content (sequences of 0s and 1s).
102+
103+
Parameters:
104+
text: The input text to check.
105+
106+
Returns:
107+
Description if binary encoding is detected, or None if clean.
108+
"""
109+
if _BINARY_PATTERN.search(text):
110+
return "Input appears to contain binary-encoded content."
111+
return None
112+
113+
114+
def _check_hex_encoding(text: str) -> Optional[str]:
115+
"""Check for hex-encoded content (escape sequences or hex prefixes).
116+
117+
Parameters:
118+
text: The input text to check.
119+
120+
Returns:
121+
Description if hex encoding is detected, or None if clean.
122+
"""
123+
if _HEX_ESCAPE_PATTERN.search(text):
124+
return "Input appears to contain hex-encoded escape sequences."
125+
if _HEX_PREFIX_PATTERN.search(text):
126+
return "Input appears to contain hex-encoded content."
127+
return None
128+
129+
130+
def _check_xml_injection(text: str) -> Optional[str]:
131+
"""Check for XML/markup tag patterns used for tool-call injection.
132+
133+
Parameters:
134+
text: The input text to check.
135+
136+
Returns:
137+
Description if suspicious XML tags are detected, or None if clean.
138+
"""
139+
if _XML_INJECTION_PATTERN.search(text):
140+
return "Input contains suspicious XML/markup injection tags."
141+
return None
142+
143+
144+
def detect_obfuscation(text: str) -> Optional[str]:
145+
"""Check input text for obfuscation techniques.
146+
147+
Runs all detection checks and returns the first match.
148+
149+
Parameters:
150+
text: The input text to check.
151+
152+
Returns:
153+
Description of detected obfuscation, or None if the input is clean.
154+
"""
155+
checks = [
156+
_check_suspicious_unicode,
157+
_check_binary_encoding,
158+
_check_hex_encoding,
159+
_check_xml_injection,
160+
]
161+
for check in checks:
162+
result = check(text)
163+
if result is not None:
164+
return result
165+
return None
166+
167+
168+
def sanitize_input(text: str) -> tuple[str, Optional[str]]:
169+
"""Normalize and check input text for obfuscation.
170+
171+
First normalizes the text to Unicode NFC form, then runs
172+
obfuscation detection checks.
173+
174+
Parameters:
175+
text: The raw user input text.
176+
177+
Returns:
178+
Tuple of (normalized_text, rejection_reason).
179+
If rejection_reason is None, the input is clean and
180+
normalized_text should be used for further processing.
181+
If rejection_reason is not None, the input should be
182+
rejected with the given reason.
183+
"""
184+
normalized = normalize_unicode(text)
185+
rejection_reason = detect_obfuscation(normalized)
186+
187+
if rejection_reason:
188+
logger.warning(
189+
"Input rejected by sanitization: %s",
190+
rejection_reason,
191+
)
192+
193+
return normalized, rejection_reason

src/utils/shields.py

Lines changed: 17 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
@@ -8,13 +9,15 @@
89
from pydantic_ai.exceptions import AgentRunError
910

1011
from configuration import AppConfig
12+
from constants import OBFUSCATION_REJECTION_MESSAGE
1113
from log import get_logger
1214
from models.api.requests import QueryRequest
1315
from models.api.responses.error import (
1416
NotFoundResponse,
1517
UnprocessableEntityResponse,
1618
)
1719
from models.common.moderation import (
20+
ShieldModerationBlocked,
1821
ShieldModerationPassed,
1922
ShieldModerationResult,
2023
)
@@ -27,6 +30,7 @@
2730
PiiRedactionCapability,
2831
)
2932
from utils.agents.error_handler import map_agent_inference_error
33+
from utils.input_sanitization import sanitize_input
3034
from utils.otel_tracing import SpanAttributes
3135

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

0 commit comments

Comments
 (0)