Skip to content

Validate UTF-8 in CBOR/MessagePack/BSON text strings at decode time - #5531

Merged
nlohmann merged 1 commit into
bson-sizesfrom
fix/cbor-msgpack-utf8-validation
Sep 15, 2026
Merged

nlohmann merged 1 commit into
bson-sizesfrom
fix/cbor-msgpack-utf8-validation

Conversation

@nlohmann

Copy link
Copy Markdown
Owner

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 was dump()'d — so from_cbor(bytes, /*strict*/true, /*allow_exceptions*/false), the pattern used specifically to get a discarded sentinel 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.

auto got = json::from_cbor(std::vector<uint8_t>{0x62, 0xc0, 0xae}, true, false);
got.is_discarded(); // -> false (before this PR)
got.dump();          // -> throws type_error.316, the *only* place this was ever caught

Fixes #5529.

Root cause and fix

binary_reader::get_string() (include/nlohmann/detail/input/binary_reader.hpp) was a thin wrapper around get_bytes() — a raw, chunked byte copy with only an EOF check, also used (unchanged here) by get_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> in detail/output/serializer.hpp (used by dump_escaped() for type_error.316). This PR extracts that DFA (decode(), the utf8d table, UTF8_ACCEPT/UTF8_REJECT) into include/nlohmann/detail/string_utils.hpp as free functions/constants in detail::, adds a small is_valid_utf8(...) built on top, and has both serializer.hpp and binary_reader.hpp use 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 other binary_reader error is reported:

return sax->parse_error(chars_read, get_token_string(),
    parse_error::create(113, chars_read,
        exception_message(format, "invalid string: ill-formed UTF-8 byte", "string"), nullptr));

parse_error.113 already covers other "invalid string" cases for these formats, so this reuses the existing code rather than adding a new one. Going through sax->parse_error() means allow_exceptions is respected automatically (throws when true, propagates to a discarded value when false) — no new plumbing needed.

Base branch

This PR targets bson-sizes (#5185) rather than develop: that PR already touches binary_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 exact parse_error.113 message with allow_exceptions=true, is_discarded() with allow_exceptions=false, a check that the identical bytes as a binary blob (CBOR byte string / MessagePack bin8) are still accepted unvalidated, and a valid-UTF-8 (including multibyte) round-trip.
  • tests/src/unit-serialization.cpp: regression guard confirming dump()'s existing type_error.316 message/behavior is byte-for-byte unchanged after the decode() extraction.
  • Full existing suites for CBOR, MessagePack, BSON, UBJSON, BJData, Unicode, serialization, regression, diagnostics, and deserialization all pass unchanged (no new failures introduced by get_string() now validating BSON/UBJSON/BJData strings too — their existing test data was already valid UTF-8).
  • Compiled include/ and single_include/ independently with g++ -std=c++17 -Wall -Wextra -Wshadow -Wpedantic and -std=c++11 — zero warnings.
  • make amalgamate run; single_include/nlohmann/json.hpp confirmed in sync with include/.

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 on dump() — it now fails immediately with parse_error.113, consistent with every other kind of malformed binary input. allow_exceptions=false now correctly discards this input instead of returning a value that would later throw on dump(). Valid UTF-8 input and binary-blob reads are unaffected.

🤖 Generated with Claude Code

https://claude.ai/code/session_01N4RQ1Ahan5YAGbnAQGjZTY


Generated by Claude Code

…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
nlohmann force-pushed the fix/cbor-msgpack-utf8-validation branch from bcb8a26 to bbb918f Compare September 15, 2026 07:15
@nlohmann nlohmann added aspect: binary formats BSON, CBOR, MessagePack, UBJSON review needed It would be great if someone could review the proposed changes. labels Sep 15, 2026
@nlohmann nlohmann added 🚀 ready to merge Ready to merge - just waiting for CI to complete. and removed review needed It would be great if someone could review the proposed changes. labels Sep 15, 2026
@nlohmann nlohmann added this to the Release 3.13.0 milestone Sep 15, 2026
@nlohmann
nlohmann merged commit 0c4f6c3 into bson-sizes Sep 15, 2026
154 checks passed
@nlohmann
nlohmann deleted the fix/cbor-msgpack-utf8-validation branch September 15, 2026 18:45
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

aspect: binary formats BSON, CBOR, MessagePack, UBJSON documentation L 🚀 ready to merge Ready to merge - just waiting for CI to complete. tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

from_cbor()/from_msgpack() do not validate UTF-8 in text strings at decode time (only dump() does)

2 participants