Skip to content

AVRO-4290: [python] Enforce a maximum decompressed block size#3850

Open
iemejia wants to merge 8 commits into
apache:mainfrom
iemejia:AVRO-4290-python-decompress-limit
Open

AVRO-4290: [python] Enforce a maximum decompressed block size#3850
iemejia wants to merge 8 commits into
apache:mainfrom
iemejia:AVRO-4290-python-decompress-limit

Conversation

@iemejia

@iemejia iemejia commented Jul 11, 2026

Copy link
Copy Markdown
Member

What is the purpose of the change

The deflate and bzip2 codecs are inflated incrementally and rejected once the output would exceed the limit; snappy rejects an over-large declared length up front; zstandard caps its streaming loop. Exceeding the limit raises avro.errors.AvroDecompressionSizeException.

When reading a data file, each block is decompressed according to the file's codec. A block with a very high compression ratio (or a malformed block) could expand to far more memory than its compressed size (a decompression bomb). This enforces a configurable maximum decompressed size while reading each block, mirroring the Java SDK's decompression limit (AVRO-4247). The limit defaults to 200 MiB and can be overridden with the AVRO_MAX_DECOMPRESS_LENGTH environment variable.

This is part of the umbrella issue AVRO-4283.

Verifying this change

This change added tests and can be verified as follows:

  • Added avro/test/test_codecs.py (TestDecompressionSizeLimit) covering deflate and bzip2 over-limit rejection and within-limit decoding (snappy/zstandard covered when those optional deps are installed).
  • Run: cd lang/py && python3 -m unittest avro.test.test_codecs

Documentation

  • Does this pull request introduce a new feature? (no — hardening/robustness)
  • If yes, how is the feature documented? (not applicable; the new AVRO_MAX_DECOMPRESS_LENGTH environment variable is documented in code comments)

When reading a data file, each block is decompressed according to the
file's codec. A block with a very high compression ratio (or a malformed
block) could expand to far more memory than its compressed size. Enforce a
configurable maximum decompressed size across the deflate, bzip2, snappy and
zstandard codecs, mirroring the Java SDK's decompression limit (AVRO-4247).
The limit defaults to 200 MiB and can be overridden with the
AVRO_MAX_DECOMPRESS_LENGTH environment variable; exceeding it raises
AvroDecompressionSizeException.

Assisted-by: GitHub Copilot:claude-opus-4.8

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR hardens the Python Avro data-file reader against decompression bombs by enforcing a configurable maximum decompressed block size (default 200 MiB, overrideable via AVRO_MAX_DECOMPRESS_LENGTH) and raising avro.errors.AvroDecompressionSizeException when the limit is exceeded.

Changes:

  • Add AvroDecompressionSizeException and enforce a maximum decompressed block size across deflate, bzip2, snappy, and zstandard codecs.
  • Introduce AVRO_MAX_DECOMPRESS_LENGTH env override and a default limit of 200 MiB in avro.codecs.
  • Add unit tests covering over-limit rejection and within-limit decoding for available codecs.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
lang/py/avro/codecs.py Adds max-decompressed-size enforcement and env-configurable limit across supported codecs.
lang/py/avro/errors.py Introduces the new exception type raised when decompressed output exceeds the configured cap.
lang/py/avro/test/test_codecs.py Adds coverage ensuring blocks over the limit are rejected and normal blocks still decode correctly.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lang/py/avro/codecs.py Outdated
Comment on lines +180 to +185
uncompressed = decompressor.decompress(data, limit + 1)
if len(uncompressed) > limit:
_raise_decompression_too_large(limit)
uncompressed += decompressor.flush()
if len(uncompressed) > limit:
_raise_decompression_too_large(limit)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 638aac5 — DeflateCodec.decompress now accumulates into a bytearray, so flush() output is appended in place instead of creating an extra full-size copy of the decompressed data.

Comment thread lang/py/avro/codecs.py Outdated
Comment on lines +258 to +260
uncompressed.extend(chunk)
if len(uncompressed) > limit:
_raise_decompression_too_large(limit)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 638aac5 — the zstandard path now checks len(uncompressed) + len(chunk) > limit before extending, so the buffer never grows past the limit.

…fore-extend

- DeflateCodec.decompress accumulates into a bytearray so the flush() output is
  appended in place instead of creating an extra full-size copy of the already
  decompressed data.
- ZstandardCodec.decompress checks len(uncompressed) + len(chunk) before
  extending, so the buffer never grows past the configured limit.

Assisted-by: GitHub Copilot:claude-opus-4.8

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread lang/py/avro/codecs.py
Comment on lines +204 to 208
decompressor = bz2.BZ2Decompressor()
uncompressed = decompressor.decompress(data, limit + 1)
if len(uncompressed) > limit:
_raise_decompression_too_large(limit)
return avro.io.BinaryDecoder(io.BytesIO(uncompressed))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch. Reworked BZip2Codec.decompress to verify the decompressor reached end-of-stream and to drain any trailing data, looping over concatenated bzip2 streams so a multi-stream block is fully decoded. Added a test for a truncated block. Pushed in 9f3c49d.

BZip2Codec.decompress now loops the BZ2Decompressor: it drains all buffered
output (bounded by the limit), verifies the stream reached EOF (rejecting a
truncated/corrupt block with InvalidAvroBinaryEncoding), and handles
concatenated bzip2 streams as bz2.decompress does. Add a truncated-block test.

Assisted-by: GitHub Copilot:claude-opus-4.8

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment thread lang/py/avro/codecs.py
Comment on lines +176 to +187
limit = _max_decompress_length()
decompressor = zlib.decompressobj(-15)
# Request at most limit + 1 bytes so that an over-large block is detected
# without allocating the whole (potentially huge) output. Accumulate into
# a bytearray so the flush() output is appended in place rather than
# creating an extra full-size copy of the already-decompressed data.
uncompressed = bytearray(decompressor.decompress(data, limit + 1))
if len(uncompressed) > limit:
_raise_decompression_too_large(limit)
uncompressed += decompressor.flush()
if len(uncompressed) > limit:
_raise_decompression_too_large(limit)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch. Added a decompressor.eof check after flush() — a truncated/incomplete deflate block now raises InvalidAvroBinaryEncoding, restoring the reject-truncated behavior that zlib.decompress had.

Comment thread lang/py/avro/codecs.py
Comment on lines 242 to +245
# Compressed data includes a 4-byte CRC32 checksum
length = readers_decoder.read_long()
data = readers_decoder.read(length - 4)
limit = _max_decompress_length()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed — SnappyCodec.decompress now validates length >= 4 and raises a codec-specific InvalidAvroBinaryEncoding before reading length - 4 bytes.

Address review feedback:
- DeflateCodec: after flushing the decompressor, verify decompressor.eof so a
  truncated/incomplete raw-deflate block is rejected with
  InvalidAvroBinaryEncoding (zlib.decompress used to raise for this; the
  decompressobj-based size cap otherwise silently accepted partial output).
- SnappyCodec: validate the block length is >= 4 before reading length - 4
  bytes, raising a codec-specific InvalidAvroBinaryEncoding instead of falling
  through to the generic decoder error.

