fix(grammars): reject cyclic $ref in JSON-schema grammar to prevent stack-overflow crash (#11020)#11041
fix(grammars): reject cyclic $ref in JSON-schema grammar to prevent stack-overflow crash (#11020)#11041Anai-Guo wants to merge 3 commits into
Conversation
…tack-overflow crash
JSONSchemaConverter.visit resolved $ref entries by recursively calling
itself with no cycle detection. A client-supplied grammar_json_functions
schema whose $defs contains a self- or mutually-referential $ref (e.g.
{"A": {"$ref": "#/$defs/A"}}) made visit recurse until the goroutine
stack was exhausted, producing a fatal "stack overflow" that kills the
whole process rather than failing the single request. The schema is
converted synchronously in the /v1/chat/completions handler before any
backend call, so this is an unauthenticated remote crash. Fixes mudler#11020.
Track the $ref targets currently on the recursion stack and error out
when one is re-entered, while popping after each descent so sibling
(non-cyclic) reuse of the same $ref is still allowed.
Signed-off-by: Tai An <antai12232931@outlook.com>
localai-org-maint-bot
left a comment
There was a problem hiding this comment.
The stack-set approach is correct for direct and mutual cycles, and the sibling-reuse test is useful. This is not complete for the reported crash surface yet:
- There is still no hard traversal-depth limit. A deeply nested but acyclic client schema can recurse through
visituntil stack exhaustion. Please add a bounded depth counter (withdefercleanup) and a regression test using a deeply nested array/object schema. LLama31SchemaConverter.visitinpkg/functions/grammars/llama31_schema.gohas the same unbounded$refrecursion and is another production entry point named in #11020. Please apply the same active-reference and depth guards there, with shared table-driven tests covering both converters.- Please use
defer delete(sc.refsInProgress, ref)immediately after marking the ref active, so an error returned from deeper traversal cannot leave stale state if the converter is reused.
Once both converters reject cyclic and over-depth schemas while still allowing sibling ref reuse, this closes the root cause rather than only the most direct path.
…ycles Addresses the review on mudler#11041. The stack-set approach catches cyclic $ref chains, but a deeply nested yet acyclic client schema (thousands of nested arrays/objects) can still recurse through visit until the goroutine stack is exhausted, which is the same unauthenticated remote crash surface as mudler#11020. - Add a bounded depth counter to JSONSchemaConverter.visit (incremented with a defer-based cleanup, capped at maxSchemaDepth = 256, far above any realistic schema) so an over-deep schema fails the request with an ordinary error instead of crashing the process. - Apply the same cyclic-$ref guard and depth bound to LLama31SchemaConverter.visit, the other production grammar entry point named in mudler#11020, which previously had no cycle detection at all. - Regression tests: a deeply nested acyclic schema is rejected while a moderately nested one still builds, plus direct/indirect $ref cycle and depth tests for the llama31 converter. Signed-off-by: Tai An <antai12232931@outlook.com>
|
Thanks for the review � both points addressed in f28877e:
Both converters now reject cyclic and over-deep schemas with an ordinary per-request error rather than crashing the process. |
localai-org-maint-bot
left a comment
There was a problem hiding this comment.
The implementation direction now covers both converters and bounded depth, but the two new llama31 cycle specs do not exercise the cycle guard and fail locally (27/29 pass). Both stop earlier with no function name found in the schema because LLama31SchemaConverter expects each top-level oneOf alternative to have its configured function-name property before descending into arguments.
Please make each test use a valid llama31 function-call shape: construct the converter with NewLLama31SchemaConverter("function"), put "function": {"const": "test"} on the top-level alternative, and place the cyclic $ref under an arguments property. With that fixture correction, all 29 grammar specs pass and the assertions genuinely observe cyclic $ref.
I prepared that test-only correction in an isolated worktree, but did not push it because the mandatory pre-commit gate failed in unrelated existing suites (pkg/utils and core/http Agent Jobs); lint was clean, the grammar suite and vet passed, and e2e passed 94/94. I will not bypass the repository hook.
The two new llama31 $ref-cycle specs asserted on "cyclic $ref" but the
converter requires each top-level oneOf alternative to carry its
function-name property before descending, so both fixtures failed
earlier with "no function name found in the schema" and never reached
the cycle guard.
Give each fixture a valid llama31 shape: construct the converter with
NewLLama31SchemaConverter("function"), put "function": {"const": "test"}
on the top-level alternative, and hang the cyclic $ref under an
arguments property, so all 29 grammar specs pass and the assertions
genuinely observe the cyclic $ref error.
Signed-off-by: Tai An <antai12232931@outlook.com>
|
Thanks — fixed in a7688b5. You were right that both new llama31 cycle specs stopped at Both fixtures now use a valid function-call shape: constructed with |
|
@mudler The author has addressed both review rounds: both grammar converters now guard active cycles and traversal depth, the corrected llama31 fixtures reach the cycle guard, and the focused grammar suite is reported passing 29/29. This security-sensitive crash fix looks ready for maintainer re-review/merge; DCO is green. |
What / Why
JSONSchemaConverter.visit()(pkg/functions/grammars/json_schema.go) converts a JSON-Schema into a GBNF grammar for function calling and resolves$refentries by recursively calling itself with no cycle detection.POST /v1/chat/completionsaccepts a client-suppliedgrammar_json_functionsschema, which is converted synchronously in the HTTP handler before any backend call. If the schema's$defscontains a self- or mutually-referential$ref, e.g.:{ "$defs": {"A": {"$ref": "#/$defs/A"}}, "oneOf": [{"type": "object", "properties": {"x": {"$ref": "#/$defs/A"}}}] }visit()recurses into the cycle until the goroutine stack is exhausted, producing an unrecoverablefatal error: stack overflowthat kills the whole process, not just the request — an unauthenticated remote crash. Fixes #11020.Fix
Track the
$reftargets currently on the recursion stack in arefsInProgressset: push before descending into a referenced schema, pop afterwards, and return a normal error if a target is re-entered. Because the marker is popped once a subtree finishes, a schema that legitimately reuses the same$refacross sibling properties still resolves — only genuine cycles are rejected.Tests
Added
json_schema_test.gospecs:A → A) returns an error containingcyclic $refinstead of crashing;A → B → A) is likewise rejected;$refreused by two sibling properties still produces a valid grammar (no false positive).🤖 Generated with Claude Code