From bfe0f32d71fdc65dcddd672c7f9bd8f310c5c762 Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Wed, 30 Sep 2026 20:07:33 +0200 Subject: [PATCH] Fix std::terminate and null pointer access in input_stream_adapter (#5699) Parsing from a std::istream crashed in two unusual but valid stream states, both in input_stream_adapter: - With eofbit in the stream's exceptions() mask, get_character() sets eofbit via is->clear(), which throws std::ios_base::failure. While that exception unwinds, ~input_stream_adapter() called clear() again to reset eofbit, which is still set and still in the exception mask, so it throws a second time out of the (implicitly noexcept) destructor and std::terminate() is called. The destructor now only calls clear() if a bit other than eofbit remains set, so the first exception can propagate normally. - For an std::istream without a stream buffer (rdbuf() == nullptr, e.g. std::istream(nullptr)), the constructor stored the null pointer without checking it, and get_character() dereferenced it. input_adapter(std::istream&) now throws parse_error.101 for such a stream, the same as it already does for a null FILE* or char*. Added regression tests to unit-deserialization.cpp and, for the JSON_PRECISE_STREAM_POSITION variant of get_character(), to unit-precise-stream-position.cpp; both crashed before this fix. Documented the two exceptions in parse.md and operator_gtgt.md. Fixes #5646. Signed-off-by: Niels Lohmann Co-authored-by: Claude Sonnet 5 --- docs/mkdocs/docs/api/basic_json/parse.md | 9 +++++- docs/mkdocs/docs/api/operator_gtgt.md | 9 +++++- .../nlohmann/detail/input/input_adapters.hpp | 14 +++++++-- single_include/nlohmann/json.hpp | 14 +++++++-- tests/src/unit-deserialization.cpp | 31 +++++++++++++++++++ tests/src/unit-precise-stream-position.cpp | 29 +++++++++++++++++ 6 files changed, 100 insertions(+), 6 deletions(-) diff --git a/docs/mkdocs/docs/api/basic_json/parse.md b/docs/mkdocs/docs/api/basic_json/parse.md index 20bb1c708..554065717 100644 --- a/docs/mkdocs/docs/api/basic_json/parse.md +++ b/docs/mkdocs/docs/api/basic_json/parse.md @@ -88,7 +88,12 @@ Strong guarantee: if an exception is thrown, there are no changes in the JSON va ## Exceptions - Throws [`parse_error.101`](../../home/exceptions.md#jsonexceptionparse_error101) in case of an unexpected token, or - empty input like a null `FILE*` or `char*` pointer. + empty input like a null `FILE*` or `char*` pointer, or an `std::istream` without a stream buffer + (`#!cpp i.rdbuf() == nullptr`, for instance `#!cpp std::istream(nullptr)`). +- If reading from an `std::istream` reaches the end of the input and `eofbit` is part of the stream's + [`exceptions()`](https://en.cppreference.com/w/cpp/io/basic_ios/exceptions) mask, the `std::ios_base::failure` + thrown by the stream itself propagates instead of a `parse_error`, the same as it would for the standard library's + own extraction operators. ## Complexity @@ -254,6 +259,8 @@ outside of a string, invalid) byte; see the [FAQ entry](../../home/faq.md#nul-by - Extended overload (2) to accept heterogeneous iterator+sentinel pairs (C++20 ranges support) in version 3.13.0. - `JSON_STRICT_NUL_HANDLING` added in version 3.13.0 to optionally reject a NUL byte in the input instead of treating it as end of input; planned to become the default in version 4.0.0. +- Extended empty-input detection to also cover an `std::istream` without a stream buffer, and fixed a crash + (`std::terminate`) when parsing from an `std::istream` with `eofbit` in its exception mask, in version 3.13.0. !!! warning "Deprecation" diff --git a/docs/mkdocs/docs/api/operator_gtgt.md b/docs/mkdocs/docs/api/operator_gtgt.md index 0173b9fb3..b68889af9 100644 --- a/docs/mkdocs/docs/api/operator_gtgt.md +++ b/docs/mkdocs/docs/api/operator_gtgt.md @@ -20,7 +20,12 @@ the stream `i` ## Exceptions -- Throws [`parse_error.101`](../home/exceptions.md#jsonexceptionparse_error101) in case of an unexpected token. +- Throws [`parse_error.101`](../home/exceptions.md#jsonexceptionparse_error101) in case of an unexpected token, or if + `i` has no stream buffer (`#!cpp i.rdbuf() == nullptr`, for instance `#!cpp std::istream(nullptr)`). +- If reading from `i` reaches the end of the input and `eofbit` is part of `i`'s + [`exceptions()`](https://en.cppreference.com/w/cpp/io/basic_ios/exceptions) mask, the `std::ios_base::failure` + thrown by `i` itself propagates instead of a `parse_error`, the same as it would for the standard library's own + extraction operators. ## Complexity @@ -118,3 +123,5 @@ being read. it as end of input; planned to become the default in version 4.0.0. - `JSON_PRECISE_STREAM_POSITION` added in version 3.13.0 to optionally leave the character that terminates a number in the stream; planned to become the default in version 4.0.0. +- Fixed a null pointer dereference for an `std::istream` without a stream buffer (now throws `parse_error.101`), and a + crash (`std::terminate`) when `i` has `eofbit` in its exception mask, in version 3.13.0. diff --git a/include/nlohmann/detail/input/input_adapters.hpp b/include/nlohmann/detail/input/input_adapters.hpp index e174775c5..f06713700 100644 --- a/include/nlohmann/detail/input/input_adapters.hpp +++ b/include/nlohmann/detail/input/input_adapters.hpp @@ -106,7 +106,13 @@ class input_stream_adapter // was given back with release_lookahead() commit_lookahead(); #endif - is->clear(is->rdstate() & std::ios::eofbit); + // only call clear() if there is something to clear: it throws + // std::ios_base::failure if the stream has exceptions() enabled + // for a state bit that remains set, and a destructor must not throw + if ((is->rdstate() & ~std::ios::eofbit) != 0) + { + is->clear(is->rdstate() & std::ios::eofbit); + } } } @@ -811,12 +817,16 @@ inline file_input_adapter input_adapter(std::FILE* file) inline input_stream_adapter input_adapter(std::istream& stream) { + if (stream.rdbuf() == nullptr) + { + JSON_THROW(parse_error::create(101, 0, "attempting to parse an empty input; check that your input string or stream contains the expected JSON", nullptr)); + } return input_stream_adapter(stream); } inline input_stream_adapter input_adapter(std::istream&& stream) { - return input_stream_adapter(stream); + return input_adapter(stream); } #endif // JSON_NO_IO diff --git a/single_include/nlohmann/json.hpp b/single_include/nlohmann/json.hpp index 16c2cdca6..3735a1925 100644 --- a/single_include/nlohmann/json.hpp +++ b/single_include/nlohmann/json.hpp @@ -7662,7 +7662,13 @@ class input_stream_adapter // was given back with release_lookahead() commit_lookahead(); #endif - is->clear(is->rdstate() & std::ios::eofbit); + // only call clear() if there is something to clear: it throws + // std::ios_base::failure if the stream has exceptions() enabled + // for a state bit that remains set, and a destructor must not throw + if ((is->rdstate() & ~std::ios::eofbit) != 0) + { + is->clear(is->rdstate() & std::ios::eofbit); + } } } @@ -8367,12 +8373,16 @@ inline file_input_adapter input_adapter(std::FILE* file) inline input_stream_adapter input_adapter(std::istream& stream) { + if (stream.rdbuf() == nullptr) + { + JSON_THROW(parse_error::create(101, 0, "attempting to parse an empty input; check that your input string or stream contains the expected JSON", nullptr)); + } return input_stream_adapter(stream); } inline input_stream_adapter input_adapter(std::istream&& stream) { - return input_stream_adapter(stream); + return input_adapter(stream); } #endif // JSON_NO_IO diff --git a/tests/src/unit-deserialization.cpp b/tests/src/unit-deserialization.cpp index 4202644f6..c18fe0c94 100644 --- a/tests/src/unit-deserialization.cpp +++ b/tests/src/unit-deserialization.cpp @@ -388,6 +388,37 @@ TEST_CASE("deserialization") })); } + SECTION("stream with eofbit in its exception mask (issue #5646)") + { + // reaching EOF while parsing a value that fills the whole input + // (e.g., a number, or any value under strict parsing) makes + // get_character() call std::istream::clear() to record eofbit; + // with eofbit in the exception mask, that clear() itself throws + // std::ios_base::failure - it must propagate to the caller instead + // of ~input_stream_adapter() throwing a second exception while the + // first is still unwinding, which would call std::terminate + json _; + + std::istringstream is1("1"); + is1.exceptions(std::ios::eofbit); + CHECK_THROWS_AS(_ = json::parse(is1), std::ios_base::failure&); + + // the same holds for the common std::ifstream::exceptions(failbit | + // badbit | eofbit) pattern, because only eofbit ends up set + std::istringstream is2("1"); + is2.exceptions(std::ios::failbit | std::ios::badbit | std::ios::eofbit); + CHECK_THROWS_AS(_ = json::parse(is2), std::ios_base::failure&); + } + + SECTION("stream without a streambuf (issue #5646)") + { + // std::istream(nullptr) has badbit set and rdbuf() == nullptr; + // get_character() must not dereference that null streambuf + std::istream is(nullptr); + json _; + CHECK_THROWS_WITH_AS(_ = json::parse(is), "[json.exception.parse_error.101] parse error: attempting to parse an empty input; check that your input string or stream contains the expected JSON", json::parse_error&); + } + SECTION("string") { json::string_t const s = R"(["foo",1,2,3,false,{"one":1})"; diff --git a/tests/src/unit-precise-stream-position.cpp b/tests/src/unit-precise-stream-position.cpp index 5b6bff682..be8cabd99 100644 --- a/tests/src/unit-precise-stream-position.cpp +++ b/tests/src/unit-precise-stream-position.cpp @@ -234,4 +234,33 @@ TEST_CASE("JSON_PRECISE_STREAM_POSITION") CHECK(j == json(1)); CHECK(remaining(is) == "true"); } + + SECTION("stream with eofbit in its exception mask (issue #5646)") + { + // with JSON_PRECISE_STREAM_POSITION, get_character() peeks via + // sb->sgetc() rather than consuming via sb->sbumpc(), but it still + // calls std::istream::clear() to record eofbit once the streambuf is + // exhausted; with eofbit in the exception mask, that clear() itself + // throws std::ios_base::failure, which must propagate to the caller + // instead of ~input_stream_adapter() throwing a second exception + // while the first is still unwinding (which would call std::terminate) + json _; + + std::istringstream is1("1"); + is1.exceptions(std::ios::eofbit); + CHECK_THROWS_AS(_ = json::parse(is1), std::ios_base::failure&); + + std::istringstream is2("1"); + is2.exceptions(std::ios::failbit | std::ios::badbit | std::ios::eofbit); + CHECK_THROWS_AS(_ = json::parse(is2), std::ios_base::failure&); + } + + SECTION("stream without a streambuf (issue #5646)") + { + // std::istream(nullptr) has badbit set and rdbuf() == nullptr; + // get_character() must not dereference that null streambuf + std::istream is(nullptr); + json _; + CHECK_THROWS_WITH_AS(_ = json::parse(is), "[json.exception.parse_error.101] parse error: attempting to parse an empty input; check that your input string or stream contains the expected JSON", json::parse_error&); + } }