diff --git a/pyrit/converter/token_smuggling/ascii_smuggler_converter.py b/pyrit/converter/token_smuggling/ascii_smuggler_converter.py index c91059d3ea..be18271662 100644 --- a/pyrit/converter/token_smuggling/ascii_smuggler_converter.py +++ b/pyrit/converter/token_smuggling/ascii_smuggler_converter.py @@ -59,26 +59,33 @@ def encode_message(self, *, message: str) -> tuple[str, str]: Returns: tuple[str, str]: A tuple with a summary of code points and the encoded message. + + Raises: + ValueError: If ``message`` contains a character outside the ASCII printable range (0x20-0x7E), + which has no Unicode Tag representation. Such characters were previously dropped while the + result still reported success; use ``SneakyBitsSmugglerConverter`` or + ``VariationSelectorSmugglerConverter`` to smuggle arbitrary Unicode losslessly. """ + invalid_chars = "".join(char for char in message if not 0x20 <= ord(char) <= 0x7E) + if invalid_chars: + raise ValueError( + f"Cannot encode {len(invalid_chars)} character(s) outside the ASCII printable range " + f"(0x20-0x7E): {invalid_chars!r}. Use SneakyBitsSmugglerConverter or " + f"VariationSelectorSmugglerConverter for lossless Unicode smuggling." + ) + encoded = "" code_points = "" - invalid_chars = "" if self.unicode_tags: encoded += chr(0xE0001) code_points += "U+E0001 " for char in message: - if 0x20 <= ord(char) <= 0x7E: - code_point = 0xE0000 + ord(char) - encoded += chr(code_point) - code_points += f"U+{code_point:X} " - else: - invalid_chars += char + code_point = 0xE0000 + ord(char) + encoded += chr(code_point) + code_points += f"U+{code_point:X} " if self.unicode_tags: encoded += chr(0xE007F) code_points += "U+E007F" - - if invalid_chars: - logger.error(f"Invalid characters detected: {invalid_chars}") return code_points, encoded def decode_message(self, *, message: str) -> str: diff --git a/tests/unit/converter/test_ascii_smuggler_converter.py b/tests/unit/converter/test_ascii_smuggler_converter.py index f5fe442862..30d48fcedb 100644 --- a/tests/unit/converter/test_ascii_smuggler_converter.py +++ b/tests/unit/converter/test_ascii_smuggler_converter.py @@ -46,3 +46,24 @@ async def test_ascii_smuggler_input_not_supported(): converter = AsciiSmugglerConverter(action="encode") with pytest.raises(ValueError, match="Input type not supported"): await converter.convert_async(prompt="test", input_type="image_path") + + +@pytest.mark.parametrize( + "prompt", ["caf\u00e9", "\u4f60\u597d\u4e16\u754c", "na\u00efve r\u00e9sum\u00e9", "\U0001f600", "line1\nline2"] +) +async def test_ascii_smuggler_encode_unrepresentable_chars_raise(prompt: str): + # Only the ASCII printable range (0x20-0x7E) maps to Unicode tags. Characters outside it used to be + # logged and dropped while convert_async still returned a success result, so a non-ASCII (or multi-line) + # objective was smuggled as a corrupted payload and an attack could be scored against a target that was + # never sent the objective. Encoding must fail loudly instead. + converter = AsciiSmugglerConverter(action="encode") + with pytest.raises(ValueError, match="ASCII printable range"): + await converter.convert_async(prompt=prompt, input_type="text") + + +async def test_ascii_smuggler_encode_printable_boundaries_do_not_raise(): + # 0x20 (space) and 0x7E (~) are the inclusive edges of the representable range and must still encode. + converter = AsciiSmugglerConverter(action="encode") + result = await converter.convert_async(prompt=" ~", input_type="text") + assert isinstance(result, ConverterResult) + assert result.output_type == "text"