Skip to content

reject non-integer JSON numbers where an integer is expected - #1240

Merged
facontidavide merged 3 commits into
BehaviorTree:masterfrom
aysha-afrah26:json-strict-integer
Oct 24, 2026
Merged

facontidavide merged 3 commits into
BehaviorTree:masterfrom
aysha-afrah26:json-strict-integer

Conversation

@aysha-afrah26

Copy link
Copy Markdown
Contributor

nlohmann's get<T>() for an integral T converts a JSON float with a bare static_cast, so a value outside the range of T is undefined behavior (UBSan float-cast-overflow), a fraction is truncated and an out-of-range integer wraps. Several readers apply it to external JSON: JsonExporter::fromJson picks std::vector<int> from the first array element only and then casts every element (ImportBlackboardFromJSON with {"v":[1, 1e300]}), a json:[1e300] value on a std::vector<int> port in the XML, async_delay in loadSubstitutionRuleFromJSON, and the uid/position/mode fields of the Groot2 HOOK_INSERT, BREAKPOINT_UNLOCK and HOOK_REMOVE requests. On the Groot2 server a uid of node_uid + 65536 or node_uid + 0.5 silently installs the hook on that real node, and 1e300 is UB on the server thread. UBSan reports 1e+300 is outside the range of representable values of type 'int' (json.hpp:4983) for the first three, and on master the hook request with a wrapped uid is accepted. #1186 fixed the scalar branch of fromJson; this covers the array branch and every other get<integral> call on external input in the tree (the BT_JSON_CONVERTER fields belong to user types and are left alone). The new inline JsonToInteger<T> in json_export.h accepts only JSON integers representable as T and throws RuntimeError otherwise, so a bad value takes the existing error paths; the Groot2 server already turns the exception into an error reply.

Behavior change: an integer array with a real or out-of-range element, and a fractional or out-of-range async_delay or hook uid, now throw instead of being converted. Inline header additions only, no ABI change. The new tests JsonTest.IntegerValuesAreRangeChecked, Substitution.ParserRejectsNonIntegerAsyncDelay and Groot2PublisherIntegration.HookRequestRejectsNonIntegerUid fail on master and pass with the change. Full ctest is 549/549 under ASan+UBSan on macOS, and clang-format and codespell are clean (clang-tidy-21 was not available locally).

json.get<T>() for an integral T converts a floating point number with a
plain static_cast, which is undefined behavior outside the range of T,
truncates fractions, and wraps out-of-range integers. Read those values
through a new JsonToInteger<T> helper that throws RuntimeError instead:
the elements of an integer array in JsonExporter::fromJson, the "json:"
form of convertFromString<std::vector<int>>, async_delay in
loadSubstitutionRuleFromJSON, and the uid/position/mode fields of the
Groot2 hook requests.

Behavior change: an integer array with a real or out-of-range element,
and a fractional or out-of-range async_delay or hook uid, now throw
instead of being silently converted.
@facontidavide

Copy link
Copy Markdown
Collaborator

Thanks @aysha-afrah26, nice find: [1, 2.5] loading as {1, 2}, and a Groot2 hook uid wrapping onto a different node, are both real bugs.

The one thing blocking a merge is that BT::JsonToInteger<T> becomes public API in the installed json_export.h. We keep the 4.x API stable and don't want to commit to supporting this helper. Could you move it into BT::details (or a non-installed header used by json_export.cpp, basic_types.cpp, bt_factory.cpp and the Groot2 publisher)? Have the test use it from there, or test it only through the public paths (fromJson, convertFromString<std::vector<int>>, loadSubstitutionRuleFromJSON, Groot2 HOOK_INSERT).

For the record, the behavior changes this introduces, so they can go in the release notes:

  • [1, 2.5], [1, true] and out-of-range elements in an int vector now throw instead of truncating or wrapping.
  • Whole-number floats are now rejected where an integer is expected: json:[1.0, 2.0], "async_delay": 100.0 / 1e2, and Groot2 uid/mode/position sent as 5.0. If you know whether Groot2 ever serializes these as doubles, please mention it.
  • A bad element now aborts ImportBlackboardFromJSON with an exception, after earlier keys were already written (consistent with how [1, "x"] already fails via nlohmann::type_error).

Once the helper is internal I'll re-review.

facontidavide and others added 2 commits October 24, 2026 15:35
Move the helper to BT::details: it has to live in an installed header
because the inline Monitor::from_json(Hook) uses it, but it is not meant
to become public API.

Accept a JSON float with no fractional part (100.0, [1, 2.0]) when it is
in range, using the same check as Any::cast<T>(). Only lossy values (2.5,
1e300, NaN, out of range) are rejected, so trees and Groot2 messages that
spell integers as whole floats keep loading as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@facontidavide

Copy link
Copy Markdown
Collaborator

To move this forward I pushed two commits on top of yours (thanks for enabling maintainer edits): a merge of master, and a follow-up that (1) moves the helper to BT::details::JsonToInteger (it has to stay in an installed header because the inline Monitor::from_json(Hook) uses it, but it's not public API), and (2) accepts in-range floats with no fractional part (100.0, [1, 2.0]) through the same check Any::cast<int>() uses. Only lossy values (2.5, 1e300, NaN, out of range) are rejected, so existing trees and Groot2 messages keep loading. I'll squash-merge once CI is green; you stay the commit author.

@facontidavide
facontidavide merged commit 181ac51 into BehaviorTree:master Oct 24, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants