Skip to content

Do not allocate from sizes a peer declares in the Native protocol - #117011

Open
alexey-milovidov wants to merge 1 commit into
masterfrom
fix-client-native-protocol-allocations
Open

Do not allocate from sizes a peer declares in the Native protocol#117011
alexey-milovidov wants to merge 1 commit into
masterfrom
fix-client-native-protocol-allocations

Conversation

@alexey-milovidov

@alexey-milovidov alexey-milovidov commented Aug 29, 2026

Copy link
Copy Markdown
Member

Closes: https://github.com/ClickHouse/clickhouse-private/issues/71254

A client allocates from sizes that the server puts on the wire, before the corresponding payload arrives, and the client has no memory limit to stop it.

  • The strings of an exception, which is the first thing a client can receive during the handshake, were resized to their declared size before the text arrived. They are now read as the bytes arrive.
  • The decompressed size of a compressed block was not bounded, while the compressed size was. It is bounded the same way now.
  • The row count of a block was only checked against a limit of a thousand billion rows, and the bulk deserialization resizes the column to it before reading. The limit is now a billion rows, which is still far above anything ClickHouse produces, and the columns are no longer preallocated from a row count that no data has backed yet.

Changelog category (leave one):

  • Bug Fix (user-visible misbehavior in an official stable release)

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Sizes declared by a server in the Native protocol no longer make the client allocate memory for data that has not arrived: exception strings are read as they arrive, the decompressed size of a block is bounded, and the maximum number of rows in a Native block is now a billion.


Workflow [PR]
Sync PR [sync-upstream/pr/117011]

A client allocates from sizes that the server puts on the wire, and the client
has no memory limit to stop it, so a small packet could make it allocate
gigabytes:

- The strings of an exception, which is the first thing a client can receive
  during the handshake, were resized to their declared size before the text
  arrived. Read them as the bytes arrive instead.
- The decompressed size of a compressed block was not bounded, while the
  compressed size was. Bound it the same way.
- The row count of a block was only checked against a limit of a thousand
  billion rows, and the bulk deserialization resizes the column to it before
  reading. Lower the limit to a billion rows, which is already far above
  anything ClickHouse produces, and stop preallocating the columns from a row
  count that has not been backed by any data yet.
@alexey-milovidov alexey-milovidov added the pr-must-backport Pull request should be backported intentionally. Use this label with great care! label Aug 29, 2026
@clickhouse-gh

clickhouse-gh Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [fb3a21b]

Summary:


AI Review

Summary

This PR hardens a few client-side Native/compressed-input allocation paths by reading exception strings incrementally, capping declared decompressed block sizes, and reducing the accepted Native row-count limit. The direction is right, but the accepted boundary values still drive the same large allocations from peer-controlled headers, and the new shared decompressed-size cap also rejects compressed data that current writers can still produce.

Findings

❌ Blockers

  • [src/Compression/CompressionInfo.h:14] DBMS_MAX_DECOMPRESSED_SIZE = 1 GiB still allows a peer-controlled block header to trigger a ~1 GiB allocation before payload validation, because CompressedReadBuffer::nextImpl / CompressedReadBufferFromFile::nextImpl immediately resize the destination buffer to size_decompressed.
  • [src/Core/Defines.h:171] DEFAULT_NATIVE_BINARY_MAX_NUM_ROWS = 1'000'000'000ULL still lets a tiny Native header force multi-gigabyte allocations; NativeReader::readData forwards rows to bulk deserializers, and SerializationNumber<T>::deserializeBinaryBulk starts with x.resize(initial_size + limit), so a single UInt64 column at the new limit still allocates about 8 GiB before data arrives.
  • [src/Compression/CompressedReadBufferBase.cpp:167] Enforcing the 1 GiB decompressed-size cap in the generic compressed-reader path breaks backward compatibility for data current writers can emit (CompressedWriteBuffer accepts up to INT_MAX, StorageLog passes through max_compress_block_size, and clickhouse-compressor --block-size is unconstrained), so previously readable compressed files can start failing after upgrade.
