From f08e645b9e704c94d8167452b5f9f5f54a07a01f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 04:26:56 +0900 Subject: [PATCH] fix(security): bound LLM judge JSON nesting depth Reject recursive object/array nesting deeper than 32 before json.loads to prevent untrusted judge responses from expanding into parser DoS. Add fail-closed tests, CHANGELOG fragment, and APA-linked doctoring. --- CHANGELOG.md | 1 + docs/changelog.d/764-llm-judge-json-depth.md | 6 ++++ docs/doctoring/llm_judge_json_depth_bound.md | 16 ++++++++++ python/fast_mlsirm/llm_judge.py | 25 ++++++++++++++++ tests/test_llm_judge.py | 31 ++++++++++++++++++++ 5 files changed, 79 insertions(+) create mode 100644 docs/changelog.d/764-llm-judge-json-depth.md create mode 100644 docs/doctoring/llm_judge_json_depth_bound.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d34b5535..9e0b63742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Cap LLM-judge response JSON nesting at 32 levels before parse to prevent recursive-object resource exhaustion. - Public fixed-form `assemble_test_form` delegates greedy maximum-information selection and content-feasibility look-ahead to the Rust core (`assemble_test_form_greedy`). - Public fixed-anchor `link_fixed_item_parameters` delegates affine scale/shift estimation and parameter transformation to the Rust core. - Public `observed_information` and `second_order_test` delegate Hessian assembly and eigenvalue diagnostics to the Rust core. diff --git a/docs/changelog.d/764-llm-judge-json-depth.md b/docs/changelog.d/764-llm-judge-json-depth.md new file mode 100644 index 000000000..e3ad2d6b3 --- /dev/null +++ b/docs/changelog.d/764-llm-judge-json-depth.md @@ -0,0 +1,6 @@ +### Fixed + +#### LLM judge JSON nesting depth bound + +- Cap raw LLM-judge response JSON nesting at 32 levels before `json.loads`, failing closed with `JudgeFormatError` so hostile recursive objects cannot expand into parser resource exhaustion. +- Keep valid shallow judge payloads accepted with the existing criterion/score contracts. diff --git a/docs/doctoring/llm_judge_json_depth_bound.md b/docs/doctoring/llm_judge_json_depth_bound.md new file mode 100644 index 000000000..c8f3b5a67 --- /dev/null +++ b/docs/doctoring/llm_judge_json_depth_bound.md @@ -0,0 +1,16 @@ +# LLM judge JSON nesting depth bound + +## Standard / threat model + +Open Web Application Security Project. (2021). *OWASP API security top 10 2023: API4 — unrestricted resource consumption*. OWASP Foundation. https://owasp.org/API-Security/ + +Bray, T. (Ed.). (2017). *The JavaScript Object Notation (JSON) data interchange format* (RFC 8259). Internet Engineering Task Force. https://doi.org/10.17487/RFC8259 + +## Product application + +Automated LLM-judge responses are untrusted text. Before `json.loads`, the product walks the raw response character stream, tracks object/array nesting outside string literals, and rejects nesting deeper than 32 with `JudgeFormatError`. This bounds parser stack/heap growth for recursive structures while preserving legitimate shallow score objects used by Contextual Orchestrator judges. + +## Verification + +- `tests/test_llm_judge.py::test_judge_rejects_excessive_json_nesting` +- `tests/test_llm_judge.py::test_judge_accepts_bounded_json_nesting` diff --git a/python/fast_mlsirm/llm_judge.py b/python/fast_mlsirm/llm_judge.py index c5608ddcb..3a516a14d 100644 --- a/python/fast_mlsirm/llm_judge.py +++ b/python/fast_mlsirm/llm_judge.py @@ -19,6 +19,7 @@ MAX_JUDGE_TEXT_CHARACTERS = 200_000 MAX_JUDGE_CRITERIA = 32 MAX_JUDGE_CATEGORIES = MAX_POLYTOMOUS_CATEGORIES +MAX_JUDGE_JSON_DEPTH = 32 _IDENTIFIER = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$") @@ -272,8 +273,32 @@ def _criteria(values: Iterable[JudgeCriterion | Mapping[str, Any]]) -> tuple[Jud return tuple(normalized) +def _validate_raw_json_depth(content: str) -> None: + depth = 0 + in_string = False + escaped = False + for char in content: + if in_string: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + continue + if char == '"': + in_string = True + elif char in "[{": + depth += 1 + if depth > MAX_JUDGE_JSON_DEPTH: + raise JudgeFormatError(f"judge response JSON nesting exceeds maximum depth of {MAX_JUDGE_JSON_DEPTH}") + elif char in "]}": + depth -= 1 + + def _response_object(raw: str) -> dict[str, Any]: text = raw.strip() + _validate_raw_json_depth(text) try: value = json.loads(text) except json.JSONDecodeError as exc: diff --git a/tests/test_llm_judge.py b/tests/test_llm_judge.py index 45afa6320..55df0583d 100644 --- a/tests/test_llm_judge.py +++ b/tests/test_llm_judge.py @@ -310,3 +310,34 @@ def test_judge_criteria_reject_non_contract_values_with_value_error() -> None: test_judge_criteria_reject_invalid_runtime_types() test_judge_criteria_reject_non_contract_values_with_value_error() print("ok") + +def test_judge_rejects_excessive_json_nesting() -> None: + """Deeply nested JSON cannot expand into recursive parser DoS.""" + # Nesting depth 33 exceeds MAX_JUDGE_JSON_DEPTH (32). + nested = "{" + '"k":{' * 32 + '"score": 0.8' + "}" * 32 + "}" + assert nested.count("{") == 33 + with pytest.raises(JudgeFormatError, match="nesting exceeds maximum depth"): + ContextualOrchestratorJudge(_FakeOrchestrator(nested)).judge( + task="task", + answer="answer", + criteria=CRITERIA, + ) + + +def test_judge_accepts_bounded_json_nesting() -> None: + """Nesting at the admitted depth still parses when the payload is valid.""" + # Build a valid judge payload with modest nesting under the limit. + inner = { + "score": 0.8, + "accepted": True, + "rationale": "ok", + "criterion_scores": {"task_alignment": 0.8, "factual_support": 0.8}, + } + raw = json.dumps(inner) + result = ContextualOrchestratorJudge(_FakeOrchestrator(raw)).judge( + task="task", + answer="answer", + criteria=CRITERIA, + ) + assert result.score == 0.8 +