Repository navigation
Validate UTF-8 in CBOR/MessagePack/BSON text strings at decode time - #5531
Merged
Merged
Conversation
nlohmann
force-pushed
the
fix/cbor-msgpack-utf8-validation
branch
from
September 15, 2026 07:09
abc3814 to
bcb8a26
Compare
…code time from_cbor()/from_msgpack()/from_bson() copied the raw bytes of a decoded text string into the resulting json value without any UTF-8 validation, even though RFC 8949 §3.1 (CBOR) and the MessagePack/BSON specifications all require text strings to be valid UTF-8. Malformed input only failed later, if the value was dump()'d, with a type_error.316 - so the allow_exceptions=false pattern used specifically to get a discarded sentinel instead of an exception did not discard this category of malformed input, unlike every other kind of malformed binary input this library rejects at decode time (see #5529). Fix this at the single choke point shared by BSON/CBOR/MessagePack/UBJSON string reads, binary_reader::get_string(): validate the bytes with the UTF-8 DFA right after they are read, and report failures the same way as every other binary_reader error (parse_error.113), so allow_exceptions and strict discarding behave consistently. get_binary()/binary blob reads are untouched and still accept arbitrary bytes, since only text strings are required to be UTF-8. There were two independent implementations of a UTF-8 validator: the lexer's streaming scanner, and the serializer's Hoehrmann DFA used by dump_escaped_impl(). Rather than write a third, the serializer's decode() function, its utf8d table and the UTF8_ACCEPT/UTF8_REJECT constants are extracted into detail/string_utils.hpp (a low-level header already included before both detail/input/ and detail/output/), alongside a new is_valid_utf8() helper built on the same decode() step. serializer.hpp's dump_escaped_impl() now calls the shared decode(), so there is exactly one UTF-8 validator in the codebase; dump()'s exact type_error.316 messages and byte-index reporting are unchanged (see the added regression-guard test in unit-serialization.cpp). Signed-off-by: Niels Lohmann <niels.lohmann@gmail.com> Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4RQ1Ahan5YAGbnAQGjZTY
nlohmann
force-pushed
the
fix/cbor-msgpack-utf8-validation
branch
from
September 15, 2026 07:15
bcb8a26 to
bbb918f
Compare
gregmarr
approved these changes
Sep 15, 2026
4 of 5 tasks
nlohmann
added a commit
that referenced
this pull request
Sep 23, 2026
…code time (#5531) from_cbor()/from_msgpack()/from_bson() copied the raw bytes of a decoded text string into the resulting json value without any UTF-8 validation, even though RFC 8949 §3.1 (CBOR) and the MessagePack/BSON specifications all require text strings to be valid UTF-8. Malformed input only failed later, if the value was dump()'d, with a type_error.316 - so the allow_exceptions=false pattern used specifically to get a discarded sentinel instead of an exception did not discard this category of malformed input, unlike every other kind of malformed binary input this library rejects at decode time (see #5529). Fix this at the single choke point shared by BSON/CBOR/MessagePack/UBJSON string reads, binary_reader::get_string(): validate the bytes with the UTF-8 DFA right after they are read, and report failures the same way as every other binary_reader error (parse_error.113), so allow_exceptions and strict discarding behave consistently. get_binary()/binary blob reads are untouched and still accept arbitrary bytes, since only text strings are required to be UTF-8. There were two independent implementations of a UTF-8 validator: the lexer's streaming scanner, and the serializer's Hoehrmann DFA used by dump_escaped_impl(). Rather than write a third, the serializer's decode() function, its utf8d table and the UTF8_ACCEPT/UTF8_REJECT constants are extracted into detail/string_utils.hpp (a low-level header already included before both detail/input/ and detail/output/), alongside a new is_valid_utf8() helper built on the same decode() step. serializer.hpp's dump_escaped_impl() now calls the shared decode(), so there is exactly one UTF-8 validator in the codebase; dump()'s exact type_error.316 messages and byte-index reporting are unchanged (see the added regression-guard test in unit-serialization.cpp). Claude-Session: https://claude.ai/code/session_01N4RQ1Ahan5YAGbnAQGjZTY Signed-off-by: Niels Lohmann <mail@nlohmann.me> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
nlohmann
added a commit
that referenced
this pull request
Sep 25, 2026
* 🐛 fix BSON conformance issue Signed-off-by: Niels Lohmann <mail@nlohmann.me> * 🐛 fix BSON conformance issue Signed-off-by: Niels Lohmann <mail@nlohmann.me> * 🐛 reject ill-formed UTF-8 in CBOR/MessagePack/BSON text strings at decode time (#5531) from_cbor()/from_msgpack()/from_bson() copied the raw bytes of a decoded text string into the resulting json value without any UTF-8 validation, even though RFC 8949 §3.1 (CBOR) and the MessagePack/BSON specifications all require text strings to be valid UTF-8. Malformed input only failed later, if the value was dump()'d, with a type_error.316 - so the allow_exceptions=false pattern used specifically to get a discarded sentinel instead of an exception did not discard this category of malformed input, unlike every other kind of malformed binary input this library rejects at decode time (see #5529). Fix this at the single choke point shared by BSON/CBOR/MessagePack/UBJSON string reads, binary_reader::get_string(): validate the bytes with the UTF-8 DFA right after they are read, and report failures the same way as every other binary_reader error (parse_error.113), so allow_exceptions and strict discarding behave consistently. get_binary()/binary blob reads are untouched and still accept arbitrary bytes, since only text strings are required to be UTF-8. There were two independent implementations of a UTF-8 validator: the lexer's streaming scanner, and the serializer's Hoehrmann DFA used by dump_escaped_impl(). Rather than write a third, the serializer's decode() function, its utf8d table and the UTF8_ACCEPT/UTF8_REJECT constants are extracted into detail/string_utils.hpp (a low-level header already included before both detail/input/ and detail/output/), alongside a new is_valid_utf8() helper built on the same decode() step. serializer.hpp's dump_escaped_impl() now calls the shared decode(), so there is exactly one UTF-8 validator in the codebase; dump()'s exact type_error.316 messages and byte-index reporting are unchanged (see the added regression-guard test in unit-serialization.cpp). Claude-Session: https://claude.ai/code/session_01N4RQ1Ahan5YAGbnAQGjZTY Signed-off-by: Niels Lohmann <mail@nlohmann.me> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * ⚡ validate only newly read bytes of binary-format strings get_string() validated the whole result after each call, but get_bytes() appends to it and CBOR indefinite-length strings collect all chunks in the same result, so every chunk re-validated everything read before it. An input of many small chunks took quadratic time (80000 one-byte chunks, 160 KB of input, took about 7 seconds). Only the newly read bytes are validated now, which also matches RFC 8949's requirement that every chunk is valid UTF-8 on its own. Signed-off-by: Niels Lohmann <mail@nlohmann.me> --------- Signed-off-by: Niels Lohmann <mail@nlohmann.me> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
nlohmann
added a commit
that referenced
this pull request
Oct 1, 2026
RFC 8949 and the MessagePack/BSON/UBJSON/BJData specs leave UTF-8 well-formedness checking up to the decoder, so following #5529 the binary readers are lenient by default again, as in release 3.12.0 (the reader-side check was added by #5185/#5531, not in any release); reader-side validation becomes opt-in in a follow-up PR. The writers stay strict and throw type_error.316 for ill-formed UTF-8. BON8 is unchanged, since UTF-8 lead bytes are structural there. Signed-off-by: Niels Lohmann <mail@nlohmann.me>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
from_cbor()/from_msgpack()(and, incidentally,from_bson()/from_ubjson()/from_bjdata(), all of which share the same string-reading code path) decoded text strings by copying raw bytes with no UTF-8 validation. RFC 8949 §3.1 requires CBOR text strings to be valid UTF-8 (MessagePack and BSON have the equivalent requirement for their string types), but this library only ever caught invalid UTF-8 later, if/when the resulting value wasdump()'d — sofrom_cbor(bytes, /*strict*/true, /*allow_exceptions*/false), the pattern used specifically to get adiscardedsentinel instead of an exception on malformed input, did not discard this category of malformed input, unlike every other kind of malformed binary input this library already rejects.Fixes #5529.
Root cause and fix
binary_reader::get_string()(include/nlohmann/detail/input/binary_reader.hpp) was a thin wrapper aroundget_bytes()— a raw, chunked byte copy with only an EOF check, also used (unchanged here) byget_binary()for binary-blob reads, which must stay unvalidated.get_string()is the single choke point shared by BSON/CBOR/MessagePack/UBJSON text string reads, so it's the correct place to add the check once.There was no existing shared UTF-8 validator: the lexer's own scanner is tightly coupled to its streaming state machine, and the only DFA-based, buffer-friendly validator was private to
serializer<BasicJsonType>indetail/output/serializer.hpp(used bydump_escaped()fortype_error.316). This PR extracts that DFA (decode(), theutf8dtable,UTF8_ACCEPT/UTF8_REJECT) intoinclude/nlohmann/detail/string_utils.hppas free functions/constants indetail::, adds a smallis_valid_utf8(...)built on top, and has bothserializer.hppandbinary_reader.hppuse the one shared implementation — so there's now exactly one UTF-8 DFA in the codebase instead of a second one waiting to be written.get_string()now validates the bytes it just read and, on failure, reports it exactly the way every otherbinary_readererror is reported:parse_error.113already covers other "invalid string" cases for these formats, so this reuses the existing code rather than adding a new one. Going throughsax->parse_error()meansallow_exceptionsis respected automatically (throws whentrue, propagates to a discarded value whenfalse) — no new plumbing needed.Base branch
This PR targets
bson-sizes(#5185) rather thandevelop: that PR already touchesbinary_reader.hpp's string-decoding path in the same area, so stacking avoids an unnecessary later rebase.Test plan
tests/src/unit-cbor.cpp/tests/src/unit-msgpack.cpp: new "invalid UTF-8 in string" sections — the exactparse_error.113message withallow_exceptions=true,is_discarded()withallow_exceptions=false, a check that the identical bytes as a binary blob (CBOR byte string / MessagePackbin8) are still accepted unvalidated, and a valid-UTF-8 (including multibyte) round-trip.tests/src/unit-serialization.cpp: regression guard confirmingdump()'s existingtype_error.316message/behavior is byte-for-byte unchanged after thedecode()extraction.get_string()now validating BSON/UBJSON/BJData strings too — their existing test data was already valid UTF-8).include/andsingle_include/independently withg++ -std=c++17 -Wall -Wextra -Wshadow -Wpedanticand-std=c++11— zero warnings.make amalgamaterun;single_include/nlohmann/json.hppconfirmed in sync withinclude/.Breaking change?
No breaking changes to the public API.
allow_exceptions=true(default) behavior changes only for previously-malformed input that used to silently decode and only fail later ondump()— it now fails immediately withparse_error.113, consistent with every other kind of malformed binary input.allow_exceptions=falsenow correctly discards this input instead of returning a value that would later throw ondump(). Valid UTF-8 input and binary-blob reads are unaffected.🤖 Generated with Claude Code
https://claude.ai/code/session_01N4RQ1Ahan5YAGbnAQGjZTY
Generated by Claude Code