[Bugfix] Reject NUL byte in structured_outputs.regex (#51796)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

Junhao Shen committed Aug 14, 2026 at 08:08 UTC 38f097fab8f6d58e3b3f57bded1d98f5b48d3f6d
3 files changed +50
tests/v1/structured_output/test_validation.py
+33
@@ -70,3 +70,36 @@ def test_degenerate_structured_outputs_rejected(structured_outputs, match):
70 StructuredOutputsConfig(),
71 tokenizer=object(),
72 )
73 +
74 +
75 +@pytest.mark.parametrize(
76 + "regex",
77 + [
78 + "\x00", # a lone leading NUL
79 + "\x00\x01\x02\x1f", # a NUL followed by other control chars
80 + "[0-9]\x00", # an embedded NUL
81 + ],
82 +)
83 +def test_regex_with_nul_byte_rejected(regex):
84 + """A NUL byte is never meaningful in a structured-outputs regex and is not
85 + handled by xgrammar's native regex converter. It must be rejected at request
86 + validation in every backend mode (a clean 400), instead of reaching that
87 + native code or silently falling back to another backend in the default
88 + 'auto' mode."""
89 + params = SamplingParams(structured_outputs=StructuredOutputsParams(regex=regex))
90 +
91 + # Rejected before backend selection, so it is a 400 even in 'auto' mode
92 + # (which would otherwise catch the error and fall back to another backend).
93 + with pytest.raises(VLLMValidationError, match="NUL"):
94 + params._validate_structured_outputs(
95 + _StubModelConfig(is_diffusion=False),
96 + StructuredOutputsConfig(),
97 + tokenizer=object(),
98 + )
99 +
100 + # The xgrammar backend also rejects it directly (defense in depth), before
101 + # the pattern reaches the native from_regex call.
102 + from vllm.v1.structured_output.backend_xgrammar import validate_xgrammar_grammar
103 +
104 + with pytest.raises(ValueError, match="NUL"):
105 + validate_xgrammar_grammar(params)
vllm/sampling_params.py
+9
@@ -1029,6 +1029,15 @@ class SamplingParams(
1029 "structured_outputs.json_object must be True if set; omit "
1030 "structured_outputs to disable structured outputs"
1031 )
1032 + # Reject a regex containing a NUL byte early, in every backend mode. A
1033 + # NUL is never meaningful in a regex pattern and is not handled by the
1034 + # regex-to-grammar conversion. Checked here, before backend selection,
1035 + # so it is a clean 400 rather than a silent fallback in the default
1036 + # "auto" mode.
1037 + if self.structured_outputs.regex and "\x00" in self.structured_outputs.regex:
1038 + raise VLLMValidationError(
1039 + "structured_outputs.regex must not contain a NUL character ('\\x00')"
1040 + )
1041
1042 from vllm.v1.structured_output.backend_guidance import (
1043 has_guidance_unsupported_json_features,
vllm/v1/structured_output/backend_xgrammar.py
+8
@@ -284,6 +284,14 @@ def validate_xgrammar_grammar(sampling_params: SamplingParams) -> None:
284 so_params = sampling_params.structured_outputs
285
286 if so_params.regex:
287 + # A NUL byte is never meaningful in a regex pattern and is not handled
288 + # by xgrammar's native regex converter. Reject it here, before the
289 + # pattern reaches that native code; the try/except below does not cover
290 + # this case.
291 + if "\x00" in so_params.regex:
292 + raise ValueError(
293 + "structured_outputs.regex must not contain a NUL character ('\\x00')"
294 + )
295 try:
296 compile_regex_with_timeout(
297 xgr.Grammar.from_regex,