From c7e2c08d14da7b1e3df9af4b7b637f7d683d41b7 Mon Sep 17 00:00:00 2001 From: Junhao Shen Date: Thu, 20 Aug 2026 06:31:14 +0800 Subject: [PATCH] fix(constrained): reject NUL bytes in grammar specs to stop an xgrammar segfault (#34679) Signed-off-by: Junhao Shen Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Baizhou Zhang Co-authored-by: kpham-sgl --- .../srt/constrained/base_grammar_backend.py | 35 +++++++++++ .../constrained/test_base_grammar_backend.py | 60 ++++++++++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/python/sglang/srt/constrained/base_grammar_backend.py b/python/sglang/srt/constrained/base_grammar_backend.py index 8fe2215fc..d6fdbf211 100644 --- a/python/sglang/srt/constrained/base_grammar_backend.py +++ b/python/sglang/srt/constrained/base_grammar_backend.py @@ -13,6 +13,7 @@ # ============================================================================== """The base class of a backend for grammar-guided constrained decoding.""" +import json import logging import time from concurrent.futures import Future, ThreadPoolExecutor @@ -158,6 +159,35 @@ class GrammarMask(NamedTuple): self.grammar.apply_vocab_mask(logits=logits, vocab_mask=self.vocab_mask) +def _grammar_key_contains_nul(key_type: str, key_string: str) -> bool: + """A NUL in a spec segfaults xgrammar's regex converter, which a JSON schema + also reaches through `pattern` (possibly escaped). Drop once the upstream fix + https://github.com/mlc-ai/xgrammar/pull/850 is in our pinned version. + """ + if "\x00" in key_string: + return True + if key_type not in ("json", "structural_tag"): + return False + try: + decoded = json.loads(key_string) + except ValueError: + # Malformed JSON: the backend's own parse reports it as a normal error. + return False + + stack = [decoded] + while stack: + node = stack.pop() + if isinstance(node, str): + if "\x00" in node: + return True + elif isinstance(node, dict): + stack.extend(node.keys()) + stack.extend(node.values()) + elif isinstance(node, list): + stack.extend(node) + return False + + class InvalidGrammarObject(BaseGrammarObject): """Represents a grammar that failed to compile, carrying the original error message.""" @@ -231,6 +261,11 @@ class BaseGrammarBackend: ) -> BaseGrammarObject: s = time.perf_counter() key_type, key_string = key + if _grammar_key_contains_nul(key_type, key_string): + logger.error(f"Rejecting {key_type} grammar containing a NUL byte") + return InvalidGrammarObject( + f"Invalid {key_type}: NUL bytes (\\u0000) are not allowed" + ) if key_type == "json": grammar = self.dispatch_json(key_string) elif key_type == "regex": diff --git a/test/registered/unit/constrained/test_base_grammar_backend.py b/test/registered/unit/constrained/test_base_grammar_backend.py index 52c4f8d29..ee3a37627 100644 --- a/test/registered/unit/constrained/test_base_grammar_backend.py +++ b/test/registered/unit/constrained/test_base_grammar_backend.py @@ -15,6 +15,7 @@ Usage: python -m pytest test_base_grammar_backend.py -v """ +import json import unittest from concurrent.futures import Future from unittest.mock import MagicMock, patch @@ -249,7 +250,7 @@ class TestCreateGrammarBackend(unittest.TestCase): backend="none", reasoning_parser=None, enable_strict_thinking=False, - **fields + **fields, ): published = { "grammar_backend": backend, @@ -466,5 +467,62 @@ class TestLlguidanceStructuralTagTriggerPairing(unittest.TestCase): self.assertNotIsInstance(result, InvalidGrammarObject) +class TestNulByteGrammarRejection(unittest.TestCase): + def setUp(self): + self.backend = BaseGrammarBackend() + + def tearDown(self): + self.backend.executor.shutdown(wait=True) + + def test_nul_payload_never_reaches_backend(self): + cases = [ + ("regex", "\x00\x01\x02\x1f"), + ("regex", "\x00"), + # a non-leading NUL truncates the pattern instead of crashing; pinned + # so the guard cannot be narrowed to startswith() + ("regex", "a\x00b"), + ("ebnf", "root ::= \x00"), + ("structural_tag", '{"triggers": ["\x00"]}'), + # escaped forms: no raw NUL byte anywhere in the key string + ("json", '{"type":"string","pattern":"\\u0000"}'), + ( + "json", + '{"type":"object","properties":' + '{"f":{"type":"string","pattern":"\\u0000x"}}}', + ), + ("structural_tag", '{"format":{"pattern":"\\u0000"}}'), + ] + for key_type, key_string in cases: + with self.subTest(key_type=key_type, key_string=repr(key_string)): + dispatch = MagicMock() + setattr(self.backend, f"dispatch_{key_type}", dispatch) + + result = self.backend._init_value_dispatch( + (key_type, key_string), False + ) + + self.assertIsInstance(result, InvalidGrammarObject) + dispatch.assert_not_called() + + def test_nul_free_payload_still_dispatches(self): + cases = [ + ("regex", "[0-9]+"), + ("regex", r"\x00"), # escaped in the pattern text, not a raw NUL byte + ("regex", "\x01\x02\x1f"), # other control bytes are not the trigger + ("json", json.dumps({"type": "string", "pattern": "[0-9]+"})), + # malformed json must reach the backend and get its normal error, + # not be swallowed by the NUL scan's decode failure + ("json", "{not valid json"), + ] + for key_type, key_string in cases: + with self.subTest(key_type=key_type, key_string=repr(key_string)): + dispatch = MagicMock(return_value=BaseGrammarObject()) + setattr(self.backend, f"dispatch_{key_type}", dispatch) + + self.backend._init_value_dispatch((key_type, key_string), False) + + dispatch.assert_called_once_with(key_string) + + if __name__ == "__main__": unittest.main()