Assisted-by: GitHub Copilot:claude-opus-4.8

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread lang/py/avro/codecs.py
Comment on lines +188 to +191
if not decompressor.eof:
# The end-of-stream marker was not reached: the block is truncated or
# corrupt. zlib.decompress() used to raise for this; preserve that.
raise avro.errors.InvalidAvroBinaryEncoding("Truncated or corrupt deflate block")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added test_deflate_truncated_block_rejected — it truncates a valid deflate block and asserts DeflateCodec.decompress() raises InvalidAvroBinaryEncoding (mirrors the existing bzip2 truncation test).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Cover the new DeflateCodec behavior (raising InvalidAvroBinaryEncoding when the
end-of-stream marker isn't reached) with a test that truncates a valid deflate
block and asserts decompress() rejects it, mirroring the bzip2 truncation test.

Assisted-by: GitHub Copilot:claude-opus-4.8

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread lang/py/avro/codecs.py
Comment on lines +176 to +191
limit = _max_decompress_length()
decompressor = zlib.decompressobj(-15)
# Request at most limit + 1 bytes so that an over-large block is detected
# without allocating the whole (potentially huge) output. Accumulate into
# a bytearray so the flush() output is appended in place rather than
# creating an extra full-size copy of the already-decompressed data.
uncompressed = bytearray(decompressor.decompress(data, limit + 1))
if len(uncompressed) > limit:
_raise_decompression_too_large(limit)
uncompressed += decompressor.flush()
if len(uncompressed) > limit:
_raise_decompression_too_large(limit)
if not decompressor.eof:
# The end-of-stream marker was not reached: the block is truncated or
# corrupt. zlib.decompress() used to raise for this; preserve that.
raise avro.errors.InvalidAvroBinaryEncoding("Truncated or corrupt deflate block")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch — decompress(data, limit+1) leaves unconsumed input, and the subsequent flush() was unbounded. Reworked to drain unconsumed_tail in a loop with a per-call max_length and to pass a bounded length to flush(), so total output can't exceed limit+1 before being rejected.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Already bounded — the final flush is decompressor.flush(limit + 1 - len(uncompressed)) (codecs.py:194) followed by the size check, so flush() can't materialize past the cap. The eof check runs after.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

The previous fix bounded decompress(data, limit+1) but then called
decompressor.flush() with no limit, so the residual output (from the
unconsumed input left by max_length) could still expand unbounded. Drain the
unconsumed_tail in a loop with a per-call max_length and pass a bounded length
to flush(), so the accumulated output can never exceed the limit by more than
one byte before being rejected.

Assisted-by: GitHub Copilot:claude-opus-4.8

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread lang/py/avro/codecs.py Outdated
Comment on lines +60 to +69
def _max_decompress_length() -> int:
"""Return the maximum decompressed block size, honoring the environment override."""
value = os.environ.get(MAX_DECOMPRESS_LENGTH_ENV)
if value is None:
return DEFAULT_MAX_DECOMPRESS_LENGTH
try:
parsed = int(value)
except ValueError:
return DEFAULT_MAX_DECOMPRESS_LENGTH
return parsed if parsed > 0 else DEFAULT_MAX_DECOMPRESS_LENGTH

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed — _max_decompress_length() now returns min(parsed, sys.maxsize) so an oversized override stays a valid Py_ssize_t and won't raise OverflowError inside zlib/bz2 decompress().

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

_max_decompress_length() returned the raw parsed int. An absurdly large override
is passed as max_length to zlib/bz2 decompress(), which raise OverflowError when
it exceeds Py_ssize_t. Clamp to sys.maxsize so an oversized override is honored
as "effectively unbounded" instead of producing a confusing OverflowError.

Assisted-by: GitHub Copilot:claude-opus-4.8

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment thread lang/py/avro/codecs.py Outdated
Comment on lines +191 to +201
uncompressed = bytearray()
pending = data
while True:
want = limit + 1 - len(uncompressed)
uncompressed += decompressor.decompress(pending, want)
if len(uncompressed) > limit:
_raise_decompression_too_large(limit)
pending = decompressor.unconsumed_tail
if not pending:
break
uncompressed += decompressor.flush(limit + 1 - len(uncompressed))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed — added _decompress_read_ceiling(limit) which returns limit + 1 normally but sys.maxsize when limit == sys.maxsize, so the deflate max_length never overflows Py_ssize_t.

Comment thread lang/py/avro/codecs.py
Comment on lines +223 to +233
limit = _max_decompress_length()
uncompressed = bytearray()
decompressor = bz2.BZ2Decompressor()
input_data = data
while True:
# Request enough to detect exceeding the limit without allocating
# the full (potentially huge) output.
want = limit + 1 - len(uncompressed)
if want <= 0:
want = 1
uncompressed += decompressor.decompress(input_data, want)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed — the bz2 path uses the same _decompress_read_ceiling() so want can't become sys.maxsize + 1.

After clamping AVRO_MAX_DECOMPRESS_LENGTH to sys.maxsize, the deflate/bz2 paths
still computed `limit + 1` for the decompress() max_length, which overflows
Py_ssize_t (and raises OverflowError) when limit == sys.maxsize. Add
_decompress_read_ceiling(), which returns limit + 1 normally but sys.maxsize
when the limit is already sys.maxsize (no realistic block can exceed it), and
use it for the bounded read/flush in both paths.

Assisted-by: GitHub Copilot:claude-opus-4.8

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants