diff --git a/tests/v1/structured_output/test_validation.py b/tests/v1/structured_output/test_validation.py index 7ea60cc660..08be56613c 100644 --- a/tests/v1/structured_output/test_validation.py +++ b/tests/v1/structured_output/test_validation.py @@ -70,3 +70,36 @@ def test_degenerate_structured_outputs_rejected(structured_outputs, match): StructuredOutputsConfig(), tokenizer=object(), ) + + +@pytest.mark.parametrize( + "regex", + [ + "\x00", # a lone leading NUL + "\x00\x01\x02\x1f", # a NUL followed by other control chars + "[0-9]\x00", # an embedded NUL + ], +) +def test_regex_with_nul_byte_rejected(regex): + """A NUL byte is never meaningful in a structured-outputs regex and is not + handled by xgrammar's native regex converter. It must be rejected at request + validation in every backend mode (a clean 400), instead of reaching that + native code or silently falling back to another backend in the default + 'auto' mode.""" + params = SamplingParams(structured_outputs=StructuredOutputsParams(regex=regex)) + + # Rejected before backend selection, so it is a 400 even in 'auto' mode + # (which would otherwise catch the error and fall back to another backend). + with pytest.raises(VLLMValidationError, match="NUL"): + params._validate_structured_outputs( + _StubModelConfig(is_diffusion=False), + StructuredOutputsConfig(), + tokenizer=object(), + ) + + # The xgrammar backend also rejects it directly (defense in depth), before + # the pattern reaches the native from_regex call. + from vllm.v1.structured_output.backend_xgrammar import validate_xgrammar_grammar + + with pytest.raises(ValueError, match="NUL"): + validate_xgrammar_grammar(params) diff --git a/vllm/sampling_params.py b/vllm/sampling_params.py index 816df226d8..46c4046082 100644 --- a/vllm/sampling_params.py +++ b/vllm/sampling_params.py @@ -1029,6 +1029,15 @@ class SamplingParams( "structured_outputs.json_object must be True if set; omit " "structured_outputs to disable structured outputs" ) + # Reject a regex containing a NUL byte early, in every backend mode. A + # NUL is never meaningful in a regex pattern and is not handled by the + # regex-to-grammar conversion. Checked here, before backend selection, + # so it is a clean 400 rather than a silent fallback in the default + # "auto" mode. + if self.structured_outputs.regex and "\x00" in self.structured_outputs.regex: + raise VLLMValidationError( + "structured_outputs.regex must not contain a NUL character ('\\x00')" + ) from vllm.v1.structured_output.backend_guidance import ( has_guidance_unsupported_json_features, diff --git a/vllm/v1/structured_output/backend_xgrammar.py b/vllm/v1/structured_output/backend_xgrammar.py index 58b726bf72..e11b1601e1 100644 --- a/vllm/v1/structured_output/backend_xgrammar.py +++ b/vllm/v1/structured_output/backend_xgrammar.py @@ -284,6 +284,14 @@ def validate_xgrammar_grammar(sampling_params: SamplingParams) -> None: so_params = sampling_params.structured_outputs if so_params.regex: + # A NUL byte is never meaningful in a regex pattern and is not handled + # by xgrammar's native regex converter. Reject it here, before the + # pattern reaches that native code; the try/except below does not cover + # this case. + if "\x00" in so_params.regex: + raise ValueError( + "structured_outputs.regex must not contain a NUL character ('\\x00')" + ) try: compile_regex_with_timeout( xgr.Grammar.from_regex,