Skip to content

PYTHON-5805 CSFLE/QE Support for HTTP Proxies - #3002

Draft
blink1073 wants to merge 34 commits into
mongodb:mainfrom
blink1073:PYTHON-5805
Draft

PYTHON-5805 CSFLE/QE Support for HTTP Proxies#3002
blink1073 wants to merge 34 commits into
mongodb:mainfrom
blink1073:PYTHON-5805

Conversation

@blink1073

@blink1073 blink1073 commented Aug 21, 2026

Copy link
Copy Markdown
Member

PYTHON-5805

Changes in this PR

Lets CSFLE and Queryable Encryption tunnel KMS traffic through an HTTP proxy, which networks that force outbound 443 through a proxy currently make impossible. The caller opens the connection and the driver still performs the KMS TLS handshake, so verification targets the KMS host rather than the proxy.

  • Adds kms_connect_callback on AutoEncryptionOpts, ClientEncryption and AsyncClientEncryption, plus KMSConnectContext.
  • Adds HTTPProxyKMSConnect and AsyncHTTPProxyKMSConnect, so most users pass a helper instead of writing a callback.
  • Splits TLS wrapping out of the connect helpers in pymongo/pool_shared.py, leaving existing connection paths unchanged.
  • Rejects a callback that returns the wrong thing without retrying. The cause is a ConfigurationError, which public operations surface as an EncryptionError wrapping it. Network errors stay retryable.
  • Adds the six prose tests from section 28 of the client-side-encryption test specification.
  • Skips prose case 5 as a known specification discrepancy, tracked in PYTHON-6037.

Mostly tests: 1411 of the 1878 added lines, and 797 of the total is the synchro-generated mirror.

Test Plan

Prose tests against real AWS KMS through the drivers-evergreen-tools proxies, both flavors: 10 passed, 2 skipped, 0 failed. The proxy logged connect_target kms.us-east-1.amazonaws.com:443. Unit tests: 41 passed, 1 skipped. just lint, just typing and just docs clean.

Evergreen patch, 7 encryption variants across RHEL8, macOS and Windows: https://spruce.corp.mongodb.com/version/6a873cdeb7e195000786c854

Checklist

Checklist for Author

  • Did you update the changelog (if necessary)?
  • Is there test coverage?
  • Is any followup work tracked in a JIRA ticket? If so, add link(s).

Checklist for Reviewer

  • Does the title of the PR reference a JIRA Ticket?
  • Do you fully understand the implementation? (Would you be comfortable explaining how this code works to someone else?)
  • Is all relevant documentation (README or docstring) updated?

Implements all six cases of spec section 28 "KMS Connect Callback":
plain and TLS proxy tunneling via kms_connect_callback, auto encryption
through a proxy, callback error propagation, timeout visibility on
KMSConnectContext, and retry after a callback network error.
… 5 gap

The docstring promised the driver could pass timeout=None, but the only
producer (max(_csot.clamp_remaining(...), 0.001)) is always a positive
float. Reworded to describe actual behavior without narrowing the
Optional[float] type. Also added a comment on the case 5 timeout
assertion in TestKmsConnectCallbackProse recording that explicit
ClientEncryption operations set no CSOT deadline, so timeoutMS on the
key-vault client does not currently tighten the value asserted.
Case 5 asserts the KMS connect callback receives a non-zero timeout. That
cannot fail in PyMongo: ClientEncryption does not support timeoutMS and
explicit encryption operations establish no CSOT deadline, so the callback
always receives the default KMS connect timeout. Skip the case rather than
leave it passing vacuously, and record the deviation on KMSConnectContext,
which the CSOT specification requires for any blocking section timeoutMS
does not cover. Tracked in PYTHON-6037.
Factor the duplicated HTTP CONNECT handshake out of the two
KMSConnectContext examples so the TLS-proxy example shows only what is
different about it, the socketpair relay. Trim the CSOT deviation note,
the changelog entry, and the case 5 comment, which restated the reason
already carried by the skip decorator. No behavior change.
…pwire

Raise ConfigurationError for kms_connect_callback contract violations
instead of a private sentinel exception. It is the right public type for
a misconfigured callback, and the no-retry clause in kms_request now keys
off it.

Remove the flavor-specific 'Must be a coroutine function.' sentence from
the ClientEncryption parameter docs. KMSConnectContext already documents
both flavors, so the synchro replacement entry and the tripwire that
guarded it are no longer needed. Rewording the awaitable-guard message
also removes the split string literals that dodged synchro's rewriting.
ssl.SSLContext.wrap_socket refuses a non-blocking socket, and a callback
has no reason to care which mode it leaves the socket in. Set the timeout
in _connect_kms rather than pushing the requirement onto the caller.

Do it there and not in _async_wrap_socket_tls, which is shared with every
MongoDB connection and currently handshakes under the connect-derived
timeout; forcing socket_timeout for all callers would change behavior on
that path.

Narrow the docstring accordingly. The real constraint is a real socket
the event loop is not managing, not the blocking mode: asyncio streams
and transports are not sockets, and the socket under one stays
registered with the loop.
transport.get_extra_info('socket') returns an asyncio TransportSocket,
which is not a socket.socket, so the existing contract check already
rejects it. Say so in the message and point at loop.sock_connect, rather
than leaving the caller to work out why their socket was refused.

Also correct the docstring: loop.sock_connect leaves an ordinary socket
behind once it completes, so it is usable here. Only streams, transports,
and the transport socket underneath them are not.
Merge the two KMSConnectContext paragraphs that both described what the
callback returns, and shorten the asyncio guidance to the three facts a
caller needs.

Shorten the contract error: an exception should point at the mistake, not
restate the docstring.

Drop four test comments that the assertion on the next line already
states, and condense the ones that explain something non-obvious.
Writing a callback by hand meant implementing the CONNECT handshake and,
for a TLS proxy, a socketpair bridge with two relay threads, because
Python cannot layer TLS over an ssl.SSLSocket. That is the most delicate
code in the feature and every user would have copied it from a docstring.

Ship it instead. HTTPProxyKMSConnect and its async variant handle plain
and TLS proxies and are usable directly as kms_connect_callback. The
callback option is unchanged and remains the escape hatch for cases the
helper does not cover, such as proxy authentication.

The prose tests now drive the shipped class rather than a private copy,
so the spec tests cover the public API. KMSConnectContext's docstring
drops from 117 lines to 44, since it no longer carries two worked
examples.
asyncio.to_thread is run_in_executor(None, ...) plus a contextvars copy.
The propagation buys nothing here, since the helper takes its timeout as
an argument and never reads the CSOT contextvar in the thread, so the
only effect was introducing a second idiom for a job auth_oidc.py already
does one way when it runs a user-supplied callback off the loop.
The worked CONNECT example moved to HTTPProxyKMSConnect when the helper
landed, so the cross-reference to KMSConnectContext was stale.
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.22798% with 15 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
pymongo/encryption_options.py 88.78% 12 Missing ⚠️
pymongo/synchronous/encryption.py 92.30% 2 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

Adds unit tests for the TLS-proxy bridge, a proxy that hangs up before
replying, and a plain def passed to the async API. The last closes a gap
a reviewer flagged: the coroutine guard had no permanent test.
A bulk read of the CONNECT response could swallow bytes a proxy sent in
the same segment, and the driver reads those from the same socket. Read
to the header boundary instead. Also close the stub proxies' sockets, so
the tests raise no ResourceWarning.

Copilot AI 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 HTTP proxy tunneling for CSFLE and Queryable Encryption KMS traffic while preserving end-to-end KMS TLS verification.

Changes:

  • Adds synchronous/asynchronous KMS connection callbacks and HTTP proxy helpers.
  • Separates socket connection from TLS wrapping.
  • Adds unit, integration, generated mirror tests, and documentation.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
pymongo/encryption_options.py Defines callback APIs, context, and proxy helpers.
pymongo/asynchronous/encryption.py Integrates callbacks into asynchronous KMS requests.
pymongo/synchronous/encryption.py Provides the generated synchronous integration.
pymongo/pool_shared.py Extracts TLS wrapping from connection creation.
test/asynchronous/test_encryption.py Tests asynchronous callbacks and proxy tunneling.
test/test_encryption.py Provides synchronous encryption tests.
test/asynchronous/test_pooling.py Tests asynchronous TLS wrapping.
test/test_pooling.py Provides synchronous TLS-wrapping tests.
tools/synchro.py Adds mappings for new asynchronous symbols.
doc/changelog.rst Documents HTTP proxy support.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pymongo/encryption_options.py
Comment thread pymongo/encryption_options.py
Comment thread pymongo/encryption_options.py
Comment thread pymongo/asynchronous/encryption.py
Comment thread pymongo/synchronous/encryption.py
Comment thread pymongo/asynchronous/encryption.py
Comment thread pymongo/synchronous/encryption.py
Bracket IPv6 hosts in CONNECT, cap the response header, and share one
deadline across connect, proxy TLS and the tunnel rather than giving each
phase the full budget. Reject an unconnected socket, which TLS accepts and
then fails as a retryable error. Compute the KMS timeout after
libmongocrypt's retry backoff, not before.

Copilot AI 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 10 out of 10 changed files in this pull request and generated 4 comments.

Suppressed comments (3)

Previously missed (3) — in code that hasn't changed since the last review.

pymongo/encryption_options.py:198

  • Thread startup is not failure-atomic. If socket.socketpair() or either Thread.start() fails (for example under descriptor or thread exhaustion), _bridge raises after the caller's close guard and can leave the TLS proxy socket, socketpair, and possibly the first relay thread alive. Add cleanup for every partially created resource before propagating startup failures.
        for pair in ((relay_side, proxy), (proxy, relay_side)):
            threading.Thread(target=relay, args=pair, daemon=True).start()

test/asynchronous/test_encryption.py:2356

  • This async helper performs synchronous http.client connect, request, and response reads on the event-loop thread, with no connection timeout. A slow or unavailable proxy can block the entire async test loop indefinitely. Run the complete blocking transaction in a worker thread (or use async networking) while preserving the generated synchronous variant.
            conn = http.client.HTTPSConnection(
                f"{KMS_PROXY_HOST}:{KMS_TLS_PROXY_PORT}", context=ctx
            )
        else:
            conn = http.client.HTTPConnection(f"{KMS_PROXY_HOST}:{KMS_PROXY_PORT}")
        try:
            conn.request(method, path)
            return conn.getresponse().read().decode()

test/asynchronous/test_encryption.py:333

  • This coroutine callback calls blocking socket.create_connection directly, which can stall the event loop during DNS resolution or connection setup. Use the event loop's nonblocking socket connection API or offload the connect before returning the deliberately nonblocking socket.
        async def callback(context):
            sock = socket.create_connection(listener.getsockname(), timeout=10)
            sock.setblocking(False)
            return sock

Comment thread pymongo/asynchronous/encryption.py
Comment thread pymongo/synchronous/encryption.py
Comment thread test/asynchronous/test_encryption.py Outdated
Comment thread pymongo/encryption_options.py Outdated
Reject datagram sockets, which pass the connected check and then fail TLS
as a retryable error. Make the callback timeout a plain float, matching
its documentation. Clean up in _bridge when thread startup fails, and keep
blocking work in the test helpers off the event loop.

Replace the non-retry test: it drove _connect_kms, which has no retry
loop, so it could not have caught a regression. It now drives kms_request.

Copilot AI 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 10 out of 10 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

pymongo/encryption_options.py:244

  • A task cancellation here cancels the asyncio Future but cannot stop an already-running executor thread. If super().__call__ subsequently succeeds, its connected tunnel socket is discarded without being closed; for HTTPS proxies this can also leave both relay threads alive. Retain/shield the worker future and arrange to close its eventual socket result when the awaiting task is cancelled.
        return await asyncio.get_running_loop().run_in_executor(None, connect)

pymongo/encryption_options.py:228

  • If socket.socketpair() fails before _bridge reaches its cleanup block (for example under file-descriptor exhaustion), this already-connected TLS proxy socket is leaked. Ensure every _bridge failure closes sock.
        return self._bridge(sock)

Comment thread pymongo/asynchronous/encryption.py
Close the proxy socket when _bridge fails at socketpair, not only at
thread start. Shield the executor future so a cancelled await still closes
the socket its thread goes on to open.

Also test the public error type: _wrap_encryption_errors turns the
ConfigurationError into an EncryptionError, so callers see the latter.
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.

2 participants