From 0c4f6c30346d76c0c0b0d5de67137180f23c2634 Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Tue, 15 Sep 2026 20:45:41 +0200 Subject: [PATCH] :bug: reject ill-formed UTF-8 in CBOR/MessagePack/BSON text strings at decode time (#5531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Claude Sonnet 5 --- .../docs/features/binary_formats/bson.md | 9 + .../docs/features/binary_formats/cbor.md | 10 + .../features/binary_formats/messagepack.md | 8 + docs/mkdocs/docs/home/exceptions.md | 6 +- .../nlohmann/detail/input/binary_reader.hpp | 20 +- include/nlohmann/detail/output/serializer.hpp | 59 +----- include/nlohmann/detail/string_utils.hpp | 97 ++++++++++ single_include/nlohmann/json.hpp | 179 ++++++++++++------ tests/src/unit-cbor.cpp | 21 ++ tests/src/unit-msgpack.cpp | 21 ++ tests/src/unit-serialization.cpp | 10 + 11 files changed, 321 insertions(+), 119 deletions(-) diff --git a/docs/mkdocs/docs/features/binary_formats/bson.md b/docs/mkdocs/docs/features/binary_formats/bson.md index 95c82e873..6f5603c8c 100644 --- a/docs/mkdocs/docs/features/binary_formats/bson.md +++ b/docs/mkdocs/docs/features/binary_formats/bson.md @@ -109,6 +109,15 @@ The library maps BSON record types to JSON value types as follows: If BSON input must be validated for strict specification compliance, validate it separately before passing it to `from_bson()`. +!!! warning "UTF-8 validation of string values" + + The BSON specification requires `string` values (type `0x02`) to be valid UTF-8. This library validates the + bytes of every such string at decode time and rejects ill-formed UTF-8 with a + [`parse_error.113`](../../home/exceptions.md#jsonexceptionparse_error113) exception (or, with `allow_exceptions` + set to `false`, a discarded value), rather than only failing later when the resulting value is dumped. Element + (key) names and `binary` values (type `0x05`) are unaffected and are never validated, since they are read + byte-by-byte as a C string, or are not required to hold text, respectively. + ??? example ```cpp diff --git a/docs/mkdocs/docs/features/binary_formats/cbor.md b/docs/mkdocs/docs/features/binary_formats/cbor.md index 670a23455..e4c257e27 100644 --- a/docs/mkdocs/docs/features/binary_formats/cbor.md +++ b/docs/mkdocs/docs/features/binary_formats/cbor.md @@ -176,6 +176,16 @@ The library maps CBOR types to JSON value types as follows: CBOR allows map keys of any type, whereas JSON only allows strings as keys in object values. Therefore, CBOR maps with keys other than UTF-8 strings are rejected. +!!! warning "UTF-8 validation of text strings" + + [RFC 8949, Section 3.1](https://www.rfc-editor.org/rfc/rfc8949.html#section-3.1) requires CBOR text strings + (major type 3) to be valid UTF-8. This library validates the bytes of every text string (object keys included) at + decode time and rejects ill-formed UTF-8 with a + [`parse_error.113`](../../home/exceptions.md#jsonexceptionparse_error113) exception (or, with + `allow_exceptions` set to `false`, a discarded value), rather than only failing later when the resulting value is + dumped. Byte strings (major type 2) are unaffected and are never validated, since they are not required to hold + text. + !!! warning "Tagged items" Tagged items (0xC0..0xDB) will throw a parse error by default. They can be ignored by passing `cbor_tag_handler_t::ignore` to function `from_cbor`, in which case the tag is skipped and the enclosed data item is parsed on its own. They can be stored by passing `cbor_tag_handler_t::store` to function `from_cbor`. Note that no tag is ever interpreted: for instance, a text string tagged with tag 0 (date/time) stays a string. diff --git a/docs/mkdocs/docs/features/binary_formats/messagepack.md b/docs/mkdocs/docs/features/binary_formats/messagepack.md index bd0c840f2..a434909c4 100644 --- a/docs/mkdocs/docs/features/binary_formats/messagepack.md +++ b/docs/mkdocs/docs/features/binary_formats/messagepack.md @@ -136,6 +136,14 @@ The library maps MessagePack types to JSON value types as follows: Any MessagePack output created by `to_msgpack` can be successfully parsed by `from_msgpack`. +!!! warning "UTF-8 validation of string values" + + The MessagePack specification requires `str` values (`fixstr`, `str 8`, `str 16`, `str 32`) to be valid UTF-8. + This library validates the bytes of every such string (object keys included) at decode time and rejects + ill-formed UTF-8 with a [`parse_error.113`](../../home/exceptions.md#jsonexceptionparse_error113) exception (or, + with `allow_exceptions` set to `false`, a discarded value), rather than only failing later when the resulting + value is dumped. `bin`/`ext`/`fixext` values are unaffected and are never validated, since they are not required + to hold text. ??? example diff --git a/docs/mkdocs/docs/home/exceptions.md b/docs/mkdocs/docs/home/exceptions.md index 09cc8e178..fd3a732e2 100644 --- a/docs/mkdocs/docs/home/exceptions.md +++ b/docs/mkdocs/docs/home/exceptions.md @@ -340,7 +340,8 @@ An unexpected byte was read in a [binary format](../features/binary_formats/inde ### json.exception.parse_error.113 A string could not be read from a [binary format](../features/binary_formats/index.md): either a value that is not a -string was read where one was required (for instance as a map key), or the string's length specification is invalid. +string was read where one was required (for instance as a map key), the string's length specification is invalid, or +the string's bytes are not valid UTF-8. !!! failure "Example messages" @@ -356,6 +357,9 @@ string was read where one was required (for instance as a map key), or the strin ``` [json.exception.parse_error.113] parse error at byte 3: syntax error while parsing BJData string: string length must not be negative ``` + ``` + [json.exception.parse_error.113] parse error at byte 3: syntax error while parsing CBOR string: invalid string: ill-formed UTF-8 byte + ``` ### json.exception.parse_error.114 diff --git a/include/nlohmann/detail/input/binary_reader.hpp b/include/nlohmann/detail/input/binary_reader.hpp index 7ba7b3ab5..7a77edd7e 100644 --- a/include/nlohmann/detail/input/binary_reader.hpp +++ b/include/nlohmann/detail/input/binary_reader.hpp @@ -32,6 +32,7 @@ #include #include #include +#include #include NLOHMANN_JSON_NAMESPACE_BEGIN @@ -3313,7 +3314,24 @@ class binary_reader const NumberType len, string_t& result) { - return get_bytes(format, len, "string", result); + if (JSON_HEDLEY_UNLIKELY(!get_bytes(format, len, "string", result))) + { + return false; + } + + // RFC 8949 (CBOR) §3.1 and the MessagePack/BSON/UBJSON specifications + // all require text strings to be valid UTF-8; reject anything else + // right here so malformed input is caught at decode time instead of + // only surfacing later as a type_error.316 when the value is dumped + // (which would defeat allow_exceptions=false / strict discarding). + if (JSON_HEDLEY_UNLIKELY(!is_valid_utf8(result))) + { + 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)); + } + + return true; } /*! diff --git a/include/nlohmann/detail/output/serializer.hpp b/include/nlohmann/detail/output/serializer.hpp index 9560729ad..4213383db 100644 --- a/include/nlohmann/detail/output/serializer.hpp +++ b/include/nlohmann/detail/output/serializer.hpp @@ -31,6 +31,7 @@ #include #include #include +#include #include NLOHMANN_JSON_NAMESPACE_BEGIN @@ -57,8 +58,6 @@ class serializer using number_integer_t = typename BasicJsonType::number_integer_t; using number_unsigned_t = typename BasicJsonType::number_unsigned_t; using binary_char_t = typename BasicJsonType::binary_t::value_type; - static constexpr std::uint8_t UTF8_ACCEPT = 0; - static constexpr std::uint8_t UTF8_REJECT = 1; public: /*! @@ -1584,62 +1583,6 @@ class serializer } } - /*! - @brief check whether a string is UTF-8 encoded - - The function checks each byte of a string whether it is UTF-8 encoded. The - result of the check is stored in the @a state parameter. The function must - be called initially with state 0 (accept). State 1 means the string must - be rejected, because the current byte is not allowed. If the string is - completely processed, but the state is non-zero, the string ended - prematurely; that is, the last byte indicated more bytes should have - followed. - - @param[in,out] state the state of the decoding - @param[in,out] codep codepoint (valid only if resulting state is UTF8_ACCEPT) - @param[in] byte next byte to decode - @return new state - - @note The function has been edited: a std::array is used. - - @copyright Copyright (c) 2008-2009 Bjoern Hoehrmann - @sa http://bjoern.hoehrmann.de/utf-8/decoder/dfa/ - */ - static std::uint8_t decode(std::uint8_t& state, std::uint32_t& codep, const std::uint8_t byte) noexcept - { - static const std::array utf8d = - { - { - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 00..1F - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 20..3F - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 40..5F - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 60..7F - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, // 80..9F - 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, // A0..BF - 8, 8, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, // C0..DF - 0xA, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x4, 0x3, 0x3, // E0..EF - 0xB, 0x6, 0x6, 0x6, 0x5, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, // F0..FF - 0x0, 0x1, 0x2, 0x3, 0x5, 0x8, 0x7, 0x1, 0x1, 0x1, 0x4, 0x6, 0x1, 0x1, 0x1, 0x1, // s0..s0 - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, // s1..s2 - 1, 2, 1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, // s3..s4 - 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 1, 3, 1, 1, 1, 1, 1, 1, // s5..s6 - 1, 3, 1, 1, 1, 1, 1, 3, 1, 3, 1, 1, 1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 // s7..s8 - } - }; - - JSON_ASSERT(static_cast(byte) < utf8d.size()); - const std::uint8_t type = utf8d[byte]; - - codep = (state != UTF8_ACCEPT) - ? (byte & 0x3fu) | (codep << 6u) - : (0xFFu >> type) & (byte); - - const std::size_t index = 256u + (static_cast(state) * 16u) + static_cast(type); - JSON_ASSERT(index < utf8d.size()); - state = utf8d[index]; - return state; - } - /* * Overload to make the compiler happy while it is instantiating * dump_integer for number_unsigned_t. diff --git a/include/nlohmann/detail/string_utils.hpp b/include/nlohmann/detail/string_utils.hpp index fe2f9109d..064d5ccf9 100644 --- a/include/nlohmann/detail/string_utils.hpp +++ b/include/nlohmann/detail/string_utils.hpp @@ -8,10 +8,13 @@ #pragma once +#include // array #include // size_t +#include // uint8_t, uint32_t #include // string, to_string #include +#include NLOHMANN_JSON_NAMESPACE_BEGIN namespace detail @@ -33,5 +36,99 @@ StringType to_string(std::size_t value) return result; } +/////////////////// +// UTF-8 decoding // +/////////////////// + +// UTF-8 decoder states used by decode() below +static constexpr std::uint8_t UTF8_ACCEPT = 0; +static constexpr std::uint8_t UTF8_REJECT = 1; + +/*! +@brief process a byte of a UTF-8 sequence + +This is a single-byte step of a "shift-based" UTF-8 decoder originally +written by Björn Hoehrmann. See +http://bjoern.hoehrmann.de/utf-8/decoder/dfa/ for details. + +This decoder is the single source of truth for UTF-8 validation in this +library: it is used both by the serializer (to escape and, in strict mode, +reject ill-formed UTF-8 when dumping a string) and by the binary readers +(to reject ill-formed UTF-8 in CBOR/MessagePack/BSON/UBJSON text strings at +decode time; see @ref is_valid_utf8 below). + +@param[in,out] state the current decoder state +@param[in,out] codep codepoint (valid only if resulting state is UTF8_ACCEPT) +@param[in] byte next byte to decode +@return new state + +@note Original source: http://bjoern.hoehrmann.de/utf-8/decoder/dfa/ +@sa http://bjoern.hoehrmann.de/utf-8/decoder/dfa/ +*/ +inline std::uint8_t decode(std::uint8_t& state, std::uint32_t& codep, const std::uint8_t byte) noexcept +{ + static const std::array utf8d = + { + { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 00..1F + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 20..3F + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 40..5F + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 60..7F + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, // 80..9F + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, // A0..BF + 8, 8, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, // C0..DF + 0xA, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x4, 0x3, 0x3, // E0..EF + 0xB, 0x6, 0x6, 0x6, 0x5, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, // F0..FF + 0x0, 0x1, 0x2, 0x3, 0x5, 0x8, 0x7, 0x1, 0x1, 0x1, 0x4, 0x6, 0x1, 0x1, 0x1, 0x1, // s0..s0 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, // s1..s2 + 1, 2, 1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, // s3..s4 + 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 1, 3, 1, 1, 1, 1, 1, 1, // s5..s6 + 1, 3, 1, 1, 1, 1, 1, 3, 1, 3, 1, 1, 1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 // s7..s8 + } + }; + + JSON_ASSERT(static_cast(byte) < utf8d.size()); + const std::uint8_t type = utf8d[byte]; + + codep = (state != UTF8_ACCEPT) + ? (byte & 0x3fu) | (codep << 6u) + : (0xFFu >> type) & (byte); + + const std::size_t index = 256u + (static_cast(state) * 16u) + static_cast(type); + JSON_ASSERT(index < utf8d.size()); + state = utf8d[index]; + return state; +} + +/*! +@brief check whether a string consists solely of valid UTF-8 + +Used by the CBOR/MessagePack/BSON/UBJSON binary readers to reject text +strings that are not valid UTF-8 at decode time (RFC 8949 §3.1 and the +MessagePack/BSON specifications all require text strings to be UTF-8), so +that malformed input is caught immediately instead of only surfacing later +as a type_error.316 when the resulting value is dumped. + +@param[in] s the string to check +@return whether @a s is valid UTF-8 +*/ +template +inline bool is_valid_utf8(const StringType& s) noexcept +{ + std::uint8_t state = UTF8_ACCEPT; + std::uint32_t codepoint = 0; + + for (std::size_t i = 0; i < s.size(); ++i) + { + decode(state, codepoint, static_cast(s[i])); + if (state == UTF8_REJECT) + { + return false; + } + } + + return state == UTF8_ACCEPT; +} + } // namespace detail NLOHMANN_JSON_NAMESPACE_END diff --git a/single_include/nlohmann/json.hpp b/single_include/nlohmann/json.hpp index 0c3f56d43..fa9ec890c 100644 --- a/single_include/nlohmann/json.hpp +++ b/single_include/nlohmann/json.hpp @@ -6002,11 +6002,15 @@ NLOHMANN_JSON_NAMESPACE_END +#include // array #include // size_t +#include // uint8_t, uint32_t #include // string, to_string // #include +// #include + NLOHMANN_JSON_NAMESPACE_BEGIN namespace detail @@ -6028,6 +6032,100 @@ StringType to_string(std::size_t value) return result; } +/////////////////// +// UTF-8 decoding // +/////////////////// + +// UTF-8 decoder states used by decode() below +static constexpr std::uint8_t UTF8_ACCEPT = 0; +static constexpr std::uint8_t UTF8_REJECT = 1; + +/*! +@brief process a byte of a UTF-8 sequence + +This is a single-byte step of a "shift-based" UTF-8 decoder originally +written by Björn Hoehrmann. See +http://bjoern.hoehrmann.de/utf-8/decoder/dfa/ for details. + +This decoder is the single source of truth for UTF-8 validation in this +library: it is used both by the serializer (to escape and, in strict mode, +reject ill-formed UTF-8 when dumping a string) and by the binary readers +(to reject ill-formed UTF-8 in CBOR/MessagePack/BSON/UBJSON text strings at +decode time; see @ref is_valid_utf8 below). + +@param[in,out] state the current decoder state +@param[in,out] codep codepoint (valid only if resulting state is UTF8_ACCEPT) +@param[in] byte next byte to decode +@return new state + +@note Original source: http://bjoern.hoehrmann.de/utf-8/decoder/dfa/ +@sa http://bjoern.hoehrmann.de/utf-8/decoder/dfa/ +*/ +inline std::uint8_t decode(std::uint8_t& state, std::uint32_t& codep, const std::uint8_t byte) noexcept +{ + static const std::array utf8d = + { + { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 00..1F + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 20..3F + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 40..5F + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 60..7F + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, // 80..9F + 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, // A0..BF + 8, 8, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, // C0..DF + 0xA, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x4, 0x3, 0x3, // E0..EF + 0xB, 0x6, 0x6, 0x6, 0x5, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, // F0..FF + 0x0, 0x1, 0x2, 0x3, 0x5, 0x8, 0x7, 0x1, 0x1, 0x1, 0x4, 0x6, 0x1, 0x1, 0x1, 0x1, // s0..s0 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, // s1..s2 + 1, 2, 1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, // s3..s4 + 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 1, 3, 1, 1, 1, 1, 1, 1, // s5..s6 + 1, 3, 1, 1, 1, 1, 1, 3, 1, 3, 1, 1, 1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 // s7..s8 + } + }; + + JSON_ASSERT(static_cast(byte) < utf8d.size()); + const std::uint8_t type = utf8d[byte]; + + codep = (state != UTF8_ACCEPT) + ? (byte & 0x3fu) | (codep << 6u) + : (0xFFu >> type) & (byte); + + const std::size_t index = 256u + (static_cast(state) * 16u) + static_cast(type); + JSON_ASSERT(index < utf8d.size()); + state = utf8d[index]; + return state; +} + +/*! +@brief check whether a string consists solely of valid UTF-8 + +Used by the CBOR/MessagePack/BSON/UBJSON binary readers to reject text +strings that are not valid UTF-8 at decode time (RFC 8949 §3.1 and the +MessagePack/BSON specifications all require text strings to be UTF-8), so +that malformed input is caught immediately instead of only surfacing later +as a type_error.316 when the resulting value is dumped. + +@param[in] s the string to check +@return whether @a s is valid UTF-8 +*/ +template +inline bool is_valid_utf8(const StringType& s) noexcept +{ + std::uint8_t state = UTF8_ACCEPT; + std::uint32_t codepoint = 0; + + for (std::size_t i = 0; i < s.size(); ++i) + { + decode(state, codepoint, static_cast(s[i])); + if (state == UTF8_REJECT) + { + return false; + } + } + + return state == UTF8_ACCEPT; +} + } // namespace detail NLOHMANN_JSON_NAMESPACE_END @@ -12016,6 +12114,8 @@ NLOHMANN_JSON_NAMESPACE_END // #include +// #include + // #include @@ -15298,7 +15398,24 @@ class binary_reader const NumberType len, string_t& result) { - return get_bytes(format, len, "string", result); + if (JSON_HEDLEY_UNLIKELY(!get_bytes(format, len, "string", result))) + { + return false; + } + + // RFC 8949 (CBOR) §3.1 and the MessagePack/BSON/UBJSON specifications + // all require text strings to be valid UTF-8; reject anything else + // right here so malformed input is caught at decode time instead of + // only surfacing later as a type_error.316 when the value is dumped + // (which would defeat allow_exceptions=false / strict discarding). + if (JSON_HEDLEY_UNLIKELY(!is_valid_utf8(result))) + { + 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)); + } + + return true; } /*! @@ -21763,6 +21880,8 @@ NLOHMANN_JSON_NAMESPACE_END // #include +// #include + // #include @@ -21790,8 +21909,6 @@ class serializer using number_integer_t = typename BasicJsonType::number_integer_t; using number_unsigned_t = typename BasicJsonType::number_unsigned_t; using binary_char_t = typename BasicJsonType::binary_t::value_type; - static constexpr std::uint8_t UTF8_ACCEPT = 0; - static constexpr std::uint8_t UTF8_REJECT = 1; public: /*! @@ -23317,62 +23434,6 @@ class serializer } } - /*! - @brief check whether a string is UTF-8 encoded - - The function checks each byte of a string whether it is UTF-8 encoded. The - result of the check is stored in the @a state parameter. The function must - be called initially with state 0 (accept). State 1 means the string must - be rejected, because the current byte is not allowed. If the string is - completely processed, but the state is non-zero, the string ended - prematurely; that is, the last byte indicated more bytes should have - followed. - - @param[in,out] state the state of the decoding - @param[in,out] codep codepoint (valid only if resulting state is UTF8_ACCEPT) - @param[in] byte next byte to decode - @return new state - - @note The function has been edited: a std::array is used. - - @copyright Copyright (c) 2008-2009 Bjoern Hoehrmann - @sa http://bjoern.hoehrmann.de/utf-8/decoder/dfa/ - */ - static std::uint8_t decode(std::uint8_t& state, std::uint32_t& codep, const std::uint8_t byte) noexcept - { - static const std::array utf8d = - { - { - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 00..1F - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 20..3F - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 40..5F - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 60..7F - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, // 80..9F - 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, // A0..BF - 8, 8, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, // C0..DF - 0xA, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x4, 0x3, 0x3, // E0..EF - 0xB, 0x6, 0x6, 0x6, 0x5, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, // F0..FF - 0x0, 0x1, 0x2, 0x3, 0x5, 0x8, 0x7, 0x1, 0x1, 0x1, 0x4, 0x6, 0x1, 0x1, 0x1, 0x1, // s0..s0 - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, // s1..s2 - 1, 2, 1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, // s3..s4 - 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 1, 3, 1, 1, 1, 1, 1, 1, // s5..s6 - 1, 3, 1, 1, 1, 1, 1, 3, 1, 3, 1, 1, 1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 // s7..s8 - } - }; - - JSON_ASSERT(static_cast(byte) < utf8d.size()); - const std::uint8_t type = utf8d[byte]; - - codep = (state != UTF8_ACCEPT) - ? (byte & 0x3fu) | (codep << 6u) - : (0xFFu >> type) & (byte); - - const std::size_t index = 256u + (static_cast(state) * 16u) + static_cast(type); - JSON_ASSERT(index < utf8d.size()); - state = utf8d[index]; - return state; - } - /* * Overload to make the compiler happy while it is instantiating * dump_integer for number_unsigned_t. diff --git a/tests/src/unit-cbor.cpp b/tests/src/unit-cbor.cpp index 4c9107517..fc7fc614a 100644 --- a/tests/src/unit-cbor.cpp +++ b/tests/src/unit-cbor.cpp @@ -1833,6 +1833,27 @@ TEST_CASE("CBOR") CHECK(json::from_cbor(std::vector({0xa1, 0xff, 0x01}), true, false).is_discarded()); } + SECTION("invalid UTF-8 in string (see #5529)") + { + // a two-character text string (major type 3) whose bytes are not + // valid UTF-8 (0xC0 0xAE is an overlong encoding of '.') must be + // rejected at decode time, matching every other kind of + // malformed binary input, rather than only failing later when + // the resulting value is dumped + json _; + CHECK_THROWS_WITH_AS(_ = json::from_cbor(std::vector({0x62, 0xc0, 0xae})), "[json.exception.parse_error.113] parse error at byte 3: syntax error while parsing CBOR string: invalid string: ill-formed UTF-8 byte", json::parse_error&); + CHECK(json::from_cbor(std::vector({0x62, 0xc0, 0xae}), true, false).is_discarded()); + + // a CBOR byte string (major type 2) with the very same bytes is + // NOT text and must still be accepted as-is + CHECK_NOTHROW(_ = json::from_cbor(std::vector({0x42, 0xc0, 0xae}))); + CHECK(_ == json::binary(std::vector({0xc0, 0xae}))); + + // valid UTF-8 must still round-trip + const json j = "h\xc3\xa9llo, w\xc3\xb6rld! \xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e"; // héllo, wörld! 日本語 + CHECK(json::from_cbor(json::to_cbor(j)) == j); + } + SECTION("strict mode") { std::vector const vec = {0xf6, 0xf6}; diff --git a/tests/src/unit-msgpack.cpp b/tests/src/unit-msgpack.cpp index 74f7f4969..7dd3f3cf0 100644 --- a/tests/src/unit-msgpack.cpp +++ b/tests/src/unit-msgpack.cpp @@ -1554,6 +1554,27 @@ TEST_CASE("MessagePack") CHECK(json::from_msgpack(std::vector({0x81, 0xff, 0x01}), true, false).is_discarded()); } + SECTION("invalid UTF-8 in string (see #5529)") + { + // a fixstr of length 2 (0xA0 | 2) whose bytes are not valid UTF-8 + // (0xC0 0xAE is an overlong encoding of '.') must be rejected at + // decode time, matching every other kind of malformed binary + // input, rather than only failing later when the resulting + // value is dumped + json _; + CHECK_THROWS_WITH_AS(_ = json::from_msgpack(std::vector({0xa2, 0xc0, 0xae})), "[json.exception.parse_error.113] parse error at byte 3: syntax error while parsing MessagePack string: invalid string: ill-formed UTF-8 byte", json::parse_error&); + CHECK(json::from_msgpack(std::vector({0xa2, 0xc0, 0xae}), true, false).is_discarded()); + + // a MessagePack bin8 blob with the very same bytes is NOT text + // and must still be accepted as-is + CHECK_NOTHROW(_ = json::from_msgpack(std::vector({0xc4, 0x02, 0xc0, 0xae}))); + CHECK(_ == json::binary(std::vector({0xc0, 0xae}))); + + // valid UTF-8 must still round-trip + const json j = "h\xc3\xa9llo, w\xc3\xb6rld! \xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e"; // héllo, wörld! 日本語 + CHECK(json::from_msgpack(json::to_msgpack(j)) == j); + } + SECTION("strict mode") { std::vector const vec = {0xc0, 0xc0}; diff --git a/tests/src/unit-serialization.cpp b/tests/src/unit-serialization.cpp index 511108c64..dfb392bd4 100644 --- a/tests/src/unit-serialization.cpp +++ b/tests/src/unit-serialization.cpp @@ -94,6 +94,16 @@ TEST_CASE("serialization") CHECK(j.dump(-1, ' ', true, json::error_handler_t::replace) == "\"\\u00e4\\ufffd\\u00fc\""); } + SECTION("invalid character (regression guard for shared UTF-8 decoder, see #5529)") + { + // dump_escaped_impl() now calls the UTF-8 decoder shared with the + // binary readers (detail::decode() in string_utils.hpp) instead + // of a private copy; the exact type_error.316 message/behavior + // must stay byte-for-byte the same as before that extraction + const json j = "ä\xA9ü"; + CHECK_THROWS_WITH_AS(utils::ignore_return_value(j.dump()), "[json.exception.type_error.316] invalid UTF-8 byte at index 2: 0xA9", json::type_error&); + } + SECTION("ending with incomplete character") { const json j = "123\xC2";