') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); PYTHON-5923 Add remaining-buffer bound check in _array_of_documents_to_buffer by AgentGymLeader · Pull Request #2872 · mongodb/mongo-python-driver · GitHub
Skip to content

PYTHON-5923 Add remaining-buffer bound check in _array_of_documents_to_buffer - #2872

Merged
NoahStapp merged 8 commits into
mongodb:mainfrom
AgentGymLeader:harden-array-of-documents-oob
Jul 22, 2026
Merged

PYTHON-5923 Add remaining-buffer bound check in _array_of_documents_to_buffer#2872
NoahStapp merged 8 commits into
mongodb:mainfrom
AgentGymLeader:harden-array-of-documents-oob

Conversation

@AgentGymLeader

@AgentGymLeaderAgentGymLeader commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

PYTHON-5923

What

Adds a single upper-bound check on the per-element embedded-document length
(value_length) in _cbson_array_of_documents_to_buffer (in
bson/_cbsonmodule.c), immediately before the pymongo_buffer_write /
memcpy call that copies the element into the output buffer.

if (value_length >= (uint32_t)(size-position)) {
PyObject*InvalidBSON=_error("InvalidBSON");
if (InvalidBSON) {
PyErr_SetString(InvalidBSON, "invalid array content");
Py_DECREF(InvalidBSON);
}
goto fail;
}

Why

_cbson_array_of_documents_to_buffer is the C fast-path used by
Collection.find_raw_batches() and aggregate_raw_batches() to convert a
raw BSON array (from a server firstBatch/nextBatch) into a flat stream
of BSON documents.

The function reads a per-element length value_length from the raw bytes and
then passes it directly to pymongo_buffer_write(buffer, string + position, value_length). Before this patch it only checked the lower bound:

if (value_length<BSON_MIN_SIZE) { … goto fail; }

There was no corresponding upper-bound check that value_length does not
exceed the bytes remaining in the array document (size - position). A
crafted raw-batch reply — from a malicious or compromised server — with an
inner value_length larger than the remaining buffer causes
pymongo_buffer_writememcpy to read past the end of the heap allocation,
constituting an out-of-bounds read.

The pure-Python counterpart _get_object_size in bson/__init__.py already
enforces this invariant. The C fast-path dropped it.

The loop already guarantees position < size and
(size - position) >= BSON_MIN_SIZE via the prior guard (line ~3221), so the
subtraction size - position cannot underflow (both variables are uint32_t
and position < size is assured).

This belongs to the same bug class as CVE-2024-5629 (out-of-bounds read in
the bson C extension, fixed in 4.6.3) but affects a different function
(_cbson_array_of_documents_to_buffer vs the one patched in 4.6.3).

Change scope

  • bson/_cbsonmodule.c: the upper-bound guard block itself (9 lines added).
  • test/test_bson.py: two regression tests for the new guard (39 lines added) — one for the oversized-length case, one for the boundary case where the embedded document's declared length exactly consumes the array's own terminator byte.
  • doc/changelog.rst: changelog entry for the fix (5 lines added).
  • Also includes a whitespace-only ruff-format fix to a slice expression in one of the new tests in test/test_bson.py, to match the file's existing style; no logic changes.

@AgentGymLeader
AgentGymLeader marked this pull request as ready for review June 14, 2026 06:50
@AgentGymLeader
AgentGymLeader requested a review from a team as a code ownerJune 14, 2026 06:50
@AgentGymLeader

Copy link
Copy Markdown
ContributorAuthor

Hi team! Could a maintainer please authorize the Evergreen CI patch run when you get a chance? No rush at all — just wanted to flag it so the checks can get started. Thanks so much! 🙏

@NoahStapp

Copy link
Copy Markdown
Contributor

Hi @AgentGymLeader,

Your new test already passes on master. Can you provide a reproduction script that shows this issue can actually occur?

@AgentGymLeader

Copy link
Copy Markdown
ContributorAuthor

Hi @NoahStapp, thanks for taking a look!

You're right that the new test passes on master — but I believe the issue is that the test verifies the added code path works, while the underlying heap over-read it prevents can still occur on unpatched builds.

Here's a minimal script to illustrate the difference:

importstructimportbson._cbsonasc# BSON array where one element declares value_length=50,# but only 7 bytes remain in the input buffer.outer_size=18data=struct.pack('<I', outer_size) # outer BSON size (4 bytes)data+=b'\x03'# type = embedded documentdata+=b'0\x00'# key "0"data+=struct.pack('<I', 50) # declared sub-doc length = 50 ← lie: only 7 bytes remaindata+=b'\x00'*6# partial content (6 bytes)data+=b'\x00'# outer null terminatortry:
c._array_of_documents_to_buffer(data)
exceptExceptionase:
print(e)
# Without this PR: "bad object or element length"# ↑ caught AFTER pymongo_buffer_write reads 50 bytes from a 7-byte window# With this PR: "invalid array content"# ↑ caught BEFORE the over-read, at the new bounds check

The problem is that pymongo_buffer_write(buffer, string + position, value_length) runs before position += value_length triggers the final position != size - 1 guard. So the heap read already happens — it's just caught downstream rather than at the point of the read.

To confirm the over-read itself: building with -fsanitize=address on a build that does not include this PR should surface an AddressSanitizer heap-buffer-overflow on the crafted input above.

Happy to add an ASan-annotated test or adjust the repro if that would help!

@NoahStapp

Copy link
Copy Markdown
Contributor

Please adjust the test so that it correctly tests only the fix's new error rather than passing with the existing fallback error on master.

@AgentGymLeader
AgentGymLeaderforce-pushed the harden-array-of-documents-oob branch from ea14897 to b432ee7CompareJune 17, 2026 00:03
@AgentGymLeader

Copy link
Copy Markdown
ContributorAuthor

@NoahStapp good catch, thanks. Tightened it to assertRaisesRegex(InvalidBSON, "invalid array content") so it keys off the message the new bound check raises. Without the guard that same input ends up on the old fallback path with a different InvalidBSON, so the test fails on master now and only goes green once the check is in. Valid-buffer case is untouched. Force-pushed.

@AgentGymLeader
AgentGymLeaderforce-pushed the harden-array-of-documents-oob branch from b432ee7 to c3e9de2CompareJune 17, 2026 07:16
AgentGymLeaderand others added 2 commits June 18, 2026 06:49
In `_cbson_array_of_documents_to_buffer`, the per-element embedded-document
length `value_length` (read from server-controlled raw-batch BSON bytes) was
checked only for a lower bound (`value_length < BSON_MIN_SIZE`) before being
passed to `pymongo_buffer_write(buffer, string + position, value_length)`,
which calls `memcpy` from the source buffer. There was no upper-bound check
that `value_length` fits within the remaining array bytes, so a crafted reply
(firstBatch/nextBatch) with an oversized inner `value_length` causes an
out-of-bounds read from the heap. The pure-Python twin `_get_object_size` in
`bson/__init__.py` already validates this invariant; the C fast-path dropped it.
This commit adds the missing guard immediately before the `pymongo_buffer_write`
call, mirroring the adjacent error-handling style and the existing lower-bound
block. The loop already guarantees `position < size` and
`(size - position) >= BSON_MIN_SIZE` via the prior check, so the subtraction
`size - position` is safe (no uint32 underflow).
The bug is reachable from `Collection.find_raw_batches()` and
`aggregate_raw_batches()` when the driver connects to a malicious or
compromised server. It belongs to the same bug class as CVE-2024-5629
(OOB read in the bson C extension, fixed in 4.6.3) but affects a different
function.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Asserts an embedded-document length larger than the remaining array bytes raises InvalidBSON, and that a valid buffer still decodes.
@AgentGymLeader
AgentGymLeaderforce-pushed the harden-array-of-documents-oob branch from c3e9de2 to bc7b6f2CompareJune 17, 2026 21:51
@AgentGymLeader

Copy link
Copy Markdown
ContributorAuthor

@NoahStapp After my latest push, Evergreen is still showing patch must be manually authorized, so the CI run hasn't kicked off.

Could you authorize the patch run when you have a moment? Per your earlier note, the test now checks for the new invalid array content rather than the existing fallback error.

@codeowners-service-app

codeowners-service-appBot commented Jun 22, 2026

Copy link
Copy Markdown

Assigned blink1073 for team dbx-python because NoahStapp is out of office.
Assigned sleepyStick for team dbx-python because NoahStapp is out of office.

@AgentGymLeader

Copy link
Copy Markdown
ContributorAuthor

Hi @blink1073, thanks for picking this up. The test tightening NoahStapp asked for is already in — it now asserts on the invalid array content message the new bound check raises, instead of passing through the old fallback path. The one thing still blocking is Evergreen showing patch must be manually authorized, so CI hasn't actually run yet. Could you authorize the patch run when you get a chance? Happy to take another look after that if anything needs adjusting.

@NoahStapp
NoahStapp removed the request for review from blink1073June 30, 2026 15:04
Signed-off-by: FugoP <264910004+AgentGymLeader@users.noreply.github.com>

@NoahStappNoahStapp 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.

Please add a changelog entry calling out this fix, otherwise looks great, thank you!

@AgentGymLeader

Copy link
Copy Markdown
ContributorAuthor

Thanks @NoahStapp! Added a changelog entry calling out the fix.

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@blink1073blink1073 changed the title Add remaining-buffer bound check in _array_of_documents_to_bufferPYTHON-XXXX Add remaining-buffer bound check in _array_of_documents_to_bufferJul 8, 2026
@Jibola
Jibola marked this pull request as draft July 8, 2026 21:56
@Jibola
Jibola marked this pull request as ready for review July 8, 2026 21:57
CopilotAI review requested due to automatic review settings July 8, 2026 21:57

CopilotAI 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.

Pull request overview

Adds a defensive bounds check to the BSON C-extension fast-path that flattens an array of embedded documents into a contiguous BSON stream, preventing potential out-of-bounds reads on malformed/crafted input.

Changes:

  • Add an upper-bound check for per-element embedded document length in bson/_cbsonmodule.c before copying bytes into the output buffer.
  • Add a regression test that crafts an embedded document length larger than the remaining bytes and asserts InvalidBSON.
  • Document the fix in the 4.18.0 changelog.

Reviewed changes

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

FileDescription
bson/_cbsonmodule.cAdds an upper-bound guard for value_length to prevent reading past the array buffer.
test/test_bson.pyAdds a regression test covering the oversized embedded-document length case.
doc/changelog.rstNotes the OOB-read fix in the 4.18.0 changelog.

Comment threadbson/_cbsonmodule.c Outdated
…terminator
Per review feedback: value_length == (size - position) also needs to be
rejected, not just value_length > (size - position). In that boundary case
the embedded document's declared length swallows the array document's own
trailing EOO byte, which no well-formed array-of-documents buffer can
produce, and which the existing post-loop `position != size - 1` check was
already rejecting -- just one step later, after an unnecessary copy.
Tightening the guard to `>=` fails before that copy and keeps the invariant
that at least one byte is always reserved for the array's own terminator.
Assisted-by: Claude Opus 4.8
Signed-off-by: FugoP <264910004+AgentGymLeader@users.noreply.github.com>
@JibolaJibola changed the title PYTHON-XXXX Add remaining-buffer bound check in _array_of_documents_to_bufferPYTHON-5923 Add remaining-buffer bound check in _array_of_documents_to_bufferJul 9, 2026
@NoahStapp
NoahStapp requested review from NoahStapp and Copilot and removed request for sleepyStickJuly 9, 2026 14:50

CopilotAI 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.

Pull request overview

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

Comment threadtest/test_bson.py
Comment threaddoc/changelog.rst
NoahStapp
NoahStapp previously approved these changes Jul 10, 2026

@NoahStappNoahStapp 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.

Thanks!

CopilotAI 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.

Pull request overview

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

@NoahStappNoahStapp 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.

Thanks!

@NoahStapp
NoahStapp merged commit a9695f1 into mongodb:mainJul 22, 2026
33 checks passed
Sign up for freeto 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.

4 participants

@AgentGymLeader@NoahStapp@codecov-commenter