Skip to content

deps: run DuckDB 1.5.5 with the Brotli CVE-2026-111497 fix - #158

Draft
fuziontech wants to merge 1 commit into
mainfrom
fix/duckdb-patched-wheel-cve-2026-111497
Draft

fuziontech wants to merge 1 commit into
mainfrom
fix/duckdb-patched-wheel-cve-2026-111497

Conversation

@fuziontech

Copy link
Copy Markdown
Member

Draft — the wheel this points at is a fork build, so I'd like a second pair of eyes on the pin (and the canary) before this ships.

Why

Upstream DuckDB 1.5.5 still vendors the pre-fix Brotli decoder (third_party/brotli 1.1.0; upstream fix is duckdb/duckdb#27791, unmerged). CVE-2026-111497 makes that decoder memory-unsafe on crafted streams: DecodeBlockTypeAndLength returns a BROTLI_BOOL that conflates malformed stream with needs more input, so the BROTLI_SAFE fast path drops the error and continues with stale state. Reproduced against DuckDB 1.1.0 — the NEEDS_MORE_INPUT tail copy writes past the 8-byte literal buffer, the overflow index lands on buffer_length, and the attacker picks the offset, reaching ~18 controlled bytes across the decoder state and a controlled free() (PostHog/duckdb#8).

millpond reads parquet through this decoder on every INSERT ... SELECT over a ducklake table, so a brotli-compressed data file is attacker-influenced input into memory-unsafe C++ in the ingestion process. Nothing here writes brotli (pyhoglake.parquet_write_options allows only none/snappy/zstd), so the exposure is ingested data, not our own output.

What

PostHog/duckdb released v1.5.5-posthog.6 (same base version, plus the fix — ported byte-for-byte to DuckDB's C++ transliteration, plus the state-leak fix in column_reader.cpp). Point the two platforms this image ships at its wheels:

[tool.uv.sources]
duckdb = [
  { url = ".../v1.5.5-posthog.6/duckdb-1.5.5-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
  { url = ".../v1.5.5-posthog.6/duckdb-1.5.5-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64'" },
  { index = "pypi", marker = "(platform_machine != 'aarch64' and platform_machine not in 'x86_64,aarch64') or sys_platform != 'linux'" },
]

macOS/Windows developers keep the PyPI wheel via the fallback entry (source markers must be disjoint, hence the odd-looking complement). The [[tool.uv.index]] pypi block exists only so the fallback source can name it.

Dockerfile installs the CLI from the same release (ARG DUCKDB_RELEASE=v1.5.5-posthog.6) instead of duckdb/duckdb, so the binary in the image and the Python module are the same decoder — otherwise anything shelling out to duckdb keeps the bug.

Verification

  • Installed module: strings .venv/.../_duckdb.cpython-312-x86_64-linux-gnu.so | grep -c BLOCK_SWITCH → 1 (0 on PyPI 1.5.5 — that's the new error string the fixed decoder returns).
  • CLI asset: strings duckdb | grep -c BLOCK_SWITCH → 1, and it runs (select version()).
  • Canary: tests/unit/test_ducklake_pin.py passes — the ducklake extension loaded under the fork wheel reports the same build SHA we validated against, so millpond/ducklake.py / schema.py behaviour is unchanged.
  • tests/unit: 1445 passed, 1 xfailed.
  • uv sync fails loudly on a platform/ABI with no matching source instead of silently falling back, e.g. CPython 3.14 here: "duckdb … binary distribution is incompatible with the current platform". The image is 3.12, which matches the wheels.

Rollback

Drop the three duckdb entries from [tool.uv.sources] (and the now-unused index block), set DUCKDB_RELEASE back / restore the upstream URL, re-lock. That returns to the vulnerable decoder, so it's a "fork build misbehaves" escape hatch, not a security-equivalent one.

Refs #157, PostHog/duckgres#1267.

Upstream duckdb 1.5.5 (PyPI wheel and the upstream CLI release we installed)
still vendors the pre-fix Brotli decoder: DecodeBlockTypeAndLength returns a
BROTLI_BOOL that conflates a malformed stream with needs-more-input, so the
fast path carries on with stale state. A crafted brotli stream writes past the
decoder's literal buffer with an attacker-chosen offset.

millpond reads parquet through this wheel (INSERT ... SELECT over ducklake
tables), so a brotli-compressed data file is attacker-influenced input into
memory-unsafe C++ in the ingestion process.

Pin the PostHog build of the same version (v1.5.5-posthog.6), which carries the
fix, for the two platforms this image ships; everything else keeps resolving
duckdb from PyPI. Dockerfile installs the matching CLI from the same release.

Verified: installed _duckdb...so has the fixed decoder's error string
(strings | grep -c BLOCK_SWITCH == 1, 0 with PyPI 1.5.5); the ducklake
extension-pin canary still passes (same extension build SHA); tests/unit
1445 passed, 1 xfailed.

Co-authored-by: Shelley <shelley@exe.dev>
@fuziontech
fuziontech requested a review from a team October 24, 2026 18:31

@bill-ph bill-ph left a comment •

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.

Reviewed the PR description, diff, and existing discussion at 4205307. Checked platform source selection, lockfile consistency, release asset names and wheel hashes, Docker installation, and the existing DuckLake extension canary. Considered correctness, security, compatibility, missing coverage, overengineering, and scope creep. Tests, builds, linters, and dependency installs were not run, as requested.

No issues found.

— Robo Bill v2 (gpt-6.1-sol, high reasoning)

@fuziontech

fuziontech commented Oct 24, 2026 •

Copy link
Copy Markdown
Member Author

CI says this is not ready: integration and e2e fail with the fork wheel, while both are green on main (CI run 39996206374 — e2e success, integration success).

FAILED tests/integration/test_write_integration.py::TestVariantDualWrite::...
RuntimeError: Failed to create table main.events: TransactionContext Error:
Failed to commit: Could not convert string 'Etc/UTC' to UINT64

Reproduced locally in this branch's venv (uv run --python 3.12 --frozen pytest tests/integration/test_write_integration.py -k shreddable → 3 failed, 1 passed), so it is the wheel, not the runner. e2e fails earlier — docker compose up --build --wait never becomes healthy, consistent with the same commit-time error inside the service.

What that rules out and what it leaves:

  • Not the ducklake extension pin: tests/unit/test_ducklake_pin.py still passes, i.e. the extension build SHA loaded under the fork wheel is the one we validated against.
  • So it is the core build differing in how a catalog/table option value is typed on commit — a string (Etc/UTC, the session timezone) is being cast to UINT64 somewhere in the transaction commit path. Upstream 1.5.5 takes it as a string; the fork build does not.

Keeping this in draft until we know why. Two ways forward:

  1. Diff the fork against v1.5.5 for the option/value serialisation path (DuckLakeUtil::ValueToSQL / catalog settings commit) — if the fork wheel differs there, either fix it in PostHog/duckdb or base the security release on a clean upstream tree + only the brotli patch (which is what v1.5.5-posthog.6 was cut from — worth checking whether the extension it selects is the fork's own PostHog/ducklake build rather than the core-repo one).
  2. Wait for upstream duckdb to merge Pull in upstream Brotli decoder fix (CVE-2026-111497) duckdb/duckdb#27791 and take a normal PyPI bump, with PostHog/ducklake#48 now preventing new brotli files in the meantime.

@jghoman flagging you since this one is assigned to you: the security side is fine, the packaging side is not.

Narrowed down

I think I have the mechanism. DuckLakeUtil::CopyExtensionSettings (src/common/ducklake_util.cpp in PostHog/ducklake) copies settings between contexts by index, not by name:

for (auto &entry : db_config.GetExtensionSettings()) {          // name -> ConfigOption
    auto setting_index = option.setting_index.GetIndex();        // build-specific index
    Value value;
    if (!from.TryGetCurrentSetting(entry.first, value)) continue; // read by NAME  -> VARCHAR 'Etc/UTC'
    to.config.user_settings.SetUserSetting(setting_index, value); // write by INDEX -> lands on UINT64
}

Read by name, write by index. That is only sound when both contexts share the same setting table. With the fork wheel the index for TimeZone evidently lands on a UINT64 option, which is exactly the error: Could not convert string 'Etc/UTC' to UINT64 at commit time, i.e. a build-level mismatch in the setting table (the fork build registers a different set/order of extension settings than the extension binary it loads), not a problem with the brotli patch.

If that reading is right, the fix is either (a) make the copy name-based (SetUserSetting(DBConfig::GetOptionIndex(name), value) / SetOptionByName), which removes the latent dependency on build layout entirely, or (b) load a ducklake extension built against exactly this core build. (a) is worth doing regardless of this PR — it is a landmine for any future rebuild.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants