Repository navigation
objstore: refuse a chunk size that wraps the response bound (heap overflow) - #2039
Conversation
…rflow) A chunked HTTP reply could make the object-store reader write past its buffer. os_read_body bounded each chunk with `total + sz > want`, a signed sum. After a 1-byte chunk, the size 7fffffffffffffff wrapped the sum negative, the bound passed, and os_read_exact wrote the rest of the reply past the want-sized buffer. A hostile allow-listed endpoint, or anyone who could change a plain http:// reply, killed the backend with SIGSEGV. os_slurp_body (Iceberg REST client, S3 LIST) had the same sum and raised XX000 from enlargeStringInfo instead of its cap error. Both readers now parse the size with os_chunk_size: the line must start with a hex digit, and strtoull must read a size that fits int64, else "bad chunk size" (08P01). Each bound compares the size with the room left, `sz > want - total` and `sz > cap - s.len`, which cannot overflow. test_objstore_chunked_framing.py drives both readers from its own server on 127.0.0.1: each hostile reply raises its guard's SQLSTATE and the same connection still answers SELECT 1, and a valid chunked reply still reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OffgridwithJD
left a comment
There was a problem hiding this comment.
Reviewed at 90ef97a.
Verified (PG 18.6, a private prefix copy, new worktree)
- Build:
make+make install, 0 warnings. - Green at the head:
pytest test_objstore_chunked_framing.pygives4 passed,accounting: 10 pass + 0 fail + 0 unrun = 10. - Red with main's
objstore/columnar_objstore_module.crestored (git checkout origin/main -- objstore/columnar_objstore_module.c, rebuilt, reinstalled):2 failed, 2 passed,accounting: 6 pass + 2 fail + 0 unrun = 8. The ranged-GET arm fails withserver closed the connection unexpectedly(the backend crashed), and the REST arm fails onXX000. The same two tests fail as in the PR body. - Fix restored and rebuilt, then the new file with
test_objstore_http_read.py,test_objstore_listing.pyandtest_iceberg_rest*.py:342 passed,accounting: 339 pass + 0 fail + 9 unrun = 348. - Registry:
--collect-onlygives6214 tests collected. 6214 - 863 = 5351 matches the newcluster_tests 5351. TESTS.md has the new section 356 and its index entry.test/docs_style.sh: 54/54 PASSED. - Code read:
os_chunk_sizerefuses a line with no leading hex digit, so it cannot skip blanks or take a minus sign. It also refuses ERANGE and anything above INT64_MAX. Both bounds now subtract (sz > want - total,sz > cap - s.len), and thetotal <= want/s.len <= capinvariants hold on every loop iteration, because each iteration adds at most the room that was left. All threeos_slurp_bodycallers pass caps far below INT_MAX, so the(int) szpassed toenlargeStringInfocannot truncate after the check.
Blocking
- None.
Non-blocking
- On the discard path (
dst == NULL), a huge chunk size still makesos_read_exactread until the peer closes. This is not a memory-safety issue and behaves as main does. Noted only because the error-page path has no cap.
Approving at 90ef97a (CI was 11/15 green at review time, with the rest pending).
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@OffgridwithJD Thanks for the approval at The one hand resolution, in TESTS.md: main's Checked on the merge: (Your #2041 also adds a TESTS.md section, so whichever of us lands second renumbers.) 🤖 Generated with Claude Code |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@OffgridwithJD Your #2041 landed and took TESTS.md section 357, the number this PR had. I merged main in again at 🤖 Generated with Claude Code |
#2042 took TESTS.md section 358, so this branch's section is now 359. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@OffgridwithJD This needs your re-approval at the new head. I merged main after #2042 landed, and this branch's TESTS.md section is now 359; no code changed. On the merge I ran test_docs_cover_the_corpus.py, test_harness_deps.py, test_sanitizer_gate.py and this PR's own test file on PG18, and all passed. The PR now links its tracking issue. |
OffgridwithJD
left a comment
There was a problem hiding this comment.
Approved at 5c64889. Since my approval at 90ef97a, the only changes are merges of main.
The change itself is unchanged. The code and test diff against the merge base is byte-identical to what I approved at 90ef97a. I compared objstore/, src/ and the new test file between the two. CI is 15 of 15 on this head.
The merges. They only re-register the change on 17d9287: TESTS.md §359 and the cluster_tests line.
Needs another restate. #2043 merged as 85cd464 and took §359, so this head now shows CONFLICTING. It also collides with the other of #2039/#2040 on the same lines. After rebasing onto main, this goes to §360 or later; my docs-audit PR also wants a number, and whichever lands later takes the next one. Ping me and I'll re-approve the restated head quickly.
Nit. The cluster_tests comment's arithmetic doesn't add up: 6214 tests collected, less guard_tests 863 gives 5351, but the line says 5354`. Main's guard_tests is no longer 863. The count is what CI collection checks, so only the comment is stale.
🤖 Generated with Claude Code
|
@OffgridwithJD Thanks for approving 5c64889. #2043 and #2047 merged right after, so the branch conflicted and couldn't merge. It's now at 64c44f9: main merged in, this PR's TESTS.md section renumbered to 360, and no code change. On the merge, |
|
@jdatcmd Re-reviewed at 64c44f9. Its code and tests are identical to what I approved at 5c64889: outside TESTS.md, expected_tests.txt and the CHANGELOG, the diff against main matches line for line. All 15 checks pass. It conflicts with main again, though. #2050 merged as f2075fd and took TESTS.md §360, which this PR claims for
Once it's restated I'll re-approve straight away, since there's no code left to review. 🤖 Generated with Claude Code |
|
@OffgridwithJD I merged main into this branch again; the new head is 96b8522. Your approval was on an earlier commit, so please re-review. What the merge brings in#2049, #2050 and #2051. The only conflict was Full pytest suite at this head
The unrun tests need a real S3 endpoint, as before. #2040 and your #2052 also want §361. Whichever merges later renumbers. 🤖 Generated with Claude Code |
Brings in #2052. TESTS.md conflicted on section 361: main's test_architecture_map.py keeps 361, and this branch's section becomes 362. cluster_tests is 5365 by collection; guard_tests is main's 894. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@OffgridwithJD I merged main into this branch again after #2052 landed; the new head is eaaaa8b. Please re-review. What the merge brings in#2052. The only conflict was Full pytest suite at this head
The unrun tests need a real S3 endpoint. #2040 also has a §362. Whichever merges second, I'll renumber. 🤖 Generated with Claude Code |
OffgridwithJD
left a comment
There was a problem hiding this comment.
Adversarial re-review at eaaaa8b: approved, with one test gap I'd close before merge.
What I checked
- Against my approval at 5c64889, the code is unchanged. Only the TESTS.md number moved (now §362).
- I built this head on PG 18.6 and ran
test_objstore_chunked_framing.py: 4 passed. - Mutant 1: restored the old summed bounds (
total + sz > want,s.len + sz > cap) and kept the new parser. The backend crashes, and both wrap tests fail (..._wraps_after_a_first_chunk_..._ranged_read,..._wraps_the_cap_..._rest_catalog_reply). Two runs, both deterministic. These tests do pin the bug. - Invariants:
total <= wantands.len <= caphold on entry to each check. Both grow only by anszthat has just passed.(int) szinos_slurp_bodyis bounded bycap, and every cap is far belowINT_MAX. The discard path (dst == NULL) takes any size and only reads until EOF. - The ledger is right by collection: 6259 collected less 894 guards = 5365. The comment's "6214 … less 863" is stale arithmetic, but the count is correct.
The gap: nothing tests the new parser's refusals
- Mutant 2: replaced
os_chunk_size's body withreturn strtoll(line, NULL, 16);. All 4 tests still pass. - The first-character check is what refuses
-1. Without it,os_read_bodywould dototal += -1and write the next chunk atdst - 1: a heap underwrite, the mirror of this bug. The old code had asz < 0check, so this guard was never tested before either. - The past-64-bits test doesn't catch it, because the bound refuses
LLONG_MAXanyway. - Suggested fix: one more ranged-read case framed as
b"1\r\nA\r\n-400\r\n" + FLOOD, expecting 08P01 with the backend alive. A leading-blank case (b" 1\r\n") would also cover the "strtoull skips blanks" sentence in the comment.
Non-blocking
strtoull(…, 16)accepts a0xprefix, so0x10reads as 16. RFC 9112 has no prefix. This is harmless, since the bounds still apply.- These are a separate class, outside this PR.
Content-LengthandContent-Rangetotal are stillstrtoll(…, NULL, 10)with noendorERANGEcheck (lines 1196, 1243). A negative length becomes "absent" and a huge one becomesLLONG_MAX. I didn't find an overflow from either, buth->len = LLONG_MAXfrom a HEAD then feeds the range arithmetic. Worth a follow-up issue if you agree.
Numbering: #2040 also takes §362, so whichever merges second renumbers to §363 and restates (cluster_tests 5376 with both).
Brings in #2039. TESTS.md conflicted on section 362: main's test_objstore_chunked_framing.py keeps 362, and this branch's section becomes 363. cluster_tests is 5376 by collection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes #2044.
What an attacker could do
A remote read could write past its buffer in the backend. The attacker is either:
pgcolumnar.objstore_allowed_endpoints, orhttp://reply in transit.An
https://reply cannot be changed in transit, so only the endpoint itself can send it. The allow-list is superuser-only and empty by default.The reply sends a chunked body: a 1-byte chunk, then the chunk size
7fffffffffffffff, then the bytes to write.os_read_bodybounded each chunk withtotal + sz > want, a signed sum. PostgreSQL builds with-fwrapv, so1 + INT64_MAXwrapped toINT64_MIN, the bound passed, andos_read_exactwrote every byte the server sent past thewant-sized buffer. The backend died with SIGSEGV, and the postmaster restarted every session.os_slurp_body(the Iceberg REST client and S3 LIST) had the same sum,(int64) s.len + sz > cap. There the(int) szcast gaveenlargeStringInfoa negative size, so it raisedXX000instead of crashing.The fix
os_chunk_sizeparses the size for both readers. The line must start with a hex digit, sostrtoullcannot skip blanks or negate a minus sign. A size that does not fitint64is refused with the existingbad chunk sizeerror (08P01).sz > want - totalandsz > cap - (int64) s.len.total <= wantands.len <= caphold on entry, so neither side can overflow. The error texts are unchanged.Red, on main's code (PG18, before any C change)
The second test (a first chunk size past 64 bits) passed on main too:
strtollclamps it toINT64_MAX, and with no earlier chunk the sum does not wrap. It pins the new parser's refusal.Green
New file, PG18 and PG17:
The new file with
test_objstore_http_read.py,test_objstore_s3_read.py,test_objstore_listing.py,test_objstore_tls_read.py,test_objstore_credentials.pyandtest_iceberg_rest*.py, on each major:The 10 unrun checks are the real-S3 integration arms, which decline without their environment variables, as on main.
makeon PG15, 16, 17, 18 and 19beta4, 0 warnings each.--pgc-expect-tests 863,863 passed.census-commits ... 0 refused. The ledger tracks shell suites, so this pytest-only change adds no row.Mutants (PG18, each restored after)
if (0))server closed the connection unexpectedlygot SQLSTATE 'XX000' want '54000'if (0))got SQLSTATE 'XX000' want '54000'Behaviour change
A chunk-size line that does not start with a hex digit is now refused with
bad chunk size. This covers an empty line, which used to read as the last chunk, and a line with a leading blank. RFC 9112 section 7.1 requires at least one hex digit (1*HEXDIG).Registries
expected_tests.txt:cluster_testsgoes from 5347 to 5351, by collection (6214 collected, less 863 guard tests).TESTS.md: new entry 356.CHANGELOG.md: added under Unreleased, Fixed. There is no Security section.🤖 Generated with Claude Code