Final Verdict
  • Status: ❌ Block
    Minimum required actions:
  • Make the compressed-block hardening actually prevent header-only large allocations at the accepted boundary.
  • Make the Native row-count hardening stop bulk deserializers from pre-sizing multi-gigabyte columns from peer-controlled rows.
  • Avoid introducing an unconditional incompatibility for existing compressed files, or gate/document it with an explicit compatibility story.

LLVM Coverage Report

Measured on commit fb3a21b.

Metric Baseline Current Δ
Lines 88.50% 88.50% +0.00%
Functions 92.00% 92.00% +0.00%
Branches 80.80% 80.80% +0.00%

Changed lines: Changed C/C++ lines covered: 36/40 (90.00%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Aug 29, 2026
* declares a huge decompressed size makes the reader allocate that much from a tiny payload.
* Blocks are written with `max_compress_block_size`, which is 1 MB by default.
*/
constexpr uint64_t DBMS_MAX_DECOMPRESSED_SIZE = 0x40000000ULL; /// 1GB

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still leaves the original allocation bug in place at the new boundary. CompressedReadBuffer::nextImpl and CompressedReadBufferFromFile::nextImpl both call memory.resize(size_decompressed + ...) immediately after readCompressedData, so a block that declares exactly 0x40000000 bytes here still forces a ~1 GiB allocation before any real payload is validated. That only moves the cliff from 4 GiB to 1 GiB; it does not satisfy the PR contract that a peer-declared size should not become an allocation on its own.

I think this needs either a much smaller byte budget that matches supported writers, or a decompression path that does not pre-size the destination buffer from the header alone.

Comment thread src/Core/Defines.h
/// Not `uz`: the value does not fit into `size_t` on 32-bit platforms, and it is compared against
/// a row count read from the wire as `UInt64`.
static constexpr auto DEFAULT_NATIVE_BINARY_MAX_NUM_ROWS = 1'000'000'000'000ULL;
static constexpr auto DEFAULT_NATIVE_BINARY_MAX_NUM_ROWS = 1'000'000'000ULL;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lowering the cap to 1'000'000'000 does not actually close the NativeReader side of the bug. NativeReader::readData still forwards rows into deserializeBinaryBulkWithMultipleStreams, and SerializationNumber<T>::deserializeBinaryBulk starts with x.resize(initial_size + limit) in src/DataTypes/Serializations/SerializationNumber.cpp. So a Native block that declares exactly 1e9 rows for a single UInt64 column still allocates about 8 GiB before any column bytes are read. The new test only exercises 1e12, but the header/payload asymmetry remains at the new boundary.

This probably needs a byte-budget-based cap, or the bulk deserializers need to grow from bytes actually read instead of trusting rows.

throw Exception(ErrorCodes::TOO_LARGE_SIZE_COMPRESSED, "Too large size_compressed_without_checksum: {}. "
"Most likely corrupted data.", size_compressed_without_checksum);

if (size_decompressed > DBMS_MAX_DECOMPRESSED_SIZE)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Putting this cap in the shared compressed-reader path is a backward-compatibility break: current writers still allow blocks larger than 1 GiB. CompressedWriteBuffer::nextImpl accepts any offset() <= INT_MAX, StorageLog passes max_compress_block_size through without clamping, and clickhouse-compressor --block-size is also an unconstrained size_t. That means previously readable compressed data can start failing after upgrade even though the format itself did not change.

If the goal is to harden untrusted Native or network reads, I think the tighter bound has to live on those untrusted entrypoints, or this needs a compatibility knob or migration story for generic compressed files.

@clickhouse-gh

clickhouse-gh Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

Comparing fb3a21b5c with master 194df773e (stripped binary size, per-symbol sizes and ThinLTO time; object sizes against the warmup build of c0938b0c2; compile times per translation unit against the most recent warmup build that recompiled it).

✅ No significant changes.

Binary sizes
Binary Master PR Δ
programs/clickhouse-stripped 710.13 MiB 707.08 MiB -3.05 MiB (-0.43%)

Only the stripped binary is compared: the official master build keeps debug symbols while PR builds strip them, so the other binaries differ by construction.

Compile time of recompiled translation units

3967 translation units recompiled, 18580 s compile time in total, 3966 of them have a recent master baseline.

Job report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-bugfix Pull request with bugfix, not backported by default pr-must-backport Pull request should be backported intentionally. Use this label with great care!

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant