From ec069a922bfc3bfbcbe28106ca3e20ec0e7d2731 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 12:10:58 +0000 Subject: [PATCH] Reject incomplete JSON numbers instead of coercing them via strtod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit json_parse treated 1., 1e, 1e+, and -.5 as valid because the tokenizer consumed the incomplete token and strtod parsed a prefix (1e → 1.0). Require digits after a decimal point or exponent marker, and require strtod to consume the whole token. Co-authored-by: Tyler Reckart --- CHANGELOG.md | 6 +++ CMakeLists.txt | 13 +++++++ src/json.cpp | 30 +++++++++++---- tests/test_json_numbers.cpp | 74 +++++++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 tests/test_json_numbers.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index dae12b43..ec0db5a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). ## [Unreleased] +### Fixed +- **JSON numbers require a complete fraction and exponent.** `json_parse` + no longer accepts `1.`, `1e`, `1e+`, or `-.5`. The tokenizer consumed + those incomplete tokens and `strtod` parsed a prefix (`"1e"` → 1.0), + so MCP / A2A / HTTP bodies silently coerced malformed numbers. + ## [0.13.4] — 2026-09-10 ### Fixed diff --git a/CMakeLists.txt b/CMakeLists.txt index ee7a9662..1b0ae434 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1712,6 +1712,19 @@ if(INDEX_BUILD_TESTS) ) add_test(NAME unit_remote_connect COMMAND unit_remote_connect) + # JSON number grammar (incomplete frac/exp). Separate from #342's + # unit_json (surrogate pairs in parse_string) so the two PRs do not + # share tests/test_json.cpp or the CMake insert after unit_logger. + add_executable(unit_json_numbers + tests/test_json_numbers.cpp + src/json.cpp + ) + target_include_directories(unit_json_numbers PRIVATE + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_SOURCE_DIR}/third_party/doctest + ) + add_test(NAME unit_json_numbers COMMAND unit_json_numbers) + # tui_design.cpp includes themes_data.h — wire every unit target that # compiles it to the embed generator (same header as the main binary). foreach(_t diff --git a/src/json.cpp b/src/json.cpp index 32f9d0d3..57c9aef4 100644 --- a/src/json.cpp +++ b/src/json.cpp @@ -232,15 +232,31 @@ struct Parser { std::shared_ptr parse_number() { size_t start = pos; if (peek() == '-') advance(); - while (pos < src.size() && std::isdigit(static_cast(peek()))) advance(); + // RFC 8259: number = [ minus ] int [ frac ] [ exp ] + // int = 1*DIGIT (leading zeros remain accepted — existing) + // frac = decimal-point 1*DIGIT + // exp = e [ minus / plus ] 1*DIGIT + // The previous tokenizer consumed `1.` / `1e` / `1e+` / `-.5`. + // strtod then parsed a prefix (`"1e"` → 1.0, end at `'e'`) so + // the document was accepted. Fail closed: every present frac + // or exp must include at least one digit, and strtod must + // consume the whole token. + auto is_digit = [&]() { + return pos < src.size() && + std::isdigit(static_cast(peek())); + }; + if (!is_digit()) error("invalid number"); + while (is_digit()) advance(); if (peek() == '.') { advance(); - while (pos < src.size() && std::isdigit(static_cast(peek()))) advance(); + if (!is_digit()) error("invalid number"); + while (is_digit()) advance(); } if (peek() == 'e' || peek() == 'E') { advance(); if (peek() == '+' || peek() == '-') advance(); - while (pos < src.size() && std::isdigit(static_cast(peek()))) advance(); + if (!is_digit()) error("invalid number"); + while (is_digit()) advance(); } // std::from_chars(double) isn't implemented in AppleClang's // libc++ (Xcode 15.x ships only the integral overloads, with @@ -254,14 +270,14 @@ struct Parser { // // Copy the digit run into a small std::string so strtod can // walk a null-terminated buffer without us mutating the - // source. pre-validation by the loop above guarantees the - // substring is well-formed; the only error we have to detect - // here is overflow via ERANGE. + // source. The loops above make the substring JSON-shaped; + // ERANGE still catches overflow, and a full-consume check + // rejects any leftover that a looser libc might leave. std::string num_str(src.data() + start, pos - start); errno = 0; char* end_ptr = nullptr; double val = std::strtod(num_str.c_str(), &end_ptr); - if (end_ptr == num_str.c_str() || errno == ERANGE) { + if (end_ptr != num_str.c_str() + num_str.size() || errno == ERANGE) { error("invalid number"); } return jnum(val); diff --git a/tests/test_json_numbers.cpp b/tests/test_json_numbers.cpp new file mode 100644 index 00000000..5e8a2387 --- /dev/null +++ b/tests/test_json_numbers.cpp @@ -0,0 +1,74 @@ +// tests/test_json_numbers.cpp — RFC 8259 number tokenizer. +// +// Distinct from #342 (UTF-16 surrogate pairs in parse_string). This +// pins frac/exp completeness: incomplete tokens like `1.` / `1e` were +// accepted because strtod parses a prefix of the tokenizer's capture. + +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include "doctest.h" + +#include "json.h" + +#include +#include + +using namespace arbiter; + +namespace { + +double parse_num(const char* s) { + auto v = json_parse(s); + REQUIRE(v); + REQUIRE(v->is_number()); + return v->as_number(); +} + +void require_throws(const char* s) { + CHECK_THROWS_AS(json_parse(s), std::runtime_error); +} + +} // namespace + +TEST_CASE("json_parse accepts well-formed JSON numbers") { + CHECK(parse_num("0") == 0.0); + CHECK(parse_num("-0") == 0.0); + CHECK(parse_num("1") == 1.0); + CHECK(parse_num("-42") == -42.0); + CHECK(parse_num("1.0") == 1.0); + CHECK(parse_num("0.5") == 0.5); + CHECK(parse_num("-0.25") == -0.25); + CHECK(parse_num("1e2") == 100.0); + CHECK(parse_num("1E2") == 100.0); + CHECK(parse_num("1e+2") == 100.0); + CHECK(parse_num("1e-2") == 0.01); + CHECK(parse_num("1.5e1") == 15.0); + CHECK(parse_num("6.02e23") == 6.02e23); +} + +TEST_CASE("json_parse rejects incomplete fraction and exponent") { + require_throws("1."); + require_throws("-1."); + require_throws("1e"); + require_throws("1E"); + require_throws("1e+"); + require_throws("1e-"); + require_throws("1.e2"); + require_throws("1.E+3"); + require_throws("-.5"); +} + +TEST_CASE("json_parse rejects incomplete numbers inside composites") { + require_throws("[1.,2]"); + require_throws("[1e]"); + require_throws("{\"n\":1e}"); + require_throws("{\"n\":1.}"); + // A well-formed neighbour still parses; only the incomplete token fails. + auto ok = json_parse("[1.0,2]"); + REQUIRE(ok); + REQUIRE(ok->is_array()); + CHECK(ok->as_array().size() == 2); +} + +TEST_CASE("json_parse overflow still fails closed") { + require_throws("1e9999"); +}