mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
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
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 #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>
* fix(grammars): add a bounded recursion depth and cover llama31 $ref cycles
Addresses the review on #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 #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 #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>
* test(grammars): make llama31 cycle fixtures valid function-call shapes
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>
---------
Signed-off-by: Tai An <antai12232931@outlook.com>
Co-authored-by: localai-org-maint-bot <bot-opensource@localaisrl.com>
This commit is contained in:
@@ -10,9 +10,25 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxSchemaDepth bounds how deeply visit may recurse into a client-supplied
|
||||
// schema. A cyclic $ref is caught by refsInProgress, but a deeply nested yet
|
||||
// acyclic schema (e.g. thousands of nested arrays/objects) could still recurse
|
||||
// until the goroutine stack is exhausted and crash the whole process. Rejecting
|
||||
// the request once this depth is exceeded turns that potential crash into an
|
||||
// ordinary per-request error. The limit is far above any realistic schema.
|
||||
const maxSchemaDepth = 256
|
||||
|
||||
type JSONSchemaConverter struct {
|
||||
propOrder map[string]int
|
||||
rules Rules
|
||||
// refsInProgress tracks the $ref targets currently on the recursion stack
|
||||
// so a self- or mutually-referential schema cannot recurse forever. It is
|
||||
// pushed before descending into a referenced schema and popped afterwards,
|
||||
// so sibling (non-cyclic) reuse of the same $ref is still allowed.
|
||||
refsInProgress map[string]bool
|
||||
// depth is the current recursion depth of visit, bounded by maxSchemaDepth
|
||||
// to guard against stack exhaustion on deeply nested acyclic schemas.
|
||||
depth int
|
||||
}
|
||||
|
||||
func NewJSONSchemaConverter(propOrder string) *JSONSchemaConverter {
|
||||
@@ -26,8 +42,9 @@ func NewJSONSchemaConverter(propOrder string) *JSONSchemaConverter {
|
||||
rules["space"] = SPACE_RULE
|
||||
|
||||
return &JSONSchemaConverter{
|
||||
propOrder: propOrderMap,
|
||||
rules: rules,
|
||||
propOrder: propOrderMap,
|
||||
rules: rules,
|
||||
refsInProgress: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +77,11 @@ func (sc *JSONSchemaConverter) addRule(name, rule string) string {
|
||||
}
|
||||
|
||||
func (sc *JSONSchemaConverter) visit(schema map[string]any, name string, rootSchema map[string]any) (string, error) {
|
||||
sc.depth++
|
||||
defer func() { sc.depth-- }()
|
||||
if sc.depth > maxSchemaDepth {
|
||||
return "", fmt.Errorf("schema nesting exceeds maximum depth %d while building grammar", maxSchemaDepth)
|
||||
}
|
||||
st, existType := schema["type"]
|
||||
var schemaType string
|
||||
var schemaTypes []string
|
||||
@@ -115,11 +137,21 @@ func (sc *JSONSchemaConverter) visit(schema map[string]any, name string, rootSch
|
||||
rule := strings.Join(alternatives, " | ")
|
||||
return sc.addRule(ruleName, rule), nil
|
||||
} else if ref, exists := schema["$ref"].(string); exists {
|
||||
// A client-supplied schema may contain a cyclic $ref (e.g. a $def that
|
||||
// references itself directly or through a chain). Without this guard the
|
||||
// recursion below never terminates and exhausts the goroutine stack,
|
||||
// crashing the whole process rather than just failing the request.
|
||||
if sc.refsInProgress[ref] {
|
||||
return "", fmt.Errorf("cyclic $ref detected while building grammar: %s", ref)
|
||||
}
|
||||
referencedSchema, err := sc.resolveReference(ref, rootSchema)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sc.visit(referencedSchema, name, rootSchema)
|
||||
sc.refsInProgress[ref] = true
|
||||
result, err := sc.visit(referencedSchema, name, rootSchema)
|
||||
delete(sc.refsInProgress, ref)
|
||||
return result, err
|
||||
} else if constVal, exists := schema["const"]; exists {
|
||||
literal, err := sc.formatLiteral((constVal))
|
||||
if err != nil {
|
||||
|
||||
@@ -605,3 +605,73 @@ var _ = Describe("JSON schema property ordering (issue #10052)", func() {
|
||||
Expect(keyIndex(grammar, "arguments")).To(BeNumerically("<", keyIndex(grammar, "aaa_unlisted")))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("JSON schema grammar cyclic $ref handling", func() {
|
||||
// A client-supplied grammar_json_functions schema with a cyclic $ref used to
|
||||
// recurse until the goroutine stack overflowed, crashing the whole process
|
||||
// instead of failing the single request. The converter must now return an
|
||||
// error for such schemas rather than recursing forever.
|
||||
It("returns an error for a directly self-referential $ref", func() {
|
||||
const schema = `{
|
||||
"$defs": {"A": {"$ref": "#/$defs/A"}},
|
||||
"oneOf": [{"type": "object", "properties": {"x": {"$ref": "#/$defs/A"}}}]
|
||||
}`
|
||||
_, err := NewJSONSchemaConverter("").GrammarFromBytes([]byte(schema))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("cyclic $ref"))
|
||||
})
|
||||
|
||||
It("returns an error for an indirect $ref cycle (A -> B -> A)", func() {
|
||||
const schema = `{
|
||||
"$defs": {
|
||||
"A": {"type": "object", "properties": {"b": {"$ref": "#/$defs/B"}}},
|
||||
"B": {"type": "object", "properties": {"a": {"$ref": "#/$defs/A"}}}
|
||||
},
|
||||
"$ref": "#/$defs/A"
|
||||
}`
|
||||
_, err := NewJSONSchemaConverter("").GrammarFromBytes([]byte(schema))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("cyclic $ref"))
|
||||
})
|
||||
|
||||
It("still resolves a non-cyclic $ref reused by sibling properties", func() {
|
||||
const schema = `{
|
||||
"$defs": {"Leaf": {"type": "string"}},
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x": {"$ref": "#/$defs/Leaf"},
|
||||
"y": {"$ref": "#/$defs/Leaf"}
|
||||
}
|
||||
}`
|
||||
grammar, err := NewJSONSchemaConverter("").GrammarFromBytes([]byte(schema))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(grammar).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
// A deeply nested but acyclic schema has no $ref cycle to catch, yet it can
|
||||
// still recurse deeply enough to exhaust the goroutine stack. The bounded
|
||||
// depth counter must reject it with an ordinary error instead of crashing.
|
||||
It("returns an error for a schema nested deeper than the depth limit", func() {
|
||||
// Wrap a string leaf in enough nested "array"/"items" layers to exceed
|
||||
// maxSchemaDepth; each layer is one visit() recursion.
|
||||
const layers = 400
|
||||
schema := `{"type": "string"}`
|
||||
for i := 0; i < layers; i++ {
|
||||
schema = `{"type": "array", "items": ` + schema + `}`
|
||||
}
|
||||
_, err := NewJSONSchemaConverter("").GrammarFromBytes([]byte(schema))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("maximum depth"))
|
||||
})
|
||||
|
||||
It("still builds a grammar for a moderately nested acyclic schema", func() {
|
||||
const layers = 32
|
||||
schema := `{"type": "string"}`
|
||||
for i := 0; i < layers; i++ {
|
||||
schema = `{"type": "array", "items": ` + schema + `}`
|
||||
}
|
||||
grammar, err := NewJSONSchemaConverter("").GrammarFromBytes([]byte(schema))
|
||||
Expect(err).To(BeNil())
|
||||
Expect(grammar).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,14 @@ import (
|
||||
type LLama31SchemaConverter struct {
|
||||
fnName string
|
||||
rules Rules
|
||||
// refsInProgress tracks the $ref targets currently on the recursion stack so
|
||||
// a self- or mutually-referential schema cannot recurse forever, mirroring
|
||||
// the guard in JSONSchemaConverter. This is a production entry point named
|
||||
// in the same crash report (#11020).
|
||||
refsInProgress map[string]bool
|
||||
// depth is the current recursion depth of visit, bounded by maxSchemaDepth
|
||||
// to guard against stack exhaustion on deeply nested acyclic schemas.
|
||||
depth int
|
||||
}
|
||||
|
||||
func NewLLama31SchemaConverter(fnName string) *LLama31SchemaConverter {
|
||||
@@ -22,8 +30,9 @@ func NewLLama31SchemaConverter(fnName string) *LLama31SchemaConverter {
|
||||
}
|
||||
|
||||
return &LLama31SchemaConverter{
|
||||
rules: rules,
|
||||
fnName: fnName,
|
||||
rules: rules,
|
||||
fnName: fnName,
|
||||
refsInProgress: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +83,11 @@ func (sc *LLama31SchemaConverter) addRule(name, rule string) string {
|
||||
}
|
||||
|
||||
func (sc *LLama31SchemaConverter) visit(schema map[string]any, name string, rootSchema map[string]any) (string, error) {
|
||||
sc.depth++
|
||||
defer func() { sc.depth-- }()
|
||||
if sc.depth > maxSchemaDepth {
|
||||
return "", fmt.Errorf("schema nesting exceeds maximum depth %d while building grammar", maxSchemaDepth)
|
||||
}
|
||||
st, existType := schema["type"]
|
||||
var schemaType string
|
||||
if existType {
|
||||
@@ -111,11 +125,20 @@ func (sc *LLama31SchemaConverter) visit(schema map[string]any, name string, root
|
||||
rule := strings.Join(alternatives, " | ")
|
||||
return sc.addRule(ruleName, rule), nil
|
||||
} else if ref, exists := schema["$ref"].(string); exists {
|
||||
// Guard against cyclic $ref chains that would otherwise recurse until
|
||||
// the goroutine stack is exhausted, crashing the whole process rather
|
||||
// than failing the single request (#11020).
|
||||
if sc.refsInProgress[ref] {
|
||||
return "", fmt.Errorf("cyclic $ref detected while building grammar: %s", ref)
|
||||
}
|
||||
referencedSchema, err := sc.resolveReference(ref, rootSchema)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sc.visit(referencedSchema, name, rootSchema)
|
||||
sc.refsInProgress[ref] = true
|
||||
result, err := sc.visit(referencedSchema, name, rootSchema)
|
||||
delete(sc.refsInProgress, ref)
|
||||
return result, err
|
||||
} else if constVal, exists := schema["const"]; exists {
|
||||
|
||||
literal, err := sc.formatLiteral((constVal))
|
||||
|
||||
@@ -43,8 +43,8 @@ const (
|
||||
// <function=example_function_name>{{"example_name": "example_value"}}</function>
|
||||
testllama31inputResult1 = `root-0-function ::= "create_event"
|
||||
freestring ::= (
|
||||
[^"\\] |
|
||||
"\\" (["\\/bfnrt] | "u" [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F])
|
||||
[^"\] |
|
||||
"\\" (["\/bfnrt] | "u" [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F])
|
||||
)* space
|
||||
root-0 ::= "<function=" root-0-function ">{" root-0-arguments "}</function>"
|
||||
root-1-arguments ::= "{" space "\"query\"" space ":" space string "}" space
|
||||
@@ -53,8 +53,8 @@ space ::= " "?
|
||||
root-0-arguments ::= "{" space "\"date\"" space ":" space string "," space "\"time\"" space ":" space string "," space "\"title\"" space ":" space string "}" space
|
||||
root-1 ::= "<function=" root-1-function ">{" root-1-arguments "}</function>"
|
||||
string ::= "\"" (
|
||||
[^"\\] |
|
||||
"\\" (["\\/bfnrt] | "u" [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F])
|
||||
[^"\] |
|
||||
"\\" (["\/bfnrt] | "u" [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F] [0-9a-fA-F])
|
||||
)* "\"" space
|
||||
root-1-function ::= "search"`
|
||||
)
|
||||
@@ -74,3 +74,66 @@ var _ = Describe("JSON schema grammar tests", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("LLama31 schema grammar cyclic $ref and depth handling", func() {
|
||||
// LLama31SchemaConverter.visit is another production entry point named in
|
||||
// the crash report (#11020). A cyclic $ref or a deeply nested acyclic schema
|
||||
// must fail the request rather than recurse until the stack is exhausted.
|
||||
//
|
||||
// The llama31 converter expects each top-level oneOf alternative to carry
|
||||
// its configured function-name property before it descends into arguments,
|
||||
// so the fixtures below use a valid function-call shape and hang the cyclic
|
||||
// $ref under `arguments` to make sure the cycle guard is what trips.
|
||||
It("returns an error for a directly self-referential $ref", func() {
|
||||
const schema = `{
|
||||
"$defs": {"A": {"$ref": "#/$defs/A"}},
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"function": {"const": "test"},
|
||||
"arguments": {
|
||||
"type": "object",
|
||||
"properties": {"x": {"$ref": "#/$defs/A"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
_, err := NewLLama31SchemaConverter("function").GrammarFromBytes([]byte(schema))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("cyclic $ref"))
|
||||
})
|
||||
|
||||
It("returns an error for an indirect $ref cycle (A -> B -> A)", func() {
|
||||
const schema = `{
|
||||
"$defs": {
|
||||
"A": {"type": "object", "properties": {"b": {"$ref": "#/$defs/B"}}},
|
||||
"B": {"type": "object", "properties": {"a": {"$ref": "#/$defs/A"}}}
|
||||
},
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"function": {"const": "test"},
|
||||
"arguments": {"$ref": "#/$defs/A"}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
_, err := NewLLama31SchemaConverter("function").GrammarFromBytes([]byte(schema))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("cyclic $ref"))
|
||||
})
|
||||
|
||||
It("returns an error for a schema nested deeper than the depth limit", func() {
|
||||
const layers = 400
|
||||
schema := `{"type": "string"}`
|
||||
for i := 0; i < layers; i++ {
|
||||
schema = `{"type": "array", "items": ` + schema + `}`
|
||||
}
|
||||
_, err := NewLLama31SchemaConverter("").GrammarFromBytes([]byte(schema))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("maximum depth"))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user