Commit b3fa69955 for llama.cpp
commit b3fa69955ea81c3b60e77e3c1e2224c4a826ffb9
Author: Yuri Khrustalev <ykhrustalev@users.noreply.github.com>
Date: Sun Oct 25 01:46:35 2026 -0400
chat : reject JSON nested more than 128 levels before walking it (#30242)
* chat : reject JSON nested more than 128 levels before walking it
The schema builder, the jinja engine and the decision code walk request
JSON with recursion. A request nested deep enough overflows the stack of
the HTTP thread and llama-server dies (SIGBUS on macOS, SIGSEGV on Linux):
for a tool schema from a few hundred levels on macOS and about 10000 on
Linux.
Check the depth first and answer 400 past 128 levels: the chat inputs in
common_chat_templates_apply (tools, tool-call arguments,
chat_template_kwargs, json_schema), the json_schema of /completion, and the
whole /v1/systemone body. A gemma4 tool result that is too deep stays text.
* fix the root, not the call side
---------
Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
diff --git a/common/jinja/value.cpp b/common/jinja/value.cpp
index 96b9bd03d..e65c20a45 100644
--- a/common/jinja/value.cpp
+++ b/common/jinja/value.cpp
@@ -6,6 +6,7 @@
#include "json.h"
#include <sstream>
+#include <stdexcept>
#include <string>
#include <cctype>
#include <cfloat>
@@ -1439,7 +1440,10 @@ const func_builtins & value_undefined_t::get_builtins() const {
//////////////////////////////////
-static value from_json(const common_json & j, bool mark_input) {
+// the runtime walks values with recursion, a deeper input overflows the stack
+static constexpr int FROM_JSON_MAX_DEPTH = 128;
+
+static value from_json(const common_json & j, bool mark_input, int depth = 0) {
if (j.is_null()) {
return mk_val<value_none>();
} else if (j.is_boolean()) {
@@ -1454,16 +1458,18 @@ static value from_json(const common_json & j, bool mark_input) {
str->mark_input();
}
return str;
+ } else if ((j.is_array() || j.is_object()) && depth >= FROM_JSON_MAX_DEPTH) {
+ throw std::invalid_argument("JSON is nested more than " + std::to_string(FROM_JSON_MAX_DEPTH) + " levels deep");
} else if (j.is_array()) {
auto arr = mk_val<value_array>();
for (const auto & item : j) {
- arr->push_back(from_json(item, mark_input));
+ arr->push_back(from_json(item, mark_input, depth + 1));
}
return arr;
} else if (j.is_object()) {
auto obj = mk_val<value_object>();
for (auto it = j.begin(); it != j.end(); ++it) {
- obj->insert(it.key(), from_json(it.value(), mark_input));
+ obj->insert(it.key(), from_json(it.value(), mark_input, depth + 1));
}
return obj;
} else {
diff --git a/common/json-schema.cpp b/common/json-schema.cpp
index 6898840e7..e1bfa79df 100644
--- a/common/json-schema.cpp
+++ b/common/json-schema.cpp
@@ -224,7 +224,21 @@ class common_chat_schema_builder {
return node;
}
+ // the builder and the code that walks the built schema recurse once per level, a deeper schema overflows the stack
+ static constexpr int max_depth = 128;
+ int depth_ = 0;
+
common_chat_schema_ptr build_node(const common_json & schema, const std::string & path) {
+ if (depth_ >= max_depth) {
+ throw std::invalid_argument("JSON schema is nested more than " + std::to_string(max_depth) + " levels deep");
+ }
+ depth_++;
+ auto node = build_node_impl(schema, path);
+ depth_--;
+ return node;
+ }
+
+ common_chat_schema_ptr build_node_impl(const common_json & schema, const std::string & path) {
if (!schema.is_object()) {
fail(path, "schema must be an object");
}
diff --git a/tests/test-chat.cpp b/tests/test-chat.cpp
index 0de475132..10a477fa4 100644
--- a/tests/test-chat.cpp
+++ b/tests/test-chat.cpp
@@ -10,6 +10,8 @@
#include "../tools/server/server-chat.h"
#include "chat-auto-parser.h"
#include "chat.h"
+#include "json-schema-to-grammar.h"
+#include "json-schema.h"
#include "common.h"
#include "ggml.h"
#include "log.h"
@@ -8106,6 +8108,58 @@ static void test_chat_session() {
}
}
+static void test_chat_json_depth() {
+ LOG_DBG("%s\n", __func__);
+ const auto arrays = [](size_t n) {
+ return std::string(n, '[') + std::string(n, ']');
+ };
+ // a schema of n nested array types
+ const auto schema = [](size_t n) {
+ std::string s;
+ for (size_t i = 0; i < n; i++) {
+ s += R"({"type": "array", "items": )";
+ }
+ return s + R"({"type": "string"})" + std::string(n, '}');
+ };
+ const auto rejected = [](const std::function<void()> & fn) {
+ try {
+ fn();
+ } catch (const std::invalid_argument & e) {
+ return std::string(e.what()).find("nested more than 128 levels deep") != std::string::npos;
+ }
+ return false;
+ };
+
+ // the schema builder stops at 128 levels, the root schema is one of them
+ assert_equals(false, rejected([&] { common_chat_schema_from_json(json::parse(schema(127))); }));
+ assert_equals(true, rejected([&] { common_chat_schema_from_json(json::parse(schema(128))); }));
+ assert_equals(true, rejected([&] { json_schema_to_grammar(json::parse(schema(1000))); }));
+
+ // the jinja engine and the schema builder reject the inputs before they recurse too deep
+ auto tmpls = read_templates("models/templates/Qwen3.5-4B.jinja");
+ const size_t deep = 1000;
+
+ common_chat_templates_inputs tool_schema;
+ tool_schema.messages = { message_user };
+ tool_schema.tools = { { "f", "", R"({"type": "object", "properties": {"x": )" + schema(deep) + "}}" } };
+ assert_equals(true, rejected([&] { common_chat_templates_apply(tmpls.get(), tool_schema); }));
+
+ common_chat_templates_inputs tool_args;
+ tool_args.messages = { message_user, simple_assist_msg("", "", "special_function", R"({"arg1": )" + arrays(deep) + "}") };
+ tool_args.tools = { special_function_tool };
+ assert_equals(true, rejected([&] { common_chat_templates_apply(tmpls.get(), tool_args); }));
+
+ common_chat_templates_inputs kwargs;
+ kwargs.messages = { message_user };
+ kwargs.chat_template_kwargs["deep"] = arrays(deep);
+ assert_equals(true, rejected([&] { common_chat_templates_apply(tmpls.get(), kwargs); }));
+
+ common_chat_templates_inputs json_schema;
+ json_schema.messages = { message_user };
+ json_schema.json_schema = schema(deep);
+ assert_equals(true, rejected([&] { common_chat_templates_apply(tmpls.get(), json_schema); }));
+}
+
static void test_reasoning_budget_tokens_per_request() {
LOG_DBG("%s\n", __func__);
// Use Qwen3 template which has <think>...</think> reasoning markers.
@@ -8355,6 +8409,7 @@ int main(int argc, char ** argv) {
test_reasoning_effort_caps();
test_prompt_parts();
test_chat_session();
+ test_chat_json_depth();
test_reasoning_budget_tokens_per_request();
test_reasoning_budget_message_per_request();
test_template_output_peg_parsers(detailed_debug);
diff --git a/tests/test-server-component.cpp b/tests/test-server-component.cpp
index 18e0cdc2f..92eeec055 100644
--- a/tests/test-server-component.cpp
+++ b/tests/test-server-component.cpp
@@ -464,6 +464,32 @@ static void test_rerank_template(testing & t) {
}
}
+//
+// server_decision_context: the recursive functions stop at RECURSIVE_LIMIT
+//
+
+static void test_decision_depth(testing & t) {
+ const auto prepare = [](size_t depth) {
+ server_decision_context ctx;
+ ctx.type = COMMON_DECISION_TYPE_H2O_LIGHTNING; // the state goes through decision_py_dump
+ std::string error;
+ try {
+ ctx.prepare_state(json::parse(std::string(depth, '[') + std::string(depth, ']')));
+ } catch (const std::invalid_argument & e) {
+ error = e.what();
+ }
+ return error;
+ };
+ t.test("state at the limit", [&](testing & t) {
+ const std::string error = prepare(128);
+ t.assert_true("accepted: " + error, error.empty());
+ });
+ t.test("deep state", [&](testing & t) {
+ const std::string error = prepare(1000);
+ t.assert_true("rejected as nested: " + error, error.find("nested more than 128 levels deep") != std::string::npos);
+ });
+}
+
int main(int argc, char ** argv) {
testing t;
@@ -484,6 +510,7 @@ int main(int argc, char ** argv) {
t.test("server_pipe", test_server_pipe);
t.test("decision_escape", test_decision_escape);
t.test("rerank_template", test_rerank_template);
+ t.test("decision_depth", test_decision_depth);
return t.summary();
}
diff --git a/tools/server/server-decision.cpp b/tools/server/server-decision.cpp
index ce4cb4d09..5fd7585ff 100644
--- a/tools/server/server-decision.cpp
+++ b/tools/server/server-decision.cpp
@@ -23,6 +23,9 @@ static const size_t DECISION_LEV_N_RATINGS = 9;
// a longer state keeps its head and its tail
static const size_t DECISION_H2O_MAX_STATE_TOKENS = 32000;
+// the recursive functions of this file walk request JSON, a deeper value would overflow the stack
+static const int RECURSIVE_LIMIT = 128;
+
static std::string decision_meta_str(const llama_model * model, const std::string & key) {
char buf[256];
const int32_t n = llama_model_meta_val_str(model, key.c_str(), buf, sizeof(buf));
@@ -417,7 +420,10 @@ json server_decision_context::parse_state(const json & body, std::vector<raw_buf
//
// replace text in all strings of a JSON value
-static json decision_replace_text(const json & val, const std::string & search, const std::string & replace) {
+static json decision_replace_text(const json & val, const std::string & search, const std::string & replace, int depth = 0) {
+ if (depth >= RECURSIVE_LIMIT) {
+ throw std::invalid_argument("JSON is nested more than " + std::to_string(RECURSIVE_LIMIT) + " levels deep");
+ }
if (val.is_string()) {
std::string str = val.get<std::string>();
string_replace_all(str, search, replace);
@@ -426,14 +432,14 @@ static json decision_replace_text(const json & val, const std::string & search,
if (val.is_array()) {
json out = json::array();
for (const auto & item : val) {
- out.push_back(decision_replace_text(item, search, replace));
+ out.push_back(decision_replace_text(item, search, replace, depth + 1));
}
return out;
}
if (val.is_object()) {
json out = json::object();
for (const auto & [key, item] : val.items()) {
- out[key] = decision_replace_text(item, search, replace);
+ out[key] = decision_replace_text(item, search, replace, depth + 1);
}
return out;
}
@@ -441,18 +447,21 @@ static json decision_replace_text(const json & val, const std::string & search,
}
// sort the keys of all objects of a JSON value
-static json decision_sort_keys(const json & val) {
+static json decision_sort_keys(const json & val, int depth = 0) {
+ if (depth >= RECURSIVE_LIMIT) {
+ throw std::invalid_argument("JSON is nested more than " + std::to_string(RECURSIVE_LIMIT) + " levels deep");
+ }
if (val.is_array()) {
json out = json::array();
for (const auto & item : val) {
- out.push_back(decision_sort_keys(item));
+ out.push_back(decision_sort_keys(item, depth + 1));
}
return out;
}
if (val.is_object()) {
std::map<std::string, json> sorted;
for (const auto & [key, item] : val.items()) {
- sorted[key] = decision_sort_keys(item);
+ sorted[key] = decision_sort_keys(item, depth + 1);
}
json out = json::object();
for (const auto & [key, item] : sorted) {
@@ -464,7 +473,10 @@ static json decision_sort_keys(const json & val) {
}
// kev flattens a JSON value into text, the keys of an object are kept as labels (kev/api.py: render)
-static std::string decision_kev_render(const json & val, int indent = 0) {
+static std::string decision_kev_render(const json & val, int indent = 0, int depth = 0) {
+ if (depth >= RECURSIVE_LIMIT) {
+ throw std::invalid_argument("JSON is nested more than " + std::to_string(RECURSIVE_LIMIT) + " levels deep");
+ }
const std::string pad(2 * indent, ' ');
if (val.is_null()) {
return "";
@@ -478,7 +490,7 @@ static std::string decision_kev_render(const json & val, int indent = 0) {
if (val.is_array()) {
std::string out;
for (const auto & item : val) {
- const std::string text = decision_kev_render(item, indent + 1);
+ const std::string text = decision_kev_render(item, indent + 1, depth + 1);
out += (out.empty() ? "" : "\n") + pad + "- " + text.substr(std::min(text.size(), text.find_first_not_of(" \t\n\r")));
}
return out;
@@ -487,7 +499,7 @@ static std::string decision_kev_render(const json & val, int indent = 0) {
std::string out;
for (const auto & [key, item] : val.items()) {
const bool is_nested = item.is_object() || item.is_array();
- out += (out.empty() ? "" : "\n") + pad + key + (is_nested ? ":\n" : ": ") + decision_kev_render(item, is_nested ? indent + 1 : 0);
+ out += (out.empty() ? "" : "\n") + pad + key + (is_nested ? ":\n" : ": ") + decision_kev_render(item, is_nested ? indent + 1 : 0, depth + 1);
}
return out;
}
@@ -528,21 +540,24 @@ static std::string decision_kev_text(const json & val) {
}
// lfm2-d1-omni: special tokens written in the input must not be parsed as such, in keys too (d1-omni prompt.py: escape)
-static json decision_d1omni_escape(const json & val) {
+static json decision_d1omni_escape(const json & val, int depth = 0) {
+ if (depth >= RECURSIVE_LIMIT) {
+ throw std::invalid_argument("JSON is nested more than " + std::to_string(RECURSIVE_LIMIT) + " levels deep");
+ }
if (val.is_string()) {
return server_decision_escape_special_tokens(val.get<std::string>());
}
if (val.is_array()) {
json out = json::array();
for (const auto & item : val) {
- out.push_back(decision_d1omni_escape(item));
+ out.push_back(decision_d1omni_escape(item, depth + 1));
}
return out;
}
if (val.is_object()) {
json out = json::object();
for (const auto & [key, item] : val.items()) {
- out[server_decision_escape_special_tokens(key)] = decision_d1omni_escape(item);
+ out[server_decision_escape_special_tokens(key)] = decision_d1omni_escape(item, depth + 1);
}
return out;
}
@@ -669,6 +684,9 @@ static void decision_py_str(std::string & out, const std::string & s, bool ensur
// json.dumps() of Python: indent < 0 is no indent, keys stay in their order
static void decision_py_dump(std::string & out, const json & val, int indent, int level, const std::string & item_sep, const std::string & key_sep, bool ensure_ascii) {
+ if (level >= RECURSIVE_LIMIT) {
+ throw std::invalid_argument("JSON is nested more than " + std::to_string(RECURSIVE_LIMIT) + " levels deep");
+ }
const auto newline = [&](int lvl) {
return indent < 0 ? std::string() : "\n" + std::string(indent * lvl, ' ');
};