headroom/tests/test_ssl_context.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

313 lines
13 KiB
Python
Raw Normal View History

"""Unit tests for headroom.proxy.ssl_context.find_ca_bundle.
Covers:
- Returns None when no env var is set
fix(proxy): build SSL contexts for custom CA bundles (#1134) ## Description Build explicit `ssl.SSLContext` objects for custom CA bundles configured through `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE`. Python 3.13 / newer OpenSSL can reject some enterprise/private PKI roots that platform TLS stacks accept, for example roots without a `keyUsage` extension. The new custom-CA contexts keep certificate verification enabled while clearing only `ssl.VERIFY_X509_STRICT`. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Build replacement `SSLContext` objects for `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` instead of passing raw CA bundle paths to httpx. - Clear only `ssl.VERIFY_X509_STRICT` for operator-provided custom CA contexts; certificate verification, hostname verification, expiry checks, and chain validation stay enabled. - Preserve `NODE_EXTRA_CA_CERTS` additive behavior by loading the extra CA bundle on top of the default/system trust store. - Update existing SSL context tests for replacement CA contexts, env-var priority, missing-path fallthrough, and strict-mode relaxation. - Add an Unreleased changelog entry for the proxy bug fix. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ PYTHONPATH=. .venv/bin/python -m pytest tests/test_ssl_context.py -q collected 12 items tests/test_ssl_context.py ............ [100%] 12 passed, 1 warning in 0.11s ``` ```text $ uv tool run ruff check headroom/proxy/ssl_context.py tests/test_ssl_context.py CHANGELOG.md All checks passed! $ uv tool run ruff format --check headroom/proxy/ssl_context.py tests/test_ssl_context.py 2 files already formatted ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.2, Headroom checkout on this branch, `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` pointed at an enterprise/private PKI CA bundle. Upstream HTTPS endpoint name is redacted. - Exact command / steps: Ran a Python smoke script that imports `headroom.proxy.ssl_context.find_ca_bundle()`, passes the returned verifier into `httpx.AsyncClient(verify=...)`, and performs a GET against the enterprise HTTPS endpoint that previously failed with OpenSSL strict verification. - Observed result: The request used an `SSLContext`, strict X.509 verification was disabled for that custom CA context, and the request reached the upstream HTTP response: ```text verify_type SSLContext strict_enabled False status 302 ``` - Not tested: Full `uv run pytest`, `uv sync --extra dev`, and `mypy headroom` in this local environment. The editable build currently fails before tests run because Cargo's `ort-sys` build script cannot download ORT prebuilt binaries due an unrelated local certificate verification error against the ORT CDN. No dependency or lockfile changes are included in this PR. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes No dependencies or lockfiles changed. Documentation is unchanged because this is a bug fix to existing custom CA environment-variable behavior rather than a new user-facing configuration surface. Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-22 19:48:19 -04:00
- Returns an ssl.SSLContext when SSL_CERT_FILE points to a valid PEM file
- Returns an ssl.SSLContext when REQUESTS_CA_BUNDLE points to a valid PEM file
- Replacement contexts relax OpenSSL VERIFY_X509_STRICT for custom CA bundles
fix(proxy): treat NODE_EXTRA_CA_CERTS as additive, not replacement (#998) (#1031) ## Description `find_ca_bundle()` returns the `NODE_EXTRA_CA_CERTS` path as a bare string passed to httpx's `verify=` parameter, which makes it the sole trust store. When that bundle contains only a private/internal root (the common corporate setup), all public upstreams (`api.anthropic.com`, `api.openai.com`) fail TLS verification with `CERTIFICATE_VERIFY_FAILED`, returning 502. This is the inverse of #741: that fix added corporate CA support, but this regression means public CAs are no longer trusted when the extra bundle is not a full superset of the public roots. The fix builds an `ssl.SSLContext` via `create_default_context()` (keeps system/default roots) then `load_verify_locations()` (adds the extra cert), matching Node.js additive semantics. `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` keep their existing replacement semantics. Closes #998 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Split `NODE_EXTRA_CA_CERTS` handling out of the replacement-semantics loop in `find_ca_bundle()` - When `NODE_EXTRA_CA_CERTS` is the source, return an `ssl.SSLContext` with default roots plus the extra cert, instead of a bare path string - Set ALPN protocols (`h2`, `http/1.1`) on the context to preserve HTTP/2 negotiation - Updated `test_node_extra_ca_certs_returns_path` to assert `ssl.SSLContext` return type - Added `test_node_extra_ca_certs_is_additive` verifying the context contains more than just the extra cert (default roots preserved) ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] New tests added for new functionality ### Test Output ```text tests/test_ssl_context.py::TestFindCaBundleNoEnvVars::test_returns_none_when_no_env_var_set PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_ssl_cert_file_returns_path PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_requests_ca_bundle_returns_path PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_node_extra_ca_certs_returns_ssl_context PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_node_extra_ca_certs_is_additive PASSED tests/test_ssl_context.py::TestFindCaBundlePriority::test_ssl_cert_file_beats_requests_ca_bundle PASSED tests/test_ssl_context.py::TestFindCaBundlePriority::test_ssl_cert_file_beats_node_extra_ca_certs PASSED tests/test_ssl_context.py::TestFindCaBundlePriority::test_requests_ca_bundle_beats_node_extra_ca_certs PASSED tests/test_ssl_context.py::TestFindCaBundleNonexistentPaths::test_nonexistent_path_is_skipped PASSED tests/test_ssl_context.py::TestFindCaBundleNonexistentPaths::test_all_nonexistent_returns_none PASSED tests/test_ssl_context.py::TestFindCaBundleNonexistentPaths::test_first_nonexistent_falls_through_to_valid PASSED 11 passed in 0.91s ``` ## Real Behavior Proof - Environment: Windows 11 Home 10.0.26200, Python 3.10.18 - Exact command / steps: Ran `python -m pytest tests/test_ssl_context.py -v` after applying the fix. The new `test_node_extra_ca_certs_is_additive` test verifies `ctx.cert_store_stats()['x509_ca'] > 1`, confirming the default trust store roots are preserved alongside the extra cert. If replacement semantics were used, only the single test CA would be loaded. - Observed result: All 11 SSL context tests pass. The additive context reports 143 x509_ca certs (system defaults + test cert), confirming default roots are preserved. - Not tested: No live TLS handshake to a public upstream with a private-only `NODE_EXTRA_CA_CERTS` bundle, but the `cert_store_stats` assertion proves the default roots are loaded into the context. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes ## Additional Notes The fix aligns with the design proposed in #741 and #745's own description (which said it "builds an SSLContext") but which the merged implementation did not follow. The `server.py` consumer does not need changes because httpx accepts `ssl.SSLContext` for `verify=`. ALPN protocols are set on the context to maintain HTTP/2 negotiation parity with httpx's internally-built contexts.
2026-06-18 00:41:49 +08:00
- Returns an ssl.SSLContext when NODE_EXTRA_CA_CERTS points to a valid PEM file
fix(proxy): build SSL contexts for custom CA bundles (#1134) ## Description Build explicit `ssl.SSLContext` objects for custom CA bundles configured through `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE`. Python 3.13 / newer OpenSSL can reject some enterprise/private PKI roots that platform TLS stacks accept, for example roots without a `keyUsage` extension. The new custom-CA contexts keep certificate verification enabled while clearing only `ssl.VERIFY_X509_STRICT`. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Build replacement `SSLContext` objects for `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` instead of passing raw CA bundle paths to httpx. - Clear only `ssl.VERIFY_X509_STRICT` for operator-provided custom CA contexts; certificate verification, hostname verification, expiry checks, and chain validation stay enabled. - Preserve `NODE_EXTRA_CA_CERTS` additive behavior by loading the extra CA bundle on top of the default/system trust store. - Update existing SSL context tests for replacement CA contexts, env-var priority, missing-path fallthrough, and strict-mode relaxation. - Add an Unreleased changelog entry for the proxy bug fix. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ PYTHONPATH=. .venv/bin/python -m pytest tests/test_ssl_context.py -q collected 12 items tests/test_ssl_context.py ............ [100%] 12 passed, 1 warning in 0.11s ``` ```text $ uv tool run ruff check headroom/proxy/ssl_context.py tests/test_ssl_context.py CHANGELOG.md All checks passed! $ uv tool run ruff format --check headroom/proxy/ssl_context.py tests/test_ssl_context.py 2 files already formatted ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.2, Headroom checkout on this branch, `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` pointed at an enterprise/private PKI CA bundle. Upstream HTTPS endpoint name is redacted. - Exact command / steps: Ran a Python smoke script that imports `headroom.proxy.ssl_context.find_ca_bundle()`, passes the returned verifier into `httpx.AsyncClient(verify=...)`, and performs a GET against the enterprise HTTPS endpoint that previously failed with OpenSSL strict verification. - Observed result: The request used an `SSLContext`, strict X.509 verification was disabled for that custom CA context, and the request reached the upstream HTTP response: ```text verify_type SSLContext strict_enabled False status 302 ``` - Not tested: Full `uv run pytest`, `uv sync --extra dev`, and `mypy headroom` in this local environment. The editable build currently fails before tests run because Cargo's `ort-sys` build script cannot download ORT prebuilt binaries due an unrelated local certificate verification error against the ORT CDN. No dependency or lockfile changes are included in this PR. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes No dependencies or lockfiles changed. Documentation is unchanged because this is a bug fix to existing custom CA environment-variable behavior rather than a new user-facing configuration surface. Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-22 19:48:19 -04:00
- The NODE_EXTRA_CA_CERTS SSLContext is additive: default/system roots are preserved (#998)
- Priority order: SSL_CERT_FILE beats REQUESTS_CA_BUNDLE beats NODE_EXTRA_CA_CERTS
- Nonexistent paths are skipped (returns None if all paths are missing)
"""
from __future__ import annotations
fix(proxy): treat NODE_EXTRA_CA_CERTS as additive, not replacement (#998) (#1031) ## Description `find_ca_bundle()` returns the `NODE_EXTRA_CA_CERTS` path as a bare string passed to httpx's `verify=` parameter, which makes it the sole trust store. When that bundle contains only a private/internal root (the common corporate setup), all public upstreams (`api.anthropic.com`, `api.openai.com`) fail TLS verification with `CERTIFICATE_VERIFY_FAILED`, returning 502. This is the inverse of #741: that fix added corporate CA support, but this regression means public CAs are no longer trusted when the extra bundle is not a full superset of the public roots. The fix builds an `ssl.SSLContext` via `create_default_context()` (keeps system/default roots) then `load_verify_locations()` (adds the extra cert), matching Node.js additive semantics. `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` keep their existing replacement semantics. Closes #998 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Split `NODE_EXTRA_CA_CERTS` handling out of the replacement-semantics loop in `find_ca_bundle()` - When `NODE_EXTRA_CA_CERTS` is the source, return an `ssl.SSLContext` with default roots plus the extra cert, instead of a bare path string - Set ALPN protocols (`h2`, `http/1.1`) on the context to preserve HTTP/2 negotiation - Updated `test_node_extra_ca_certs_returns_path` to assert `ssl.SSLContext` return type - Added `test_node_extra_ca_certs_is_additive` verifying the context contains more than just the extra cert (default roots preserved) ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] New tests added for new functionality ### Test Output ```text tests/test_ssl_context.py::TestFindCaBundleNoEnvVars::test_returns_none_when_no_env_var_set PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_ssl_cert_file_returns_path PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_requests_ca_bundle_returns_path PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_node_extra_ca_certs_returns_ssl_context PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_node_extra_ca_certs_is_additive PASSED tests/test_ssl_context.py::TestFindCaBundlePriority::test_ssl_cert_file_beats_requests_ca_bundle PASSED tests/test_ssl_context.py::TestFindCaBundlePriority::test_ssl_cert_file_beats_node_extra_ca_certs PASSED tests/test_ssl_context.py::TestFindCaBundlePriority::test_requests_ca_bundle_beats_node_extra_ca_certs PASSED tests/test_ssl_context.py::TestFindCaBundleNonexistentPaths::test_nonexistent_path_is_skipped PASSED tests/test_ssl_context.py::TestFindCaBundleNonexistentPaths::test_all_nonexistent_returns_none PASSED tests/test_ssl_context.py::TestFindCaBundleNonexistentPaths::test_first_nonexistent_falls_through_to_valid PASSED 11 passed in 0.91s ``` ## Real Behavior Proof - Environment: Windows 11 Home 10.0.26200, Python 3.10.18 - Exact command / steps: Ran `python -m pytest tests/test_ssl_context.py -v` after applying the fix. The new `test_node_extra_ca_certs_is_additive` test verifies `ctx.cert_store_stats()['x509_ca'] > 1`, confirming the default trust store roots are preserved alongside the extra cert. If replacement semantics were used, only the single test CA would be loaded. - Observed result: All 11 SSL context tests pass. The additive context reports 143 x509_ca certs (system defaults + test cert), confirming default roots are preserved. - Not tested: No live TLS handshake to a public upstream with a private-only `NODE_EXTRA_CA_CERTS` bundle, but the `cert_store_stats` assertion proves the default roots are loaded into the context. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes ## Additional Notes The fix aligns with the design proposed in #741 and #745's own description (which said it "builds an SSLContext") but which the merged implementation did not follow. The `server.py` consumer does not need changes because httpx accepts `ssl.SSLContext` for `verify=`. ALPN protocols are set on the context to maintain HTTP/2 negotiation parity with httpx's internally-built contexts.
2026-06-18 00:41:49 +08:00
import ssl
import pytest
fix(proxy): build SSL contexts for custom CA bundles (#1134) ## Description Build explicit `ssl.SSLContext` objects for custom CA bundles configured through `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE`. Python 3.13 / newer OpenSSL can reject some enterprise/private PKI roots that platform TLS stacks accept, for example roots without a `keyUsage` extension. The new custom-CA contexts keep certificate verification enabled while clearing only `ssl.VERIFY_X509_STRICT`. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Build replacement `SSLContext` objects for `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` instead of passing raw CA bundle paths to httpx. - Clear only `ssl.VERIFY_X509_STRICT` for operator-provided custom CA contexts; certificate verification, hostname verification, expiry checks, and chain validation stay enabled. - Preserve `NODE_EXTRA_CA_CERTS` additive behavior by loading the extra CA bundle on top of the default/system trust store. - Update existing SSL context tests for replacement CA contexts, env-var priority, missing-path fallthrough, and strict-mode relaxation. - Add an Unreleased changelog entry for the proxy bug fix. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ PYTHONPATH=. .venv/bin/python -m pytest tests/test_ssl_context.py -q collected 12 items tests/test_ssl_context.py ............ [100%] 12 passed, 1 warning in 0.11s ``` ```text $ uv tool run ruff check headroom/proxy/ssl_context.py tests/test_ssl_context.py CHANGELOG.md All checks passed! $ uv tool run ruff format --check headroom/proxy/ssl_context.py tests/test_ssl_context.py 2 files already formatted ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.2, Headroom checkout on this branch, `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` pointed at an enterprise/private PKI CA bundle. Upstream HTTPS endpoint name is redacted. - Exact command / steps: Ran a Python smoke script that imports `headroom.proxy.ssl_context.find_ca_bundle()`, passes the returned verifier into `httpx.AsyncClient(verify=...)`, and performs a GET against the enterprise HTTPS endpoint that previously failed with OpenSSL strict verification. - Observed result: The request used an `SSLContext`, strict X.509 verification was disabled for that custom CA context, and the request reached the upstream HTTP response: ```text verify_type SSLContext strict_enabled False status 302 ``` - Not tested: Full `uv run pytest`, `uv sync --extra dev`, and `mypy headroom` in this local environment. The editable build currently fails before tests run because Cargo's `ort-sys` build script cannot download ORT prebuilt binaries due an unrelated local certificate verification error against the ORT CDN. No dependency or lockfile changes are included in this PR. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes No dependencies or lockfiles changed. Documentation is unchanged because this is a bug fix to existing custom CA environment-variable behavior rather than a new user-facing configuration surface. Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-22 19:48:19 -04:00
from headroom.proxy import ssl_context
fix(tls): add HEADROOM_TLS_STRICT=0 toggle for corporate SSL inspection (#1308) (#1341) ## Description Behind a corporate TLS-inspection proxy (Zscaler, Netskope) on Python 3.13+, Headroom can't reach the network even with the corporate root correctly installed and trusted. Every path fails with: ``` [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: Basic Constraints of CA cert not marked critical ``` This isn't a missing-CA problem — the cert is found and trusted. Python 3.13 + OpenSSL 3.x enable `VERIFY_X509_STRICT` by default, which enforces RFC 5280 §4.2.1.9 (a CA cert's `basicConstraints` MUST be marked critical). Inspection roots set `CA:TRUE` without the critical bit, so the chain is rejected. Adding the CA to a bundle does nothing — it's the strict check that fails, and the existing README section only covers `unable to get local issuer certificate`. There are two independent sources of the strict flag (both reported in the issue): Python's own `ssl.create_default_context()` (hits the httpx upstream client), and urllib3 ≥ 2.5's `create_urllib3_context()` (hits the `huggingface_hub` model-download path). Closes #1308 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `ssl_context.py`: added `HEADROOM_TLS_STRICT`. `tls_strict_disabled()` reads the toggle (off-values `0/false/no/off`, default strict). `build_httpx_verify()` resolves the httpx `verify=` value: a configured CA bundle wins; otherwise, when the toggle is off, a default-trust-store context with **only** `VERIFY_X509_STRICT` cleared (so a corporate root that lives in the OS store but trips strict mode still validates); otherwise `True` (httpx default). `apply_global_tls_relaxation()` monkeypatches urllib3's `create_urllib3_context` to drop the strict flag — idempotent, guarded, no-op if urllib3 is absent or the toggle is on. - `server.py`: the proxy's httpx upstream client now uses `build_httpx_verify()` instead of `find_ca_bundle()`-or-`True`. - `cli/proxy.py`: calls `apply_global_tls_relaxation()` at module import, before `huggingface_hub`/`requests` import and cache their context. - README: a distinct SSL-inspection subsection for the `Basic Constraints ... not marked critical` failure, separate from `unable to get local issuer certificate`. Documents that the Rust core's ONNX download (`cdn.pyke.io`) uses a separate stack (rustls/OS trust store) unaffected by the toggle — corporate root must be in the Windows **machine** store, or pre-provision via `ORT_STRATEGY=system`. Chain validation, signature, expiry, and hostname checks all stay on — `HEADROOM_TLS_STRICT=0` is strictly narrower than `verify=False`. Default is strict, matching Python's own default. ## Testing - [x] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_ssl_context.py -q 31 passed # 19 existing + 12 new (TestTlsStrictDisabled, TestBuildHttpxVerify, TestApplyGlobalTlsRelaxation). ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.10.11 (OpenSSL build that exposes `VERIFY_X509_STRICT`). - Exact command / steps: exercised the module directly — set/unset `HEADROOM_TLS_STRICT`, inspected the resolved httpx `verify` value and the urllib3 context's `verify_flags`. - Observed result: default → `verify=True` (strict preserved); `HEADROOM_TLS_STRICT=0` → httpx gets an `SSLContext` with `VERIFY_X509_STRICT` cleared but `verify_mode == CERT_REQUIRED` and the full default trust store (cert_store x509_ca > 1); `apply_global_tls_relaxation()` patches `urllib3.util.ssl_.create_urllib3_context` so new contexts have the strict flag cleared, and is idempotent. A configured `SSL_CERT_FILE` still wins over the toggle. - Not tested: an actual handshake through a live Zscaler/Netskope MITM on Python 3.13 — I don't have that environment. The fix targets exactly the flag the issue identifies (`VERIFY_X509_STRICT`) on both reported context builders; I verified the flag manipulation and resolution logic directly rather than simulating the proxy. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - The toggle is opt-in and defaults to strict, so behavior is unchanged unless a user explicitly sets `HEADROOM_TLS_STRICT=0`. It clears only the strict flag, never disables verification. - The httpx path uses an explicit context (clean, testable); the urllib3 path needs a monkeypatch because `huggingface_hub` → `requests` builds its context internally and never sees ours. - CHANGELOG.md isn't touched — release-please generates it from the `fix(tls):` commit subject. - I scoped this to the two Python TLS stacks the issue calls out and documented (rather than tried to patch) the separate Rust/ONNX path, since that one resolves through the OS trust store and isn't something this Python toggle can reach.
2026-06-24 20:21:30 +05:30
from headroom.proxy.ssl_context import (
apply_global_tls_relaxation,
build_httpx_verify,
find_ca_bundle,
tls_strict_disabled,
)
# Minimal self-signed CA certificate (PEM) used only to verify that
# load_verify_locations accepts the file. Generated offline; never used
# for real TLS handshakes in these tests.
_SELF_SIGNED_CA_PEM = b"""\
-----BEGIN CERTIFICATE-----
MIIDFzCCAf+gAwIBAgIUWP49K8QzU5B68/BZSmeqPCDaBoQwDQYJKoZIhvcNAQEL
BQAwGzEZMBcGA1UEAwwQaGVhZHJvb20tdGVzdC1jYTAeFw0yNjA2MDgxNDIwMzFa
Fw0zNjA2MDUxNDIwMzFaMBsxGTAXBgNVBAMMEGhlYWRyb29tLXRlc3QtY2EwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvTqYZXAhet9yw1n4cFeC8HosC
1Od/bibXyW7ko7aOuuzUT7B9l7MwDfgrE2mjHecoSe2qbknFcv6hxbYojh4J7C8r
UPgCA2QTtU3pBxQdwO156YAOmFPuBFPb19NAErOVlnHCU+NXCVSsE5y+AJjM161S
W0HnZgO8OADZHBs5jSAGDE3ymMw+8xpuvRKJnuvK0Tcu6bOqOTMbnggwmPBZBBLW
PrurPTN0vV9C2oyHA1tXgEJyYtEPoMfaqyE80GxYeUujt9EQWrLp+3k8ufB/yJ1b
DaSrH0GZYx2HUn0p1mqWzXcKZrSrL1o+38gCmCivG0movXt6z1tUly8mTGz/AgMB
AAGjUzBRMB0GA1UdDgQWBBTyJ8OWE/bpWbKM3SB52P+9DhGN/TAfBgNVHSMEGDAW
gBTyJ8OWE/bpWbKM3SB52P+9DhGN/TAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
DQEBCwUAA4IBAQAb44h2gg9wWU5todvwSXVAlBb/WZD1l/NG2PeTsGoH7xqmfgq9
DxV6tvoIuDlu6OKz071ljSqRh0Mesh1ma1cj6snsc/jqgsakSlcOpOCsrTCvw2DB
2oTztHnO4PiZAPtuKiawhVQpJfEna9/xOkbalazecSGngtSzd/oIJEXe299hE1/1
Tfx2hBGZ0UogmREaXFi099rmaueZ0HIBn51b3kYqc7of5TI0fHwSHF4GdXXs2OZi
6EVQWhKx5nQbklTYP5/ge9olEIsMdGqJEiz7WfSC6QBBgvoYyH596GiSGRZcX67p
kF9agIt8Q8t/2kviMn2roInGTwTyPYOEQV0m
-----END CERTIFICATE-----
"""
fix(proxy): relocate stray system-role messages to the top-level system param (#765) (#1357) ## Description On requests large enough to trigger compression, the proxy emitted an upstream Anthropic request whose `messages[0]` had `role: "system"`. Anthropic's Messages API rejects any `system` role inside `messages[]`: ``` 400 invalid_request_error: "messages.0: use the top-level 'system' parameter for the initial system prompt" ``` The original request correctly carries its system prompt in the top-level `system` parameter; a compression/transform/pipeline step relocates the harness system block into `messages[0]`, so the request fails outright (intermittent only because it requires a context large enough to compress). This adds a wire-contract guard in the Anthropic forwarder: as the **last** step before sending upstream (after every transform, memory injection, tool sort, and pipeline extension, covering both the Bedrock and direct paths), any stray `role="system"` message is relocated out of `messages[]` and merged back into the top-level `system` parameter. Content order is preserved (existing system first, relocated content after) and block-level `cache_control` survives. The guard is a no-op on the common path (no system-role entry → inputs pass through unchanged). Closes #765 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/proxy/helpers.py`: new pure helper `relocate_system_messages_to_top_level(messages, system) -> (clean_messages, new_system, changed)` plus `_system_message_to_blocks`. Handles `system` being `None`/`str`/`list`, never drops content, preserves order and content blocks. - `headroom/proxy/handlers/anthropic.py`: invoke the guard just before the byte-faithful forward block; on relocation, update `body["messages"]`/`body["system"]`, mark the body mutated (`system_role_relocated`) so the byte-faithful forwarder re-serializes, and log a warning. - `tests/test_proxy_handler_helpers.py`: 3 unit tests (relocate stray system into top-level, append-to-existing-system order, no-op without a system entry). - `CHANGELOG.md`: Bug Fixes entry. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run --extra dev python -m pytest tests/test_proxy_handler_helpers.py -q 29 passed in 4.95s # Regression on the forward path (byte-faithful forwarding, system-prompt immutability, cache stability): $ uv run --extra dev python -m pytest tests/test_proxy_handler_helpers.py tests/test_proxy_byte_faithful_forwarding.py tests/test_proxy_system_prompt_immutable.py tests/test_proxy_anthropic_cache_stability.py -q 90 passed, 15 warnings in 29.72s $ uv run ruff check headroom/proxy/helpers.py headroom/proxy/handlers/anthropic.py tests/test_proxy_handler_helpers.py All checks passed! $ uv run ruff format --check ... # 3 files already formatted $ uv run mypy headroom/proxy/helpers.py headroom/proxy/handlers/anthropic.py Success: no issues found in 2 source files ``` ## Test verification (RED → GREEN) The new tests exercise the guard directly and import the new helper at module top, so reverting the production fix makes them fail at collection. **RED — production fix reverted (helper removed):** ```text ImportError while importing test module 'tests/test_proxy_handler_helpers.py'. E ImportError: cannot import name 'relocate_system_messages_to_top_level' from 'headroom.proxy.helpers' =========================== 1 error in 0.41s =============================== ``` **GREEN — production fix applied:** ```text tests/test_proxy_handler_helpers.py ... [100%] ======================= 3 passed, 26 deselected in 1.50s ======================= ``` ## Real Behavior Proof - Environment: Python 3.13, `uv run` in this repo, branch `fix/issue-765`. - Exact command / steps: ran the guard on a body in the exact #765 failure shape — `system: None` and a `role="system"` harness block at `messages[0]`: - Observed result: ```text BEFORE: messages[0].role = system (Anthropic 400 trigger) changed = True AFTER roles = ['user', 'assistant'] system param = [{"type": "text", "text": "You are Claude Code. <system-reminder>...</system-reminder>"}] OK: no role=system in messages[]; system content preserved in top-level param ``` The illegal `role="system"` entry is removed from `messages[]` and its content lands in the top-level `system` parameter — exactly the body Anthropic accepts. - Not tested: a full live 250k+-token Claude Code session against the real Anthropic API (needs a large live context + API key); the fix is validated at the request-shaping boundary the 400 is raised on. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes The guard intentionally fires at the forwarder boundary rather than in any single transform: the issue's captures show the relocation can originate from the compression path, and pipeline extensions / hooks can also mutate `messages` late. Enforcing Anthropic's wire contract once, at the point the body is serialized upstream, fixes the 400 regardless of which step introduced the stray entry and matches the architecture invariant "never produce a `system`-role entry within `messages[]`". --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-13 18:52:09 +02:00
_TEST_CA_COUNT = 1
@pytest.fixture()
def ca_pem_file(tmp_path):
"""Write the self-signed CA PEM to a temp file and return its path."""
p = tmp_path / "ca.pem"
p.write_bytes(_SELF_SIGNED_CA_PEM)
return str(p)
def _clean_env(monkeypatch):
fix(tls): add HEADROOM_TLS_STRICT=0 toggle for corporate SSL inspection (#1308) (#1341) ## Description Behind a corporate TLS-inspection proxy (Zscaler, Netskope) on Python 3.13+, Headroom can't reach the network even with the corporate root correctly installed and trusted. Every path fails with: ``` [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: Basic Constraints of CA cert not marked critical ``` This isn't a missing-CA problem — the cert is found and trusted. Python 3.13 + OpenSSL 3.x enable `VERIFY_X509_STRICT` by default, which enforces RFC 5280 §4.2.1.9 (a CA cert's `basicConstraints` MUST be marked critical). Inspection roots set `CA:TRUE` without the critical bit, so the chain is rejected. Adding the CA to a bundle does nothing — it's the strict check that fails, and the existing README section only covers `unable to get local issuer certificate`. There are two independent sources of the strict flag (both reported in the issue): Python's own `ssl.create_default_context()` (hits the httpx upstream client), and urllib3 ≥ 2.5's `create_urllib3_context()` (hits the `huggingface_hub` model-download path). Closes #1308 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `ssl_context.py`: added `HEADROOM_TLS_STRICT`. `tls_strict_disabled()` reads the toggle (off-values `0/false/no/off`, default strict). `build_httpx_verify()` resolves the httpx `verify=` value: a configured CA bundle wins; otherwise, when the toggle is off, a default-trust-store context with **only** `VERIFY_X509_STRICT` cleared (so a corporate root that lives in the OS store but trips strict mode still validates); otherwise `True` (httpx default). `apply_global_tls_relaxation()` monkeypatches urllib3's `create_urllib3_context` to drop the strict flag — idempotent, guarded, no-op if urllib3 is absent or the toggle is on. - `server.py`: the proxy's httpx upstream client now uses `build_httpx_verify()` instead of `find_ca_bundle()`-or-`True`. - `cli/proxy.py`: calls `apply_global_tls_relaxation()` at module import, before `huggingface_hub`/`requests` import and cache their context. - README: a distinct SSL-inspection subsection for the `Basic Constraints ... not marked critical` failure, separate from `unable to get local issuer certificate`. Documents that the Rust core's ONNX download (`cdn.pyke.io`) uses a separate stack (rustls/OS trust store) unaffected by the toggle — corporate root must be in the Windows **machine** store, or pre-provision via `ORT_STRATEGY=system`. Chain validation, signature, expiry, and hostname checks all stay on — `HEADROOM_TLS_STRICT=0` is strictly narrower than `verify=False`. Default is strict, matching Python's own default. ## Testing - [x] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_ssl_context.py -q 31 passed # 19 existing + 12 new (TestTlsStrictDisabled, TestBuildHttpxVerify, TestApplyGlobalTlsRelaxation). ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.10.11 (OpenSSL build that exposes `VERIFY_X509_STRICT`). - Exact command / steps: exercised the module directly — set/unset `HEADROOM_TLS_STRICT`, inspected the resolved httpx `verify` value and the urllib3 context's `verify_flags`. - Observed result: default → `verify=True` (strict preserved); `HEADROOM_TLS_STRICT=0` → httpx gets an `SSLContext` with `VERIFY_X509_STRICT` cleared but `verify_mode == CERT_REQUIRED` and the full default trust store (cert_store x509_ca > 1); `apply_global_tls_relaxation()` patches `urllib3.util.ssl_.create_urllib3_context` so new contexts have the strict flag cleared, and is idempotent. A configured `SSL_CERT_FILE` still wins over the toggle. - Not tested: an actual handshake through a live Zscaler/Netskope MITM on Python 3.13 — I don't have that environment. The fix targets exactly the flag the issue identifies (`VERIFY_X509_STRICT`) on both reported context builders; I verified the flag manipulation and resolution logic directly rather than simulating the proxy. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - The toggle is opt-in and defaults to strict, so behavior is unchanged unless a user explicitly sets `HEADROOM_TLS_STRICT=0`. It clears only the strict flag, never disables verification. - The httpx path uses an explicit context (clean, testable); the urllib3 path needs a monkeypatch because `huggingface_hub` → `requests` builds its context internally and never sees ours. - CHANGELOG.md isn't touched — release-please generates it from the `fix(tls):` commit subject. - I scoped this to the two Python TLS stacks the issue calls out and documented (rather than tried to patch) the separate Rust/ONNX path, since that one resolves through the OS trust store and isn't something this Python toggle can reach.
2026-06-24 20:21:30 +05:30
"""Remove all CA-bundle env vars + the strict toggle for a clean state."""
for var in (
"SSL_CERT_FILE",
"REQUESTS_CA_BUNDLE",
"NODE_EXTRA_CA_CERTS",
"HEADROOM_TLS_STRICT",
):
monkeypatch.delenv(var, raising=False)
fix(proxy): relocate stray system-role messages to the top-level system param (#765) (#1357) ## Description On requests large enough to trigger compression, the proxy emitted an upstream Anthropic request whose `messages[0]` had `role: "system"`. Anthropic's Messages API rejects any `system` role inside `messages[]`: ``` 400 invalid_request_error: "messages.0: use the top-level 'system' parameter for the initial system prompt" ``` The original request correctly carries its system prompt in the top-level `system` parameter; a compression/transform/pipeline step relocates the harness system block into `messages[0]`, so the request fails outright (intermittent only because it requires a context large enough to compress). This adds a wire-contract guard in the Anthropic forwarder: as the **last** step before sending upstream (after every transform, memory injection, tool sort, and pipeline extension, covering both the Bedrock and direct paths), any stray `role="system"` message is relocated out of `messages[]` and merged back into the top-level `system` parameter. Content order is preserved (existing system first, relocated content after) and block-level `cache_control` survives. The guard is a no-op on the common path (no system-role entry → inputs pass through unchanged). Closes #765 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/proxy/helpers.py`: new pure helper `relocate_system_messages_to_top_level(messages, system) -> (clean_messages, new_system, changed)` plus `_system_message_to_blocks`. Handles `system` being `None`/`str`/`list`, never drops content, preserves order and content blocks. - `headroom/proxy/handlers/anthropic.py`: invoke the guard just before the byte-faithful forward block; on relocation, update `body["messages"]`/`body["system"]`, mark the body mutated (`system_role_relocated`) so the byte-faithful forwarder re-serializes, and log a warning. - `tests/test_proxy_handler_helpers.py`: 3 unit tests (relocate stray system into top-level, append-to-existing-system order, no-op without a system entry). - `CHANGELOG.md`: Bug Fixes entry. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run --extra dev python -m pytest tests/test_proxy_handler_helpers.py -q 29 passed in 4.95s # Regression on the forward path (byte-faithful forwarding, system-prompt immutability, cache stability): $ uv run --extra dev python -m pytest tests/test_proxy_handler_helpers.py tests/test_proxy_byte_faithful_forwarding.py tests/test_proxy_system_prompt_immutable.py tests/test_proxy_anthropic_cache_stability.py -q 90 passed, 15 warnings in 29.72s $ uv run ruff check headroom/proxy/helpers.py headroom/proxy/handlers/anthropic.py tests/test_proxy_handler_helpers.py All checks passed! $ uv run ruff format --check ... # 3 files already formatted $ uv run mypy headroom/proxy/helpers.py headroom/proxy/handlers/anthropic.py Success: no issues found in 2 source files ``` ## Test verification (RED → GREEN) The new tests exercise the guard directly and import the new helper at module top, so reverting the production fix makes them fail at collection. **RED — production fix reverted (helper removed):** ```text ImportError while importing test module 'tests/test_proxy_handler_helpers.py'. E ImportError: cannot import name 'relocate_system_messages_to_top_level' from 'headroom.proxy.helpers' =========================== 1 error in 0.41s =============================== ``` **GREEN — production fix applied:** ```text tests/test_proxy_handler_helpers.py ... [100%] ======================= 3 passed, 26 deselected in 1.50s ======================= ``` ## Real Behavior Proof - Environment: Python 3.13, `uv run` in this repo, branch `fix/issue-765`. - Exact command / steps: ran the guard on a body in the exact #765 failure shape — `system: None` and a `role="system"` harness block at `messages[0]`: - Observed result: ```text BEFORE: messages[0].role = system (Anthropic 400 trigger) changed = True AFTER roles = ['user', 'assistant'] system param = [{"type": "text", "text": "You are Claude Code. <system-reminder>...</system-reminder>"}] OK: no role=system in messages[]; system content preserved in top-level param ``` The illegal `role="system"` entry is removed from `messages[]` and its content lands in the top-level `system` parameter — exactly the body Anthropic accepts. - Not tested: a full live 250k+-token Claude Code session against the real Anthropic API (needs a large live context + API key); the fix is validated at the request-shaping boundary the 400 is raised on. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes The guard intentionally fires at the forwarder boundary rather than in any single transform: the issue's captures show the relocation can originate from the compression path, and pipeline extensions / hooks can also mutate `messages` late. Enforcing Anthropic's wire contract once, at the point the body is serialized upstream, fixes the 400 regardless of which step introduced the stray entry and matches the architecture invariant "never produce a `system`-role entry within `messages[]`". --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-13 18:52:09 +02:00
def _default_x509_ca_count() -> int:
return ssl.create_default_context().cert_store_stats()["x509_ca"]
fix(proxy): build SSL contexts for custom CA bundles (#1134) ## Description Build explicit `ssl.SSLContext` objects for custom CA bundles configured through `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE`. Python 3.13 / newer OpenSSL can reject some enterprise/private PKI roots that platform TLS stacks accept, for example roots without a `keyUsage` extension. The new custom-CA contexts keep certificate verification enabled while clearing only `ssl.VERIFY_X509_STRICT`. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Build replacement `SSLContext` objects for `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` instead of passing raw CA bundle paths to httpx. - Clear only `ssl.VERIFY_X509_STRICT` for operator-provided custom CA contexts; certificate verification, hostname verification, expiry checks, and chain validation stay enabled. - Preserve `NODE_EXTRA_CA_CERTS` additive behavior by loading the extra CA bundle on top of the default/system trust store. - Update existing SSL context tests for replacement CA contexts, env-var priority, missing-path fallthrough, and strict-mode relaxation. - Add an Unreleased changelog entry for the proxy bug fix. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ PYTHONPATH=. .venv/bin/python -m pytest tests/test_ssl_context.py -q collected 12 items tests/test_ssl_context.py ............ [100%] 12 passed, 1 warning in 0.11s ``` ```text $ uv tool run ruff check headroom/proxy/ssl_context.py tests/test_ssl_context.py CHANGELOG.md All checks passed! $ uv tool run ruff format --check headroom/proxy/ssl_context.py tests/test_ssl_context.py 2 files already formatted ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.2, Headroom checkout on this branch, `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` pointed at an enterprise/private PKI CA bundle. Upstream HTTPS endpoint name is redacted. - Exact command / steps: Ran a Python smoke script that imports `headroom.proxy.ssl_context.find_ca_bundle()`, passes the returned verifier into `httpx.AsyncClient(verify=...)`, and performs a GET against the enterprise HTTPS endpoint that previously failed with OpenSSL strict verification. - Observed result: The request used an `SSLContext`, strict X.509 verification was disabled for that custom CA context, and the request reached the upstream HTTP response: ```text verify_type SSLContext strict_enabled False status 302 ``` - Not tested: Full `uv run pytest`, `uv sync --extra dev`, and `mypy headroom` in this local environment. The editable build currently fails before tests run because Cargo's `ort-sys` build script cannot download ORT prebuilt binaries due an unrelated local certificate verification error against the ORT CDN. No dependency or lockfile changes are included in this PR. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes No dependencies or lockfiles changed. Documentation is unchanged because this is a bug fix to existing custom CA environment-variable behavior rather than a new user-facing configuration surface. Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-22 19:48:19 -04:00
class FakeSSLContext:
def __init__(self, verify_flags: int = 0) -> None:
self.verify_flags = verify_flags
self.loaded_cafile: str | None = None
self.alpn_protocols: list[str] | None = None
def load_verify_locations(self, *, cafile: str) -> None:
self.loaded_cafile = cafile
def set_alpn_protocols(self, protocols: list[str]) -> None:
self.alpn_protocols = protocols
class TestFindCaBundleNoEnvVars:
def test_returns_none_when_no_env_var_set(self, monkeypatch):
_clean_env(monkeypatch)
assert find_ca_bundle() is None
class TestFindCaBundleWithValidPem:
fix(proxy): build SSL contexts for custom CA bundles (#1134) ## Description Build explicit `ssl.SSLContext` objects for custom CA bundles configured through `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE`. Python 3.13 / newer OpenSSL can reject some enterprise/private PKI roots that platform TLS stacks accept, for example roots without a `keyUsage` extension. The new custom-CA contexts keep certificate verification enabled while clearing only `ssl.VERIFY_X509_STRICT`. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Build replacement `SSLContext` objects for `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` instead of passing raw CA bundle paths to httpx. - Clear only `ssl.VERIFY_X509_STRICT` for operator-provided custom CA contexts; certificate verification, hostname verification, expiry checks, and chain validation stay enabled. - Preserve `NODE_EXTRA_CA_CERTS` additive behavior by loading the extra CA bundle on top of the default/system trust store. - Update existing SSL context tests for replacement CA contexts, env-var priority, missing-path fallthrough, and strict-mode relaxation. - Add an Unreleased changelog entry for the proxy bug fix. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ PYTHONPATH=. .venv/bin/python -m pytest tests/test_ssl_context.py -q collected 12 items tests/test_ssl_context.py ............ [100%] 12 passed, 1 warning in 0.11s ``` ```text $ uv tool run ruff check headroom/proxy/ssl_context.py tests/test_ssl_context.py CHANGELOG.md All checks passed! $ uv tool run ruff format --check headroom/proxy/ssl_context.py tests/test_ssl_context.py 2 files already formatted ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.2, Headroom checkout on this branch, `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` pointed at an enterprise/private PKI CA bundle. Upstream HTTPS endpoint name is redacted. - Exact command / steps: Ran a Python smoke script that imports `headroom.proxy.ssl_context.find_ca_bundle()`, passes the returned verifier into `httpx.AsyncClient(verify=...)`, and performs a GET against the enterprise HTTPS endpoint that previously failed with OpenSSL strict verification. - Observed result: The request used an `SSLContext`, strict X.509 verification was disabled for that custom CA context, and the request reached the upstream HTTP response: ```text verify_type SSLContext strict_enabled False status 302 ``` - Not tested: Full `uv run pytest`, `uv sync --extra dev`, and `mypy headroom` in this local environment. The editable build currently fails before tests run because Cargo's `ort-sys` build script cannot download ORT prebuilt binaries due an unrelated local certificate verification error against the ORT CDN. No dependency or lockfile changes are included in this PR. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes No dependencies or lockfiles changed. Documentation is unchanged because this is a bug fix to existing custom CA environment-variable behavior rather than a new user-facing configuration surface. Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-22 19:48:19 -04:00
def test_ssl_cert_file_returns_ssl_context(self, monkeypatch, ca_pem_file):
_clean_env(monkeypatch)
monkeypatch.setenv("SSL_CERT_FILE", ca_pem_file)
ctx = find_ca_bundle()
fix(proxy): build SSL contexts for custom CA bundles (#1134) ## Description Build explicit `ssl.SSLContext` objects for custom CA bundles configured through `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE`. Python 3.13 / newer OpenSSL can reject some enterprise/private PKI roots that platform TLS stacks accept, for example roots without a `keyUsage` extension. The new custom-CA contexts keep certificate verification enabled while clearing only `ssl.VERIFY_X509_STRICT`. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Build replacement `SSLContext` objects for `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` instead of passing raw CA bundle paths to httpx. - Clear only `ssl.VERIFY_X509_STRICT` for operator-provided custom CA contexts; certificate verification, hostname verification, expiry checks, and chain validation stay enabled. - Preserve `NODE_EXTRA_CA_CERTS` additive behavior by loading the extra CA bundle on top of the default/system trust store. - Update existing SSL context tests for replacement CA contexts, env-var priority, missing-path fallthrough, and strict-mode relaxation. - Add an Unreleased changelog entry for the proxy bug fix. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ PYTHONPATH=. .venv/bin/python -m pytest tests/test_ssl_context.py -q collected 12 items tests/test_ssl_context.py ............ [100%] 12 passed, 1 warning in 0.11s ``` ```text $ uv tool run ruff check headroom/proxy/ssl_context.py tests/test_ssl_context.py CHANGELOG.md All checks passed! $ uv tool run ruff format --check headroom/proxy/ssl_context.py tests/test_ssl_context.py 2 files already formatted ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.2, Headroom checkout on this branch, `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` pointed at an enterprise/private PKI CA bundle. Upstream HTTPS endpoint name is redacted. - Exact command / steps: Ran a Python smoke script that imports `headroom.proxy.ssl_context.find_ca_bundle()`, passes the returned verifier into `httpx.AsyncClient(verify=...)`, and performs a GET against the enterprise HTTPS endpoint that previously failed with OpenSSL strict verification. - Observed result: The request used an `SSLContext`, strict X.509 verification was disabled for that custom CA context, and the request reached the upstream HTTP response: ```text verify_type SSLContext strict_enabled False status 302 ``` - Not tested: Full `uv run pytest`, `uv sync --extra dev`, and `mypy headroom` in this local environment. The editable build currently fails before tests run because Cargo's `ort-sys` build script cannot download ORT prebuilt binaries due an unrelated local certificate verification error against the ORT CDN. No dependency or lockfile changes are included in this PR. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes No dependencies or lockfiles changed. Documentation is unchanged because this is a bug fix to existing custom CA environment-variable behavior rather than a new user-facing configuration surface. Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-22 19:48:19 -04:00
assert isinstance(ctx, ssl.SSLContext)
fix(proxy): build SSL contexts for custom CA bundles (#1134) ## Description Build explicit `ssl.SSLContext` objects for custom CA bundles configured through `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE`. Python 3.13 / newer OpenSSL can reject some enterprise/private PKI roots that platform TLS stacks accept, for example roots without a `keyUsage` extension. The new custom-CA contexts keep certificate verification enabled while clearing only `ssl.VERIFY_X509_STRICT`. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Build replacement `SSLContext` objects for `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` instead of passing raw CA bundle paths to httpx. - Clear only `ssl.VERIFY_X509_STRICT` for operator-provided custom CA contexts; certificate verification, hostname verification, expiry checks, and chain validation stay enabled. - Preserve `NODE_EXTRA_CA_CERTS` additive behavior by loading the extra CA bundle on top of the default/system trust store. - Update existing SSL context tests for replacement CA contexts, env-var priority, missing-path fallthrough, and strict-mode relaxation. - Add an Unreleased changelog entry for the proxy bug fix. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ PYTHONPATH=. .venv/bin/python -m pytest tests/test_ssl_context.py -q collected 12 items tests/test_ssl_context.py ............ [100%] 12 passed, 1 warning in 0.11s ``` ```text $ uv tool run ruff check headroom/proxy/ssl_context.py tests/test_ssl_context.py CHANGELOG.md All checks passed! $ uv tool run ruff format --check headroom/proxy/ssl_context.py tests/test_ssl_context.py 2 files already formatted ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.2, Headroom checkout on this branch, `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` pointed at an enterprise/private PKI CA bundle. Upstream HTTPS endpoint name is redacted. - Exact command / steps: Ran a Python smoke script that imports `headroom.proxy.ssl_context.find_ca_bundle()`, passes the returned verifier into `httpx.AsyncClient(verify=...)`, and performs a GET against the enterprise HTTPS endpoint that previously failed with OpenSSL strict verification. - Observed result: The request used an `SSLContext`, strict X.509 verification was disabled for that custom CA context, and the request reached the upstream HTTP response: ```text verify_type SSLContext strict_enabled False status 302 ``` - Not tested: Full `uv run pytest`, `uv sync --extra dev`, and `mypy headroom` in this local environment. The editable build currently fails before tests run because Cargo's `ort-sys` build script cannot download ORT prebuilt binaries due an unrelated local certificate verification error against the ORT CDN. No dependency or lockfile changes are included in this PR. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes No dependencies or lockfiles changed. Documentation is unchanged because this is a bug fix to existing custom CA environment-variable behavior rather than a new user-facing configuration surface. Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-22 19:48:19 -04:00
def test_requests_ca_bundle_returns_ssl_context(self, monkeypatch, ca_pem_file):
_clean_env(monkeypatch)
monkeypatch.setenv("REQUESTS_CA_BUNDLE", ca_pem_file)
ctx = find_ca_bundle()
fix(proxy): build SSL contexts for custom CA bundles (#1134) ## Description Build explicit `ssl.SSLContext` objects for custom CA bundles configured through `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE`. Python 3.13 / newer OpenSSL can reject some enterprise/private PKI roots that platform TLS stacks accept, for example roots without a `keyUsage` extension. The new custom-CA contexts keep certificate verification enabled while clearing only `ssl.VERIFY_X509_STRICT`. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Build replacement `SSLContext` objects for `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` instead of passing raw CA bundle paths to httpx. - Clear only `ssl.VERIFY_X509_STRICT` for operator-provided custom CA contexts; certificate verification, hostname verification, expiry checks, and chain validation stay enabled. - Preserve `NODE_EXTRA_CA_CERTS` additive behavior by loading the extra CA bundle on top of the default/system trust store. - Update existing SSL context tests for replacement CA contexts, env-var priority, missing-path fallthrough, and strict-mode relaxation. - Add an Unreleased changelog entry for the proxy bug fix. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ PYTHONPATH=. .venv/bin/python -m pytest tests/test_ssl_context.py -q collected 12 items tests/test_ssl_context.py ............ [100%] 12 passed, 1 warning in 0.11s ``` ```text $ uv tool run ruff check headroom/proxy/ssl_context.py tests/test_ssl_context.py CHANGELOG.md All checks passed! $ uv tool run ruff format --check headroom/proxy/ssl_context.py tests/test_ssl_context.py 2 files already formatted ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.2, Headroom checkout on this branch, `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` pointed at an enterprise/private PKI CA bundle. Upstream HTTPS endpoint name is redacted. - Exact command / steps: Ran a Python smoke script that imports `headroom.proxy.ssl_context.find_ca_bundle()`, passes the returned verifier into `httpx.AsyncClient(verify=...)`, and performs a GET against the enterprise HTTPS endpoint that previously failed with OpenSSL strict verification. - Observed result: The request used an `SSLContext`, strict X.509 verification was disabled for that custom CA context, and the request reached the upstream HTTP response: ```text verify_type SSLContext strict_enabled False status 302 ``` - Not tested: Full `uv run pytest`, `uv sync --extra dev`, and `mypy headroom` in this local environment. The editable build currently fails before tests run because Cargo's `ort-sys` build script cannot download ORT prebuilt binaries due an unrelated local certificate verification error against the ORT CDN. No dependency or lockfile changes are included in this PR. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes No dependencies or lockfiles changed. Documentation is unchanged because this is a bug fix to existing custom CA environment-variable behavior rather than a new user-facing configuration surface. Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-22 19:48:19 -04:00
assert isinstance(ctx, ssl.SSLContext)
def test_replacement_ca_context_relaxes_x509_strict(self, monkeypatch, ca_pem_file):
_clean_env(monkeypatch)
monkeypatch.setenv("SSL_CERT_FILE", ca_pem_file)
strict_flag = 0x20
created_context = FakeSSLContext(verify_flags=strict_flag | 0x100)
def fake_create_default_context(*, cafile: str | None = None):
assert cafile == ca_pem_file
return created_context
monkeypatch.setattr(ssl_context.ssl, "VERIFY_X509_STRICT", strict_flag, raising=False)
monkeypatch.setattr(ssl_context.ssl, "create_default_context", fake_create_default_context)
ctx = find_ca_bundle()
assert ctx is created_context
assert created_context.verify_flags & strict_flag == 0
assert created_context.verify_flags & 0x100
assert created_context.alpn_protocols == ["h2", "http/1.1"]
fix(proxy): treat NODE_EXTRA_CA_CERTS as additive, not replacement (#998) (#1031) ## Description `find_ca_bundle()` returns the `NODE_EXTRA_CA_CERTS` path as a bare string passed to httpx's `verify=` parameter, which makes it the sole trust store. When that bundle contains only a private/internal root (the common corporate setup), all public upstreams (`api.anthropic.com`, `api.openai.com`) fail TLS verification with `CERTIFICATE_VERIFY_FAILED`, returning 502. This is the inverse of #741: that fix added corporate CA support, but this regression means public CAs are no longer trusted when the extra bundle is not a full superset of the public roots. The fix builds an `ssl.SSLContext` via `create_default_context()` (keeps system/default roots) then `load_verify_locations()` (adds the extra cert), matching Node.js additive semantics. `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` keep their existing replacement semantics. Closes #998 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Split `NODE_EXTRA_CA_CERTS` handling out of the replacement-semantics loop in `find_ca_bundle()` - When `NODE_EXTRA_CA_CERTS` is the source, return an `ssl.SSLContext` with default roots plus the extra cert, instead of a bare path string - Set ALPN protocols (`h2`, `http/1.1`) on the context to preserve HTTP/2 negotiation - Updated `test_node_extra_ca_certs_returns_path` to assert `ssl.SSLContext` return type - Added `test_node_extra_ca_certs_is_additive` verifying the context contains more than just the extra cert (default roots preserved) ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] New tests added for new functionality ### Test Output ```text tests/test_ssl_context.py::TestFindCaBundleNoEnvVars::test_returns_none_when_no_env_var_set PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_ssl_cert_file_returns_path PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_requests_ca_bundle_returns_path PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_node_extra_ca_certs_returns_ssl_context PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_node_extra_ca_certs_is_additive PASSED tests/test_ssl_context.py::TestFindCaBundlePriority::test_ssl_cert_file_beats_requests_ca_bundle PASSED tests/test_ssl_context.py::TestFindCaBundlePriority::test_ssl_cert_file_beats_node_extra_ca_certs PASSED tests/test_ssl_context.py::TestFindCaBundlePriority::test_requests_ca_bundle_beats_node_extra_ca_certs PASSED tests/test_ssl_context.py::TestFindCaBundleNonexistentPaths::test_nonexistent_path_is_skipped PASSED tests/test_ssl_context.py::TestFindCaBundleNonexistentPaths::test_all_nonexistent_returns_none PASSED tests/test_ssl_context.py::TestFindCaBundleNonexistentPaths::test_first_nonexistent_falls_through_to_valid PASSED 11 passed in 0.91s ``` ## Real Behavior Proof - Environment: Windows 11 Home 10.0.26200, Python 3.10.18 - Exact command / steps: Ran `python -m pytest tests/test_ssl_context.py -v` after applying the fix. The new `test_node_extra_ca_certs_is_additive` test verifies `ctx.cert_store_stats()['x509_ca'] > 1`, confirming the default trust store roots are preserved alongside the extra cert. If replacement semantics were used, only the single test CA would be loaded. - Observed result: All 11 SSL context tests pass. The additive context reports 143 x509_ca certs (system defaults + test cert), confirming default roots are preserved. - Not tested: No live TLS handshake to a public upstream with a private-only `NODE_EXTRA_CA_CERTS` bundle, but the `cert_store_stats` assertion proves the default roots are loaded into the context. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes ## Additional Notes The fix aligns with the design proposed in #741 and #745's own description (which said it "builds an SSLContext") but which the merged implementation did not follow. The `server.py` consumer does not need changes because httpx accepts `ssl.SSLContext` for `verify=`. ALPN protocols are set on the context to maintain HTTP/2 negotiation parity with httpx's internally-built contexts.
2026-06-18 00:41:49 +08:00
def test_node_extra_ca_certs_returns_ssl_context(self, monkeypatch, ca_pem_file):
"""NODE_EXTRA_CA_CERTS returns an SSLContext, not a bare path (#998)."""
_clean_env(monkeypatch)
monkeypatch.setenv("NODE_EXTRA_CA_CERTS", ca_pem_file)
ctx = find_ca_bundle()
fix(proxy): treat NODE_EXTRA_CA_CERTS as additive, not replacement (#998) (#1031) ## Description `find_ca_bundle()` returns the `NODE_EXTRA_CA_CERTS` path as a bare string passed to httpx's `verify=` parameter, which makes it the sole trust store. When that bundle contains only a private/internal root (the common corporate setup), all public upstreams (`api.anthropic.com`, `api.openai.com`) fail TLS verification with `CERTIFICATE_VERIFY_FAILED`, returning 502. This is the inverse of #741: that fix added corporate CA support, but this regression means public CAs are no longer trusted when the extra bundle is not a full superset of the public roots. The fix builds an `ssl.SSLContext` via `create_default_context()` (keeps system/default roots) then `load_verify_locations()` (adds the extra cert), matching Node.js additive semantics. `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` keep their existing replacement semantics. Closes #998 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Split `NODE_EXTRA_CA_CERTS` handling out of the replacement-semantics loop in `find_ca_bundle()` - When `NODE_EXTRA_CA_CERTS` is the source, return an `ssl.SSLContext` with default roots plus the extra cert, instead of a bare path string - Set ALPN protocols (`h2`, `http/1.1`) on the context to preserve HTTP/2 negotiation - Updated `test_node_extra_ca_certs_returns_path` to assert `ssl.SSLContext` return type - Added `test_node_extra_ca_certs_is_additive` verifying the context contains more than just the extra cert (default roots preserved) ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] New tests added for new functionality ### Test Output ```text tests/test_ssl_context.py::TestFindCaBundleNoEnvVars::test_returns_none_when_no_env_var_set PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_ssl_cert_file_returns_path PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_requests_ca_bundle_returns_path PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_node_extra_ca_certs_returns_ssl_context PASSED tests/test_ssl_context.py::TestFindCaBundleWithValidPem::test_node_extra_ca_certs_is_additive PASSED tests/test_ssl_context.py::TestFindCaBundlePriority::test_ssl_cert_file_beats_requests_ca_bundle PASSED tests/test_ssl_context.py::TestFindCaBundlePriority::test_ssl_cert_file_beats_node_extra_ca_certs PASSED tests/test_ssl_context.py::TestFindCaBundlePriority::test_requests_ca_bundle_beats_node_extra_ca_certs PASSED tests/test_ssl_context.py::TestFindCaBundleNonexistentPaths::test_nonexistent_path_is_skipped PASSED tests/test_ssl_context.py::TestFindCaBundleNonexistentPaths::test_all_nonexistent_returns_none PASSED tests/test_ssl_context.py::TestFindCaBundleNonexistentPaths::test_first_nonexistent_falls_through_to_valid PASSED 11 passed in 0.91s ``` ## Real Behavior Proof - Environment: Windows 11 Home 10.0.26200, Python 3.10.18 - Exact command / steps: Ran `python -m pytest tests/test_ssl_context.py -v` after applying the fix. The new `test_node_extra_ca_certs_is_additive` test verifies `ctx.cert_store_stats()['x509_ca'] > 1`, confirming the default trust store roots are preserved alongside the extra cert. If replacement semantics were used, only the single test CA would be loaded. - Observed result: All 11 SSL context tests pass. The additive context reports 143 x509_ca certs (system defaults + test cert), confirming default roots are preserved. - Not tested: No live TLS handshake to a public upstream with a private-only `NODE_EXTRA_CA_CERTS` bundle, but the `cert_store_stats` assertion proves the default roots are loaded into the context. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes ## Additional Notes The fix aligns with the design proposed in #741 and #745's own description (which said it "builds an SSLContext") but which the merged implementation did not follow. The `server.py` consumer does not need changes because httpx accepts `ssl.SSLContext` for `verify=`. ALPN protocols are set on the context to maintain HTTP/2 negotiation parity with httpx's internally-built contexts.
2026-06-18 00:41:49 +08:00
assert isinstance(ctx, ssl.SSLContext)
def test_node_extra_ca_certs_is_additive(self, monkeypatch, ca_pem_file):
"""The SSLContext must contain default/system roots plus the extra cert (#998)."""
_clean_env(monkeypatch)
monkeypatch.setenv("NODE_EXTRA_CA_CERTS", ca_pem_file)
ctx = find_ca_bundle()
assert isinstance(ctx, ssl.SSLContext)
stats = ctx.cert_store_stats()
fix(proxy): relocate stray system-role messages to the top-level system param (#765) (#1357) ## Description On requests large enough to trigger compression, the proxy emitted an upstream Anthropic request whose `messages[0]` had `role: "system"`. Anthropic's Messages API rejects any `system` role inside `messages[]`: ``` 400 invalid_request_error: "messages.0: use the top-level 'system' parameter for the initial system prompt" ``` The original request correctly carries its system prompt in the top-level `system` parameter; a compression/transform/pipeline step relocates the harness system block into `messages[0]`, so the request fails outright (intermittent only because it requires a context large enough to compress). This adds a wire-contract guard in the Anthropic forwarder: as the **last** step before sending upstream (after every transform, memory injection, tool sort, and pipeline extension, covering both the Bedrock and direct paths), any stray `role="system"` message is relocated out of `messages[]` and merged back into the top-level `system` parameter. Content order is preserved (existing system first, relocated content after) and block-level `cache_control` survives. The guard is a no-op on the common path (no system-role entry → inputs pass through unchanged). Closes #765 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/proxy/helpers.py`: new pure helper `relocate_system_messages_to_top_level(messages, system) -> (clean_messages, new_system, changed)` plus `_system_message_to_blocks`. Handles `system` being `None`/`str`/`list`, never drops content, preserves order and content blocks. - `headroom/proxy/handlers/anthropic.py`: invoke the guard just before the byte-faithful forward block; on relocation, update `body["messages"]`/`body["system"]`, mark the body mutated (`system_role_relocated`) so the byte-faithful forwarder re-serializes, and log a warning. - `tests/test_proxy_handler_helpers.py`: 3 unit tests (relocate stray system into top-level, append-to-existing-system order, no-op without a system entry). - `CHANGELOG.md`: Bug Fixes entry. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run --extra dev python -m pytest tests/test_proxy_handler_helpers.py -q 29 passed in 4.95s # Regression on the forward path (byte-faithful forwarding, system-prompt immutability, cache stability): $ uv run --extra dev python -m pytest tests/test_proxy_handler_helpers.py tests/test_proxy_byte_faithful_forwarding.py tests/test_proxy_system_prompt_immutable.py tests/test_proxy_anthropic_cache_stability.py -q 90 passed, 15 warnings in 29.72s $ uv run ruff check headroom/proxy/helpers.py headroom/proxy/handlers/anthropic.py tests/test_proxy_handler_helpers.py All checks passed! $ uv run ruff format --check ... # 3 files already formatted $ uv run mypy headroom/proxy/helpers.py headroom/proxy/handlers/anthropic.py Success: no issues found in 2 source files ``` ## Test verification (RED → GREEN) The new tests exercise the guard directly and import the new helper at module top, so reverting the production fix makes them fail at collection. **RED — production fix reverted (helper removed):** ```text ImportError while importing test module 'tests/test_proxy_handler_helpers.py'. E ImportError: cannot import name 'relocate_system_messages_to_top_level' from 'headroom.proxy.helpers' =========================== 1 error in 0.41s =============================== ``` **GREEN — production fix applied:** ```text tests/test_proxy_handler_helpers.py ... [100%] ======================= 3 passed, 26 deselected in 1.50s ======================= ``` ## Real Behavior Proof - Environment: Python 3.13, `uv run` in this repo, branch `fix/issue-765`. - Exact command / steps: ran the guard on a body in the exact #765 failure shape — `system: None` and a `role="system"` harness block at `messages[0]`: - Observed result: ```text BEFORE: messages[0].role = system (Anthropic 400 trigger) changed = True AFTER roles = ['user', 'assistant'] system param = [{"type": "text", "text": "You are Claude Code. <system-reminder>...</system-reminder>"}] OK: no role=system in messages[]; system content preserved in top-level param ``` The illegal `role="system"` entry is removed from `messages[]` and its content lands in the top-level `system` parameter — exactly the body Anthropic accepts. - Not tested: a full live 250k+-token Claude Code session against the real Anthropic API (needs a large live context + API key); the fix is validated at the request-shaping boundary the 400 is raised on. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes The guard intentionally fires at the forwarder boundary rather than in any single transform: the issue's captures show the relocation can originate from the compression path, and pipeline extensions / hooks can also mutate `messages` late. Enforcing Anthropic's wire contract once, at the point the body is serialized upstream, fixes the 400 regardless of which step introduced the stray entry and matches the architecture invariant "never produce a `system`-role entry within `messages[]`". --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-13 18:52:09 +02:00
# Additive loading preserves whatever the runner's default trust store
# contains. Some minimal CI images have a tiny or empty default store, so
# compare against the local baseline instead of assuming "dozens" of CAs.
assert stats["x509_ca"] >= _default_x509_ca_count() + _TEST_CA_COUNT
class TestFindCaBundlePriority:
def test_ssl_cert_file_beats_requests_ca_bundle(self, monkeypatch, tmp_path):
"""SSL_CERT_FILE is used first even when REQUESTS_CA_BUNDLE is also set."""
_clean_env(monkeypatch)
pem1 = tmp_path / "first.pem"
pem2 = tmp_path / "second.pem"
pem1.write_bytes(_SELF_SIGNED_CA_PEM)
pem2.write_bytes(_SELF_SIGNED_CA_PEM)
monkeypatch.setenv("SSL_CERT_FILE", str(pem1))
monkeypatch.setenv("REQUESTS_CA_BUNDLE", str(pem2))
fix(proxy): build SSL contexts for custom CA bundles (#1134) ## Description Build explicit `ssl.SSLContext` objects for custom CA bundles configured through `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE`. Python 3.13 / newer OpenSSL can reject some enterprise/private PKI roots that platform TLS stacks accept, for example roots without a `keyUsage` extension. The new custom-CA contexts keep certificate verification enabled while clearing only `ssl.VERIFY_X509_STRICT`. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Build replacement `SSLContext` objects for `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` instead of passing raw CA bundle paths to httpx. - Clear only `ssl.VERIFY_X509_STRICT` for operator-provided custom CA contexts; certificate verification, hostname verification, expiry checks, and chain validation stay enabled. - Preserve `NODE_EXTRA_CA_CERTS` additive behavior by loading the extra CA bundle on top of the default/system trust store. - Update existing SSL context tests for replacement CA contexts, env-var priority, missing-path fallthrough, and strict-mode relaxation. - Add an Unreleased changelog entry for the proxy bug fix. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ PYTHONPATH=. .venv/bin/python -m pytest tests/test_ssl_context.py -q collected 12 items tests/test_ssl_context.py ............ [100%] 12 passed, 1 warning in 0.11s ``` ```text $ uv tool run ruff check headroom/proxy/ssl_context.py tests/test_ssl_context.py CHANGELOG.md All checks passed! $ uv tool run ruff format --check headroom/proxy/ssl_context.py tests/test_ssl_context.py 2 files already formatted ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.2, Headroom checkout on this branch, `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` pointed at an enterprise/private PKI CA bundle. Upstream HTTPS endpoint name is redacted. - Exact command / steps: Ran a Python smoke script that imports `headroom.proxy.ssl_context.find_ca_bundle()`, passes the returned verifier into `httpx.AsyncClient(verify=...)`, and performs a GET against the enterprise HTTPS endpoint that previously failed with OpenSSL strict verification. - Observed result: The request used an `SSLContext`, strict X.509 verification was disabled for that custom CA context, and the request reached the upstream HTTP response: ```text verify_type SSLContext strict_enabled False status 302 ``` - Not tested: Full `uv run pytest`, `uv sync --extra dev`, and `mypy headroom` in this local environment. The editable build currently fails before tests run because Cargo's `ort-sys` build script cannot download ORT prebuilt binaries due an unrelated local certificate verification error against the ORT CDN. No dependency or lockfile changes are included in this PR. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes No dependencies or lockfiles changed. Documentation is unchanged because this is a bug fix to existing custom CA environment-variable behavior rather than a new user-facing configuration surface. Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-22 19:48:19 -04:00
created_context = FakeSSLContext()
fix(proxy): build SSL contexts for custom CA bundles (#1134) ## Description Build explicit `ssl.SSLContext` objects for custom CA bundles configured through `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE`. Python 3.13 / newer OpenSSL can reject some enterprise/private PKI roots that platform TLS stacks accept, for example roots without a `keyUsage` extension. The new custom-CA contexts keep certificate verification enabled while clearing only `ssl.VERIFY_X509_STRICT`. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Build replacement `SSLContext` objects for `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` instead of passing raw CA bundle paths to httpx. - Clear only `ssl.VERIFY_X509_STRICT` for operator-provided custom CA contexts; certificate verification, hostname verification, expiry checks, and chain validation stay enabled. - Preserve `NODE_EXTRA_CA_CERTS` additive behavior by loading the extra CA bundle on top of the default/system trust store. - Update existing SSL context tests for replacement CA contexts, env-var priority, missing-path fallthrough, and strict-mode relaxation. - Add an Unreleased changelog entry for the proxy bug fix. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ PYTHONPATH=. .venv/bin/python -m pytest tests/test_ssl_context.py -q collected 12 items tests/test_ssl_context.py ............ [100%] 12 passed, 1 warning in 0.11s ``` ```text $ uv tool run ruff check headroom/proxy/ssl_context.py tests/test_ssl_context.py CHANGELOG.md All checks passed! $ uv tool run ruff format --check headroom/proxy/ssl_context.py tests/test_ssl_context.py 2 files already formatted ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.2, Headroom checkout on this branch, `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` pointed at an enterprise/private PKI CA bundle. Upstream HTTPS endpoint name is redacted. - Exact command / steps: Ran a Python smoke script that imports `headroom.proxy.ssl_context.find_ca_bundle()`, passes the returned verifier into `httpx.AsyncClient(verify=...)`, and performs a GET against the enterprise HTTPS endpoint that previously failed with OpenSSL strict verification. - Observed result: The request used an `SSLContext`, strict X.509 verification was disabled for that custom CA context, and the request reached the upstream HTTP response: ```text verify_type SSLContext strict_enabled False status 302 ``` - Not tested: Full `uv run pytest`, `uv sync --extra dev`, and `mypy headroom` in this local environment. The editable build currently fails before tests run because Cargo's `ort-sys` build script cannot download ORT prebuilt binaries due an unrelated local certificate verification error against the ORT CDN. No dependency or lockfile changes are included in this PR. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes No dependencies or lockfiles changed. Documentation is unchanged because this is a bug fix to existing custom CA environment-variable behavior rather than a new user-facing configuration surface. Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-22 19:48:19 -04:00
def fake_create_default_context(*, cafile: str | None = None):
assert cafile == str(pem1)
return created_context
monkeypatch.setattr(ssl_context.ssl, "create_default_context", fake_create_default_context)
assert find_ca_bundle() is created_context
def test_ssl_cert_file_beats_node_extra_ca_certs(self, monkeypatch, tmp_path):
"""SSL_CERT_FILE takes precedence over NODE_EXTRA_CA_CERTS."""
_clean_env(monkeypatch)
pem = tmp_path / "ca.pem"
pem.write_bytes(_SELF_SIGNED_CA_PEM)
monkeypatch.setenv("SSL_CERT_FILE", str(pem))
monkeypatch.setenv("NODE_EXTRA_CA_CERTS", "/nonexistent/node.pem")
fix(proxy): build SSL contexts for custom CA bundles (#1134) ## Description Build explicit `ssl.SSLContext` objects for custom CA bundles configured through `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE`. Python 3.13 / newer OpenSSL can reject some enterprise/private PKI roots that platform TLS stacks accept, for example roots without a `keyUsage` extension. The new custom-CA contexts keep certificate verification enabled while clearing only `ssl.VERIFY_X509_STRICT`. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Build replacement `SSLContext` objects for `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` instead of passing raw CA bundle paths to httpx. - Clear only `ssl.VERIFY_X509_STRICT` for operator-provided custom CA contexts; certificate verification, hostname verification, expiry checks, and chain validation stay enabled. - Preserve `NODE_EXTRA_CA_CERTS` additive behavior by loading the extra CA bundle on top of the default/system trust store. - Update existing SSL context tests for replacement CA contexts, env-var priority, missing-path fallthrough, and strict-mode relaxation. - Add an Unreleased changelog entry for the proxy bug fix. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ PYTHONPATH=. .venv/bin/python -m pytest tests/test_ssl_context.py -q collected 12 items tests/test_ssl_context.py ............ [100%] 12 passed, 1 warning in 0.11s ``` ```text $ uv tool run ruff check headroom/proxy/ssl_context.py tests/test_ssl_context.py CHANGELOG.md All checks passed! $ uv tool run ruff format --check headroom/proxy/ssl_context.py tests/test_ssl_context.py 2 files already formatted ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.2, Headroom checkout on this branch, `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` pointed at an enterprise/private PKI CA bundle. Upstream HTTPS endpoint name is redacted. - Exact command / steps: Ran a Python smoke script that imports `headroom.proxy.ssl_context.find_ca_bundle()`, passes the returned verifier into `httpx.AsyncClient(verify=...)`, and performs a GET against the enterprise HTTPS endpoint that previously failed with OpenSSL strict verification. - Observed result: The request used an `SSLContext`, strict X.509 verification was disabled for that custom CA context, and the request reached the upstream HTTP response: ```text verify_type SSLContext strict_enabled False status 302 ``` - Not tested: Full `uv run pytest`, `uv sync --extra dev`, and `mypy headroom` in this local environment. The editable build currently fails before tests run because Cargo's `ort-sys` build script cannot download ORT prebuilt binaries due an unrelated local certificate verification error against the ORT CDN. No dependency or lockfile changes are included in this PR. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes No dependencies or lockfiles changed. Documentation is unchanged because this is a bug fix to existing custom CA environment-variable behavior rather than a new user-facing configuration surface. Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-22 19:48:19 -04:00
created_context = FakeSSLContext()
fix(proxy): build SSL contexts for custom CA bundles (#1134) ## Description Build explicit `ssl.SSLContext` objects for custom CA bundles configured through `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE`. Python 3.13 / newer OpenSSL can reject some enterprise/private PKI roots that platform TLS stacks accept, for example roots without a `keyUsage` extension. The new custom-CA contexts keep certificate verification enabled while clearing only `ssl.VERIFY_X509_STRICT`. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Build replacement `SSLContext` objects for `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` instead of passing raw CA bundle paths to httpx. - Clear only `ssl.VERIFY_X509_STRICT` for operator-provided custom CA contexts; certificate verification, hostname verification, expiry checks, and chain validation stay enabled. - Preserve `NODE_EXTRA_CA_CERTS` additive behavior by loading the extra CA bundle on top of the default/system trust store. - Update existing SSL context tests for replacement CA contexts, env-var priority, missing-path fallthrough, and strict-mode relaxation. - Add an Unreleased changelog entry for the proxy bug fix. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ PYTHONPATH=. .venv/bin/python -m pytest tests/test_ssl_context.py -q collected 12 items tests/test_ssl_context.py ............ [100%] 12 passed, 1 warning in 0.11s ``` ```text $ uv tool run ruff check headroom/proxy/ssl_context.py tests/test_ssl_context.py CHANGELOG.md All checks passed! $ uv tool run ruff format --check headroom/proxy/ssl_context.py tests/test_ssl_context.py 2 files already formatted ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.2, Headroom checkout on this branch, `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` pointed at an enterprise/private PKI CA bundle. Upstream HTTPS endpoint name is redacted. - Exact command / steps: Ran a Python smoke script that imports `headroom.proxy.ssl_context.find_ca_bundle()`, passes the returned verifier into `httpx.AsyncClient(verify=...)`, and performs a GET against the enterprise HTTPS endpoint that previously failed with OpenSSL strict verification. - Observed result: The request used an `SSLContext`, strict X.509 verification was disabled for that custom CA context, and the request reached the upstream HTTP response: ```text verify_type SSLContext strict_enabled False status 302 ``` - Not tested: Full `uv run pytest`, `uv sync --extra dev`, and `mypy headroom` in this local environment. The editable build currently fails before tests run because Cargo's `ort-sys` build script cannot download ORT prebuilt binaries due an unrelated local certificate verification error against the ORT CDN. No dependency or lockfile changes are included in this PR. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes No dependencies or lockfiles changed. Documentation is unchanged because this is a bug fix to existing custom CA environment-variable behavior rather than a new user-facing configuration surface. Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-22 19:48:19 -04:00
def fake_create_default_context(*, cafile: str | None = None):
assert cafile == str(pem)
return created_context
monkeypatch.setattr(ssl_context.ssl, "create_default_context", fake_create_default_context)
assert find_ca_bundle() is created_context
def test_requests_ca_bundle_beats_node_extra_ca_certs(self, monkeypatch, tmp_path):
"""REQUESTS_CA_BUNDLE is used before NODE_EXTRA_CA_CERTS."""
_clean_env(monkeypatch)
pem = tmp_path / "ca.pem"
pem.write_bytes(_SELF_SIGNED_CA_PEM)
monkeypatch.setenv("SSL_CERT_FILE", "/nonexistent/ssl.pem")
monkeypatch.setenv("REQUESTS_CA_BUNDLE", str(pem))
monkeypatch.setenv("NODE_EXTRA_CA_CERTS", "/nonexistent/node.pem")
fix(proxy): build SSL contexts for custom CA bundles (#1134) ## Description Build explicit `ssl.SSLContext` objects for custom CA bundles configured through `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE`. Python 3.13 / newer OpenSSL can reject some enterprise/private PKI roots that platform TLS stacks accept, for example roots without a `keyUsage` extension. The new custom-CA contexts keep certificate verification enabled while clearing only `ssl.VERIFY_X509_STRICT`. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Build replacement `SSLContext` objects for `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` instead of passing raw CA bundle paths to httpx. - Clear only `ssl.VERIFY_X509_STRICT` for operator-provided custom CA contexts; certificate verification, hostname verification, expiry checks, and chain validation stay enabled. - Preserve `NODE_EXTRA_CA_CERTS` additive behavior by loading the extra CA bundle on top of the default/system trust store. - Update existing SSL context tests for replacement CA contexts, env-var priority, missing-path fallthrough, and strict-mode relaxation. - Add an Unreleased changelog entry for the proxy bug fix. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ PYTHONPATH=. .venv/bin/python -m pytest tests/test_ssl_context.py -q collected 12 items tests/test_ssl_context.py ............ [100%] 12 passed, 1 warning in 0.11s ``` ```text $ uv tool run ruff check headroom/proxy/ssl_context.py tests/test_ssl_context.py CHANGELOG.md All checks passed! $ uv tool run ruff format --check headroom/proxy/ssl_context.py tests/test_ssl_context.py 2 files already formatted ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.2, Headroom checkout on this branch, `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` pointed at an enterprise/private PKI CA bundle. Upstream HTTPS endpoint name is redacted. - Exact command / steps: Ran a Python smoke script that imports `headroom.proxy.ssl_context.find_ca_bundle()`, passes the returned verifier into `httpx.AsyncClient(verify=...)`, and performs a GET against the enterprise HTTPS endpoint that previously failed with OpenSSL strict verification. - Observed result: The request used an `SSLContext`, strict X.509 verification was disabled for that custom CA context, and the request reached the upstream HTTP response: ```text verify_type SSLContext strict_enabled False status 302 ``` - Not tested: Full `uv run pytest`, `uv sync --extra dev`, and `mypy headroom` in this local environment. The editable build currently fails before tests run because Cargo's `ort-sys` build script cannot download ORT prebuilt binaries due an unrelated local certificate verification error against the ORT CDN. No dependency or lockfile changes are included in this PR. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes No dependencies or lockfiles changed. Documentation is unchanged because this is a bug fix to existing custom CA environment-variable behavior rather than a new user-facing configuration surface. Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-22 19:48:19 -04:00
created_context = FakeSSLContext()
fix(proxy): build SSL contexts for custom CA bundles (#1134) ## Description Build explicit `ssl.SSLContext` objects for custom CA bundles configured through `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE`. Python 3.13 / newer OpenSSL can reject some enterprise/private PKI roots that platform TLS stacks accept, for example roots without a `keyUsage` extension. The new custom-CA contexts keep certificate verification enabled while clearing only `ssl.VERIFY_X509_STRICT`. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Build replacement `SSLContext` objects for `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` instead of passing raw CA bundle paths to httpx. - Clear only `ssl.VERIFY_X509_STRICT` for operator-provided custom CA contexts; certificate verification, hostname verification, expiry checks, and chain validation stay enabled. - Preserve `NODE_EXTRA_CA_CERTS` additive behavior by loading the extra CA bundle on top of the default/system trust store. - Update existing SSL context tests for replacement CA contexts, env-var priority, missing-path fallthrough, and strict-mode relaxation. - Add an Unreleased changelog entry for the proxy bug fix. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ PYTHONPATH=. .venv/bin/python -m pytest tests/test_ssl_context.py -q collected 12 items tests/test_ssl_context.py ............ [100%] 12 passed, 1 warning in 0.11s ``` ```text $ uv tool run ruff check headroom/proxy/ssl_context.py tests/test_ssl_context.py CHANGELOG.md All checks passed! $ uv tool run ruff format --check headroom/proxy/ssl_context.py tests/test_ssl_context.py 2 files already formatted ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.2, Headroom checkout on this branch, `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` pointed at an enterprise/private PKI CA bundle. Upstream HTTPS endpoint name is redacted. - Exact command / steps: Ran a Python smoke script that imports `headroom.proxy.ssl_context.find_ca_bundle()`, passes the returned verifier into `httpx.AsyncClient(verify=...)`, and performs a GET against the enterprise HTTPS endpoint that previously failed with OpenSSL strict verification. - Observed result: The request used an `SSLContext`, strict X.509 verification was disabled for that custom CA context, and the request reached the upstream HTTP response: ```text verify_type SSLContext strict_enabled False status 302 ``` - Not tested: Full `uv run pytest`, `uv sync --extra dev`, and `mypy headroom` in this local environment. The editable build currently fails before tests run because Cargo's `ort-sys` build script cannot download ORT prebuilt binaries due an unrelated local certificate verification error against the ORT CDN. No dependency or lockfile changes are included in this PR. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes No dependencies or lockfiles changed. Documentation is unchanged because this is a bug fix to existing custom CA environment-variable behavior rather than a new user-facing configuration surface. Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-22 19:48:19 -04:00
def fake_create_default_context(*, cafile: str | None = None):
assert cafile == str(pem)
return created_context
monkeypatch.setattr(ssl_context.ssl, "create_default_context", fake_create_default_context)
assert find_ca_bundle() is created_context
class TestFindCaBundleNonexistentPaths:
def test_nonexistent_path_is_skipped(self, monkeypatch):
_clean_env(monkeypatch)
monkeypatch.setenv("SSL_CERT_FILE", "/nonexistent/path/ca.pem")
assert find_ca_bundle() is None
def test_all_nonexistent_returns_none(self, monkeypatch):
_clean_env(monkeypatch)
monkeypatch.setenv("SSL_CERT_FILE", "/no/such/file1.pem")
monkeypatch.setenv("REQUESTS_CA_BUNDLE", "/no/such/file2.pem")
monkeypatch.setenv("NODE_EXTRA_CA_CERTS", "/no/such/file3.pem")
assert find_ca_bundle() is None
def test_first_nonexistent_falls_through_to_valid(self, monkeypatch, ca_pem_file):
"""When the first env var path is missing, the next valid one is used."""
_clean_env(monkeypatch)
monkeypatch.setenv("SSL_CERT_FILE", "/nonexistent/ssl.pem")
monkeypatch.setenv("REQUESTS_CA_BUNDLE", ca_pem_file)
ctx = find_ca_bundle()
fix(proxy): build SSL contexts for custom CA bundles (#1134) ## Description Build explicit `ssl.SSLContext` objects for custom CA bundles configured through `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE`. Python 3.13 / newer OpenSSL can reject some enterprise/private PKI roots that platform TLS stacks accept, for example roots without a `keyUsage` extension. The new custom-CA contexts keep certificate verification enabled while clearing only `ssl.VERIFY_X509_STRICT`. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Build replacement `SSLContext` objects for `SSL_CERT_FILE` and `REQUESTS_CA_BUNDLE` instead of passing raw CA bundle paths to httpx. - Clear only `ssl.VERIFY_X509_STRICT` for operator-provided custom CA contexts; certificate verification, hostname verification, expiry checks, and chain validation stay enabled. - Preserve `NODE_EXTRA_CA_CERTS` additive behavior by loading the extra CA bundle on top of the default/system trust store. - Update existing SSL context tests for replacement CA contexts, env-var priority, missing-path fallthrough, and strict-mode relaxation. - Add an Unreleased changelog entry for the proxy bug fix. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ PYTHONPATH=. .venv/bin/python -m pytest tests/test_ssl_context.py -q collected 12 items tests/test_ssl_context.py ............ [100%] 12 passed, 1 warning in 0.11s ``` ```text $ uv tool run ruff check headroom/proxy/ssl_context.py tests/test_ssl_context.py CHANGELOG.md All checks passed! $ uv tool run ruff format --check headroom/proxy/ssl_context.py tests/test_ssl_context.py 2 files already formatted ``` ## Real Behavior Proof - Environment: macOS, Python 3.13.2, Headroom checkout on this branch, `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` pointed at an enterprise/private PKI CA bundle. Upstream HTTPS endpoint name is redacted. - Exact command / steps: Ran a Python smoke script that imports `headroom.proxy.ssl_context.find_ca_bundle()`, passes the returned verifier into `httpx.AsyncClient(verify=...)`, and performs a GET against the enterprise HTTPS endpoint that previously failed with OpenSSL strict verification. - Observed result: The request used an `SSLContext`, strict X.509 verification was disabled for that custom CA context, and the request reached the upstream HTTP response: ```text verify_type SSLContext strict_enabled False status 302 ``` - Not tested: Full `uv run pytest`, `uv sync --extra dev`, and `mypy headroom` in this local environment. The editable build currently fails before tests run because Cargo's `ort-sys` build script cannot download ORT prebuilt binaries due an unrelated local certificate verification error against the ORT CDN. No dependency or lockfile changes are included in this PR. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A. ## Additional Notes No dependencies or lockfiles changed. Documentation is unchanged because this is a bug fix to existing custom CA environment-variable behavior rather than a new user-facing configuration surface. Signed-off-by: Mark Phelps <209477+markphelps@users.noreply.github.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-22 19:48:19 -04:00
assert isinstance(ctx, ssl.SSLContext)
fix(tls): add HEADROOM_TLS_STRICT=0 toggle for corporate SSL inspection (#1308) (#1341) ## Description Behind a corporate TLS-inspection proxy (Zscaler, Netskope) on Python 3.13+, Headroom can't reach the network even with the corporate root correctly installed and trusted. Every path fails with: ``` [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: Basic Constraints of CA cert not marked critical ``` This isn't a missing-CA problem — the cert is found and trusted. Python 3.13 + OpenSSL 3.x enable `VERIFY_X509_STRICT` by default, which enforces RFC 5280 §4.2.1.9 (a CA cert's `basicConstraints` MUST be marked critical). Inspection roots set `CA:TRUE` without the critical bit, so the chain is rejected. Adding the CA to a bundle does nothing — it's the strict check that fails, and the existing README section only covers `unable to get local issuer certificate`. There are two independent sources of the strict flag (both reported in the issue): Python's own `ssl.create_default_context()` (hits the httpx upstream client), and urllib3 ≥ 2.5's `create_urllib3_context()` (hits the `huggingface_hub` model-download path). Closes #1308 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `ssl_context.py`: added `HEADROOM_TLS_STRICT`. `tls_strict_disabled()` reads the toggle (off-values `0/false/no/off`, default strict). `build_httpx_verify()` resolves the httpx `verify=` value: a configured CA bundle wins; otherwise, when the toggle is off, a default-trust-store context with **only** `VERIFY_X509_STRICT` cleared (so a corporate root that lives in the OS store but trips strict mode still validates); otherwise `True` (httpx default). `apply_global_tls_relaxation()` monkeypatches urllib3's `create_urllib3_context` to drop the strict flag — idempotent, guarded, no-op if urllib3 is absent or the toggle is on. - `server.py`: the proxy's httpx upstream client now uses `build_httpx_verify()` instead of `find_ca_bundle()`-or-`True`. - `cli/proxy.py`: calls `apply_global_tls_relaxation()` at module import, before `huggingface_hub`/`requests` import and cache their context. - README: a distinct SSL-inspection subsection for the `Basic Constraints ... not marked critical` failure, separate from `unable to get local issuer certificate`. Documents that the Rust core's ONNX download (`cdn.pyke.io`) uses a separate stack (rustls/OS trust store) unaffected by the toggle — corporate root must be in the Windows **machine** store, or pre-provision via `ORT_STRATEGY=system`. Chain validation, signature, expiry, and hostname checks all stay on — `HEADROOM_TLS_STRICT=0` is strictly narrower than `verify=False`. Default is strict, matching Python's own default. ## Testing - [x] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_ssl_context.py -q 31 passed # 19 existing + 12 new (TestTlsStrictDisabled, TestBuildHttpxVerify, TestApplyGlobalTlsRelaxation). ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.10.11 (OpenSSL build that exposes `VERIFY_X509_STRICT`). - Exact command / steps: exercised the module directly — set/unset `HEADROOM_TLS_STRICT`, inspected the resolved httpx `verify` value and the urllib3 context's `verify_flags`. - Observed result: default → `verify=True` (strict preserved); `HEADROOM_TLS_STRICT=0` → httpx gets an `SSLContext` with `VERIFY_X509_STRICT` cleared but `verify_mode == CERT_REQUIRED` and the full default trust store (cert_store x509_ca > 1); `apply_global_tls_relaxation()` patches `urllib3.util.ssl_.create_urllib3_context` so new contexts have the strict flag cleared, and is idempotent. A configured `SSL_CERT_FILE` still wins over the toggle. - Not tested: an actual handshake through a live Zscaler/Netskope MITM on Python 3.13 — I don't have that environment. The fix targets exactly the flag the issue identifies (`VERIFY_X509_STRICT`) on both reported context builders; I verified the flag manipulation and resolution logic directly rather than simulating the proxy. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - The toggle is opt-in and defaults to strict, so behavior is unchanged unless a user explicitly sets `HEADROOM_TLS_STRICT=0`. It clears only the strict flag, never disables verification. - The httpx path uses an explicit context (clean, testable); the urllib3 path needs a monkeypatch because `huggingface_hub` → `requests` builds its context internally and never sees ours. - CHANGELOG.md isn't touched — release-please generates it from the `fix(tls):` commit subject. - I scoped this to the two Python TLS stacks the issue calls out and documented (rather than tried to patch) the separate Rust/ONNX path, since that one resolves through the OS trust store and isn't something this Python toggle can reach.
2026-06-24 20:21:30 +05:30
# ---------------------------------------------------------------------------
# HEADROOM_TLS_STRICT toggle (issue #1308): corporate TLS-inspection roots
# (Zscaler, Netskope) set CA:TRUE without the critical bit, which Python 3.13
# + OpenSSL 3.x reject under VERIFY_X509_STRICT. A CA bundle can't fix that —
# the cert is found, the strict check fails. The toggle clears only the strict
# flag, on both the httpx upstream path and the urllib3/huggingface path.
# ---------------------------------------------------------------------------
class TestTlsStrictDisabled:
@pytest.mark.parametrize("val", ["0", "false", "FALSE", "No", "off", " off "])
def test_off_values_disable_strict(self, monkeypatch, val):
_clean_env(monkeypatch)
monkeypatch.setenv("HEADROOM_TLS_STRICT", val)
assert tls_strict_disabled() is True
@pytest.mark.parametrize("val", ["1", "true", "yes", "on", "", "strict", "00"])
def test_other_values_keep_strict(self, monkeypatch, val):
_clean_env(monkeypatch)
monkeypatch.setenv("HEADROOM_TLS_STRICT", val)
assert tls_strict_disabled() is False
def test_unset_keeps_strict(self, monkeypatch):
_clean_env(monkeypatch)
assert tls_strict_disabled() is False
class TestBuildHttpxVerify:
def test_default_returns_true(self, monkeypatch):
"""No CA bundle, strict on → httpx's own default verification."""
_clean_env(monkeypatch)
assert build_httpx_verify() is True
def test_toggle_off_returns_relaxed_context(self, monkeypatch):
"""No CA bundle, strict OFF → default trust store with strict cleared."""
_clean_env(monkeypatch)
monkeypatch.setenv("HEADROOM_TLS_STRICT", "0")
ctx = build_httpx_verify()
assert isinstance(ctx, ssl.SSLContext)
strict_flag = getattr(ssl, "VERIFY_X509_STRICT", 0)
if strict_flag:
assert ctx.verify_flags & strict_flag == 0
# Still a real verifying context — NOT verify=False.
assert ctx.verify_mode == ssl.CERT_REQUIRED
fix(proxy): relocate stray system-role messages to the top-level system param (#765) (#1357) ## Description On requests large enough to trigger compression, the proxy emitted an upstream Anthropic request whose `messages[0]` had `role: "system"`. Anthropic's Messages API rejects any `system` role inside `messages[]`: ``` 400 invalid_request_error: "messages.0: use the top-level 'system' parameter for the initial system prompt" ``` The original request correctly carries its system prompt in the top-level `system` parameter; a compression/transform/pipeline step relocates the harness system block into `messages[0]`, so the request fails outright (intermittent only because it requires a context large enough to compress). This adds a wire-contract guard in the Anthropic forwarder: as the **last** step before sending upstream (after every transform, memory injection, tool sort, and pipeline extension, covering both the Bedrock and direct paths), any stray `role="system"` message is relocated out of `messages[]` and merged back into the top-level `system` parameter. Content order is preserved (existing system first, relocated content after) and block-level `cache_control` survives. The guard is a no-op on the common path (no system-role entry → inputs pass through unchanged). Closes #765 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `headroom/proxy/helpers.py`: new pure helper `relocate_system_messages_to_top_level(messages, system) -> (clean_messages, new_system, changed)` plus `_system_message_to_blocks`. Handles `system` being `None`/`str`/`list`, never drops content, preserves order and content blocks. - `headroom/proxy/handlers/anthropic.py`: invoke the guard just before the byte-faithful forward block; on relocation, update `body["messages"]`/`body["system"]`, mark the body mutated (`system_role_relocated`) so the byte-faithful forwarder re-serializes, and log a warning. - `tests/test_proxy_handler_helpers.py`: 3 unit tests (relocate stray system into top-level, append-to-existing-system order, no-op without a system entry). - `CHANGELOG.md`: Bug Fixes entry. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ uv run --extra dev python -m pytest tests/test_proxy_handler_helpers.py -q 29 passed in 4.95s # Regression on the forward path (byte-faithful forwarding, system-prompt immutability, cache stability): $ uv run --extra dev python -m pytest tests/test_proxy_handler_helpers.py tests/test_proxy_byte_faithful_forwarding.py tests/test_proxy_system_prompt_immutable.py tests/test_proxy_anthropic_cache_stability.py -q 90 passed, 15 warnings in 29.72s $ uv run ruff check headroom/proxy/helpers.py headroom/proxy/handlers/anthropic.py tests/test_proxy_handler_helpers.py All checks passed! $ uv run ruff format --check ... # 3 files already formatted $ uv run mypy headroom/proxy/helpers.py headroom/proxy/handlers/anthropic.py Success: no issues found in 2 source files ``` ## Test verification (RED → GREEN) The new tests exercise the guard directly and import the new helper at module top, so reverting the production fix makes them fail at collection. **RED — production fix reverted (helper removed):** ```text ImportError while importing test module 'tests/test_proxy_handler_helpers.py'. E ImportError: cannot import name 'relocate_system_messages_to_top_level' from 'headroom.proxy.helpers' =========================== 1 error in 0.41s =============================== ``` **GREEN — production fix applied:** ```text tests/test_proxy_handler_helpers.py ... [100%] ======================= 3 passed, 26 deselected in 1.50s ======================= ``` ## Real Behavior Proof - Environment: Python 3.13, `uv run` in this repo, branch `fix/issue-765`. - Exact command / steps: ran the guard on a body in the exact #765 failure shape — `system: None` and a `role="system"` harness block at `messages[0]`: - Observed result: ```text BEFORE: messages[0].role = system (Anthropic 400 trigger) changed = True AFTER roles = ['user', 'assistant'] system param = [{"type": "text", "text": "You are Claude Code. <system-reminder>...</system-reminder>"}] OK: no role=system in messages[]; system content preserved in top-level param ``` The illegal `role="system"` entry is removed from `messages[]` and its content lands in the top-level `system` parameter — exactly the body Anthropic accepts. - Not tested: a full live 250k+-token Claude Code session against the real Anthropic API (needs a large live context + API key); the fix is validated at the request-shaping boundary the 400 is raised on. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes The guard intentionally fires at the forwarder boundary rather than in any single transform: the issue's captures show the relocation can originate from the compression path, and pipeline extensions / hooks can also mutate `messages` late. Enforcing Anthropic's wire contract once, at the point the body is serialized upstream, fixes the 400 regardless of which step introduced the stray entry and matches the architecture invariant "never produce a `system`-role entry within `messages[]`". --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-08-13 18:52:09 +02:00
# Default trust store retained for this runner, not replaced by a custom
# one-cert bundle.
assert ctx.cert_store_stats()["x509_ca"] == _default_x509_ca_count()
fix(tls): add HEADROOM_TLS_STRICT=0 toggle for corporate SSL inspection (#1308) (#1341) ## Description Behind a corporate TLS-inspection proxy (Zscaler, Netskope) on Python 3.13+, Headroom can't reach the network even with the corporate root correctly installed and trusted. Every path fails with: ``` [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: Basic Constraints of CA cert not marked critical ``` This isn't a missing-CA problem — the cert is found and trusted. Python 3.13 + OpenSSL 3.x enable `VERIFY_X509_STRICT` by default, which enforces RFC 5280 §4.2.1.9 (a CA cert's `basicConstraints` MUST be marked critical). Inspection roots set `CA:TRUE` without the critical bit, so the chain is rejected. Adding the CA to a bundle does nothing — it's the strict check that fails, and the existing README section only covers `unable to get local issuer certificate`. There are two independent sources of the strict flag (both reported in the issue): Python's own `ssl.create_default_context()` (hits the httpx upstream client), and urllib3 ≥ 2.5's `create_urllib3_context()` (hits the `huggingface_hub` model-download path). Closes #1308 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `ssl_context.py`: added `HEADROOM_TLS_STRICT`. `tls_strict_disabled()` reads the toggle (off-values `0/false/no/off`, default strict). `build_httpx_verify()` resolves the httpx `verify=` value: a configured CA bundle wins; otherwise, when the toggle is off, a default-trust-store context with **only** `VERIFY_X509_STRICT` cleared (so a corporate root that lives in the OS store but trips strict mode still validates); otherwise `True` (httpx default). `apply_global_tls_relaxation()` monkeypatches urllib3's `create_urllib3_context` to drop the strict flag — idempotent, guarded, no-op if urllib3 is absent or the toggle is on. - `server.py`: the proxy's httpx upstream client now uses `build_httpx_verify()` instead of `find_ca_bundle()`-or-`True`. - `cli/proxy.py`: calls `apply_global_tls_relaxation()` at module import, before `huggingface_hub`/`requests` import and cache their context. - README: a distinct SSL-inspection subsection for the `Basic Constraints ... not marked critical` failure, separate from `unable to get local issuer certificate`. Documents that the Rust core's ONNX download (`cdn.pyke.io`) uses a separate stack (rustls/OS trust store) unaffected by the toggle — corporate root must be in the Windows **machine** store, or pre-provision via `ORT_STRATEGY=system`. Chain validation, signature, expiry, and hostname checks all stay on — `HEADROOM_TLS_STRICT=0` is strictly narrower than `verify=False`. Default is strict, matching Python's own default. ## Testing - [x] Unit tests pass (`pytest`) - [ ] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_ssl_context.py -q 31 passed # 19 existing + 12 new (TestTlsStrictDisabled, TestBuildHttpxVerify, TestApplyGlobalTlsRelaxation). ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.10.11 (OpenSSL build that exposes `VERIFY_X509_STRICT`). - Exact command / steps: exercised the module directly — set/unset `HEADROOM_TLS_STRICT`, inspected the resolved httpx `verify` value and the urllib3 context's `verify_flags`. - Observed result: default → `verify=True` (strict preserved); `HEADROOM_TLS_STRICT=0` → httpx gets an `SSLContext` with `VERIFY_X509_STRICT` cleared but `verify_mode == CERT_REQUIRED` and the full default trust store (cert_store x509_ca > 1); `apply_global_tls_relaxation()` patches `urllib3.util.ssl_.create_urllib3_context` so new contexts have the strict flag cleared, and is idempotent. A configured `SSL_CERT_FILE` still wins over the toggle. - Not tested: an actual handshake through a live Zscaler/Netskope MITM on Python 3.13 — I don't have that environment. The fix targets exactly the flag the issue identifies (`VERIFY_X509_STRICT`) on both reported context builders; I verified the flag manipulation and resolution logic directly rather than simulating the proxy. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - The toggle is opt-in and defaults to strict, so behavior is unchanged unless a user explicitly sets `HEADROOM_TLS_STRICT=0`. It clears only the strict flag, never disables verification. - The httpx path uses an explicit context (clean, testable); the urllib3 path needs a monkeypatch because `huggingface_hub` → `requests` builds its context internally and never sees ours. - CHANGELOG.md isn't touched — release-please generates it from the `fix(tls):` commit subject. - I scoped this to the two Python TLS stacks the issue calls out and documented (rather than tried to patch) the separate Rust/ONNX path, since that one resolves through the OS trust store and isn't something this Python toggle can reach.
2026-06-24 20:21:30 +05:30
def test_custom_ca_takes_precedence_over_toggle(self, monkeypatch, ca_pem_file):
"""A configured CA bundle wins; the result is that bundle's context."""
_clean_env(monkeypatch)
monkeypatch.setenv("SSL_CERT_FILE", ca_pem_file)
monkeypatch.setenv("HEADROOM_TLS_STRICT", "0")
ctx = build_httpx_verify()
assert isinstance(ctx, ssl.SSLContext)
# Replacement bundle → only the single test CA is trusted.
assert ctx.cert_store_stats()["x509_ca"] == 1
class TestApplyGlobalTlsRelaxation:
def test_noop_when_strict_on(self, monkeypatch):
_clean_env(monkeypatch)
assert apply_global_tls_relaxation() is False
def test_patches_urllib3_when_toggle_off(self, monkeypatch):
_clean_env(monkeypatch)
monkeypatch.setenv("HEADROOM_TLS_STRICT", "0")
strict_flag = getattr(ssl, "VERIFY_X509_STRICT", 0)
if not strict_flag:
pytest.skip("VERIFY_X509_STRICT unavailable on this OpenSSL build")
import urllib3.util.ssl_ as u3ssl
original = u3ssl.create_urllib3_context
try:
assert apply_global_tls_relaxation() is True
ctx = u3ssl.create_urllib3_context()
assert ctx.verify_flags & strict_flag == 0
# Idempotent: second call doesn't re-wrap or error.
assert apply_global_tls_relaxation() is True
assert getattr(u3ssl.create_urllib3_context, "_headroom_strict_relaxed", False)
finally:
u3ssl.create_urllib3_context = original