SEC2-027: read_wheel reads a wheel's metadata members without a size cap #969

Closed
opened 2026-10-17 10:38:41 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-10-16 in a read-only security review of postulo at 56fc9d531, checked by a second, independent pass.

Severity: Informational · Category: Input Validation · Reference: CWE-409 (Improper Handling of Highly Compressed Data); CWE-400 (Uncontrolled Resource Consumption); OWASP A05:2021 (Security Misconfiguration)

Location:

  • src/postulo/plugins/installing.py:212-245 (wheel.read(metadata_name), wheel.read(wheel_name), wheel.read(points_name))
  • src/postulo/core/server_views.py:1776-1797 (_upload: the wheel is written to disk from upload.chunks() and handed to read_wheel with no check of its own)
  • src/postulo/core/middleware.py:295-296 (the request ceiling for /server/plugins/action is max(POSTULO_UPLOAD_CEILING_MB, 64) MB, compressed)

Issue

The other zip readers in the core ask the archive's directory first and read a bounded number of bytes (documents/readers.py:60-66, core/importer.py:_read_within). read_wheel does not:

with zipfile.ZipFile(path) as wheel:
    names = wheel.namelist()
    metadata_name = _one(names, r"[^/]+\.dist-info/METADATA")
    ...
    headers = Parser().parsestr(wheel.read(metadata_name).decode("utf-8", "replace"))

A .dist-info/METADATA, WHEEL or entry_points.txt member that inflates to gigabytes is read whole into the worker's memory, before check() can refuse anything. The upload path admits up to 64 MB compressed (deflate inflates about a thousandfold), and the catalogue path up to MAX_WHEEL_BYTES (40 MB) after the checksum matched; namelist() is likewise unbounded in count.

Impact

Only an administrator reaches the page, and a catalogue wheel has to carry the published checksum, so this is a self-inflicted or catalogue-publisher denial of service: one worker is killed by the OOM killer or held for minutes while a wheel the administrator meant to inspect is read. Informational, as defence in depth.

Fix

Bound each member before reading it, as readers.text_of does:

MAX_METADATA_BYTES = 1 << 20
info = wheel.getinfo(metadata_name)
if info.file_size > MAX_METADATA_BYTES:
    raise InstallError(str(_("The wheel's metadata is far larger than any plugin's.")))
with wheel.open(info) as member:
    raw = member.read(MAX_METADATA_BYTES + 1)
if len(raw) > MAX_METADATA_BYTES:
    raise InstallError(...)

for the three members; refuse an encrypted member (info.flag_bits & 0x1) as readers.py does; and refuse a wheel whose namelist() exceeds a few thousand entries. Done when: tests/test_plugin_install.py builds a wheel whose METADATA declares a 2 GB file_size (a stored entry with a lying header, or a deflate bomb) and asserts read_wheel raises InstallError without reading it.

#466 (closed) - the same class of caps for the account-archive import; nothing covers the wheel reader.

Found on 2026-10-16 in a read-only security review of `postulo` at `56fc9d531`, checked by a second, independent pass. **Severity:** Informational · **Category:** Input Validation · **Reference:** CWE-409 (Improper Handling of Highly Compressed Data); CWE-400 (Uncontrolled Resource Consumption); OWASP A05:2021 (Security Misconfiguration) **Location:** - `src/postulo/plugins/installing.py:212-245` (`wheel.read(metadata_name)`, `wheel.read(wheel_name)`, `wheel.read(points_name)`) - `src/postulo/core/server_views.py:1776-1797` (`_upload`: the wheel is written to disk from `upload.chunks()` and handed to `read_wheel` with no check of its own) - `src/postulo/core/middleware.py:295-296` (the request ceiling for `/server/plugins/action` is `max(POSTULO_UPLOAD_CEILING_MB, 64)` MB, compressed) ## Issue The other zip readers in the core ask the archive's directory first and read a bounded number of bytes (`documents/readers.py:60-66`, `core/importer.py:_read_within`). `read_wheel` does not: ```python with zipfile.ZipFile(path) as wheel: names = wheel.namelist() metadata_name = _one(names, r"[^/]+\.dist-info/METADATA") ... headers = Parser().parsestr(wheel.read(metadata_name).decode("utf-8", "replace")) ``` A `.dist-info/METADATA`, `WHEEL` or `entry_points.txt` member that inflates to gigabytes is read whole into the worker's memory, before `check()` can refuse anything. The upload path admits up to 64 MB compressed (deflate inflates about a thousandfold), and the catalogue path up to `MAX_WHEEL_BYTES` (40 MB) after the checksum matched; `namelist()` is likewise unbounded in count. ## Impact Only an administrator reaches the page, and a catalogue wheel has to carry the published checksum, so this is a self-inflicted or catalogue-publisher denial of service: one worker is killed by the OOM killer or held for minutes while a wheel the administrator meant to inspect is read. Informational, as defence in depth. ## Fix Bound each member before reading it, as `readers.text_of` does: ```python MAX_METADATA_BYTES = 1 << 20 info = wheel.getinfo(metadata_name) if info.file_size > MAX_METADATA_BYTES: raise InstallError(str(_("The wheel's metadata is far larger than any plugin's."))) with wheel.open(info) as member: raw = member.read(MAX_METADATA_BYTES + 1) if len(raw) > MAX_METADATA_BYTES: raise InstallError(...) ``` for the three members; refuse an encrypted member (`info.flag_bits & 0x1`) as `readers.py` does; and refuse a wheel whose `namelist()` exceeds a few thousand entries. Done when: `tests/test_plugin_install.py` builds a wheel whose `METADATA` declares a 2 GB `file_size` (a stored entry with a lying header, or a deflate bomb) and asserts `read_wheel` raises `InstallError` without reading it. ## Related #466 (closed) - the same class of caps for the account-archive import; nothing covers the wheel reader.
tiagoagueda added this to the 1.0.0 milestone 2026-10-17 10:38:43 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#969
No description provided.