0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
242 lines
10 KiB
Python
242 lines
10 KiB
Python
"""SSL context builder for the Headroom upstream httpx client.
|
|
|
|
Respects the standard CA-bundle environment variables used by Python
|
|
(``SSL_CERT_FILE``), requests (``REQUESTS_CA_BUNDLE``), and Node.js /
|
|
Claude Code (``NODE_EXTRA_CA_CERTS``) so that enterprise / corporate
|
|
deployments with custom certificate authorities work without extra
|
|
configuration.
|
|
|
|
Priority order (first match wins):
|
|
1. ``SSL_CERT_FILE`` — replacement semantics (only these CAs are trusted)
|
|
2. ``REQUESTS_CA_BUNDLE`` — replacement semantics
|
|
3. ``NODE_EXTRA_CA_CERTS`` — **additive** semantics (extra roots loaded
|
|
on top of the default/system trust store, matching Node.js behavior)
|
|
|
|
Strict-mode toggle (``HEADROOM_TLS_STRICT``):
|
|
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. Corporate TLS-inspection roots (Zscaler, Netskope, …)
|
|
commonly set ``CA:TRUE`` *without* the critical bit, so the chain is
|
|
rejected with ``Basic Constraints of CA cert not marked critical`` even
|
|
though the root is correctly installed and trusted. A CA bundle env var
|
|
can't fix this — the cert is found, it's the strict check that fails.
|
|
|
|
Setting ``HEADROOM_TLS_STRICT=0`` clears *only* ``VERIFY_X509_STRICT`` from
|
|
every TLS context Headroom controls (the httpx upstream client AND the
|
|
urllib3/requests stack used by ``huggingface_hub`` for model downloads).
|
|
Chain validation, signature checks, expiry, and hostname verification all
|
|
stay on — this is strictly narrower than ``verify=False``. Default is
|
|
strict (the flag stays set) to match Python's own default.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import ssl
|
|
from typing import Any, cast
|
|
|
|
logger = logging.getLogger("headroom.proxy")
|
|
|
|
_REPLACEMENT_CA_VARS = (
|
|
"SSL_CERT_FILE",
|
|
"REQUESTS_CA_BUNDLE",
|
|
)
|
|
|
|
# Env var that opts out of OpenSSL's RFC 5280 strict CA-constraint checks.
|
|
TLS_STRICT_ENV = "HEADROOM_TLS_STRICT"
|
|
|
|
# Values (case-insensitive) that mean "turn strict mode OFF".
|
|
_TLS_STRICT_OFF_VALUES = frozenset({"0", "false", "no", "off"})
|
|
|
|
|
|
def tls_strict_disabled() -> bool:
|
|
"""True when ``HEADROOM_TLS_STRICT`` opts out of OpenSSL strict mode.
|
|
|
|
Default (unset / any other value) is strict, matching Python 3.13's own
|
|
default. Only the explicit off-values flip it.
|
|
"""
|
|
return os.environ.get(TLS_STRICT_ENV, "").strip().lower() in _TLS_STRICT_OFF_VALUES
|
|
|
|
|
|
def _clear_x509_strict(ctx: ssl.SSLContext, *, reason: str) -> ssl.SSLContext:
|
|
"""Clear only ``VERIFY_X509_STRICT`` from a context, leaving all else on.
|
|
|
|
Keeps certificate verification, hostname verification, expiry checks, and
|
|
chain validation enabled — this is far narrower than disabling verify.
|
|
"""
|
|
strict_flag = getattr(ssl, "VERIFY_X509_STRICT", 0)
|
|
if strict_flag and ctx.verify_flags & strict_flag:
|
|
ctx.verify_flags &= ~strict_flag
|
|
logger.info("event=ssl_x509_strict_disabled reason=%s", reason)
|
|
return ctx
|
|
|
|
|
|
def _relax_x509_strict_for_custom_ca(ctx: ssl.SSLContext, *, path: str) -> ssl.SSLContext:
|
|
"""Relax OpenSSL strict-mode checks for an operator-provided CA bundle.
|
|
|
|
Python 3.13 / newer OpenSSL can reject some enterprise or private PKI
|
|
roots that platform TLS stacks accept, for example roots without a
|
|
keyUsage extension. Clearing only ``VERIFY_X509_STRICT`` keeps certificate
|
|
verification, hostname verification, expiry checks, and chain validation
|
|
enabled while making custom CA bundles usable in those environments.
|
|
|
|
A custom CA bundle is itself a strong signal of a corporate PKI, so the
|
|
strict flag is relaxed here regardless of ``HEADROOM_TLS_STRICT`` (the
|
|
historical behavior). The env toggle additionally covers the case where
|
|
the corporate root lives in the *default* trust store and no bundle var
|
|
is set — see :func:`build_httpx_verify`.
|
|
"""
|
|
return _clear_x509_strict(ctx, reason=f"custom_ca:{path}")
|
|
|
|
|
|
def _replacement_ca_context(path: str) -> ssl.SSLContext:
|
|
"""Build a replacement trust-store context from a CA bundle path."""
|
|
ctx = ssl.create_default_context(cafile=path)
|
|
ctx.set_alpn_protocols(["h2", "http/1.1"])
|
|
return _relax_x509_strict_for_custom_ca(ctx, path=path)
|
|
|
|
|
|
def _additive_ca_context(path: str) -> ssl.SSLContext:
|
|
"""Build an additive trust-store context from a CA bundle path."""
|
|
ctx = ssl.create_default_context()
|
|
ctx.load_verify_locations(cafile=path)
|
|
ctx.set_alpn_protocols(["h2", "http/1.1"])
|
|
return _relax_x509_strict_for_custom_ca(ctx, path=path)
|
|
|
|
|
|
def find_ca_bundle() -> ssl.SSLContext | None:
|
|
"""Return a CA verification target for httpx's ``verify=`` parameter.
|
|
|
|
``SSL_CERT_FILE`` and ``REQUESTS_CA_BUNDLE`` use **replacement**
|
|
semantics: the returned context trusts that bundle as its trust store.
|
|
|
|
``NODE_EXTRA_CA_CERTS`` uses **additive** semantics (matching Node.js):
|
|
the returned context contains the default/system roots *plus* the extra
|
|
certificate, so public upstreams stay reachable when the extra bundle
|
|
contains only a private/internal root.
|
|
|
|
Returns ``None`` when no env var is set (or all paths are missing),
|
|
which signals to the caller to use httpx's default TLS verification.
|
|
"""
|
|
for var in _REPLACEMENT_CA_VARS:
|
|
path = os.environ.get(var)
|
|
if path and os.path.isfile(path):
|
|
logger.info(
|
|
"event=ssl_ca_bundle_loaded env_var=%s path=%s",
|
|
var,
|
|
path,
|
|
)
|
|
return _replacement_ca_context(path)
|
|
if path and not os.path.isfile(path):
|
|
logger.warning(
|
|
"event=ssl_ca_bundle_missing env_var=%s path=%r (skipped)",
|
|
var,
|
|
path,
|
|
)
|
|
|
|
node_path = os.environ.get("NODE_EXTRA_CA_CERTS")
|
|
if node_path and os.path.isfile(node_path):
|
|
logger.info(
|
|
"event=ssl_ca_bundle_loaded env_var=NODE_EXTRA_CA_CERTS path=%s additive=true",
|
|
node_path,
|
|
)
|
|
return _additive_ca_context(node_path)
|
|
if node_path and not os.path.isfile(node_path):
|
|
logger.warning(
|
|
"event=ssl_ca_bundle_missing env_var=NODE_EXTRA_CA_CERTS path=%r (skipped)",
|
|
node_path,
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def _default_strict_relaxed_context() -> ssl.SSLContext:
|
|
"""Default trust store, but with ``VERIFY_X509_STRICT`` cleared.
|
|
|
|
Used when no custom CA bundle is configured (the corporate root lives in
|
|
the OS/default trust store) but ``HEADROOM_TLS_STRICT=0`` asks us to
|
|
tolerate a non-critical ``basicConstraints`` CA. Mirrors what httpx builds
|
|
for ``verify=True`` (default context + ALPN), minus the strict flag.
|
|
"""
|
|
ctx = ssl.create_default_context()
|
|
ctx.set_alpn_protocols(["h2", "http/1.1"])
|
|
return _clear_x509_strict(ctx, reason="env_toggle")
|
|
|
|
|
|
def build_httpx_verify() -> ssl.SSLContext | bool:
|
|
"""Return the value for httpx's ``verify=`` parameter.
|
|
|
|
Resolution order:
|
|
|
|
1. A custom CA bundle env var (``SSL_CERT_FILE`` / ``REQUESTS_CA_BUNDLE`` /
|
|
``NODE_EXTRA_CA_CERTS``) → a context trusting that bundle, with strict
|
|
mode already relaxed (corporate PKI signal).
|
|
2. No bundle, but ``HEADROOM_TLS_STRICT=0`` → the default trust store with
|
|
``VERIFY_X509_STRICT`` cleared, so a corporate root that's installed in
|
|
the OS store but trips RFC 5280 strict mode still validates.
|
|
3. Otherwise → ``True`` (httpx's default strict verification).
|
|
|
|
Returning ``True`` rather than a hand-built context in the common case
|
|
keeps httpx's own default behavior (including its certifi fallback) intact.
|
|
"""
|
|
ca_ctx = find_ca_bundle()
|
|
if ca_ctx is not None:
|
|
return ca_ctx
|
|
if tls_strict_disabled():
|
|
return _default_strict_relaxed_context()
|
|
return True
|
|
|
|
|
|
def apply_global_tls_relaxation() -> bool:
|
|
"""Strip ``VERIFY_X509_STRICT`` from urllib3's context builder when opted in.
|
|
|
|
The proxy's upstream httpx client is handled explicitly via
|
|
:func:`build_httpx_verify`, but model downloads go through
|
|
``huggingface_hub`` → ``requests`` → ``urllib3``, which builds its own
|
|
context via ``urllib3.util.ssl_.create_urllib3_context`` and sets
|
|
``VERIFY_X509_STRICT`` independently (urllib3 ≥ 2.5). That path never sees
|
|
our httpx context, so a corporate-MITM user hits the same
|
|
``Basic Constraints ... not marked critical`` rejection on a model cache
|
|
miss.
|
|
|
|
When ``HEADROOM_TLS_STRICT=0`` this monkeypatches
|
|
``create_urllib3_context`` to clear the strict flag from every context it
|
|
returns. The patch is idempotent (guarded by a sentinel attribute) and a
|
|
no-op when urllib3 isn't importable. Returns True if a patch was applied
|
|
(or was already in place), False otherwise.
|
|
|
|
Call this as early as possible — before ``huggingface_hub`` / ``requests``
|
|
import and cache their context — i.e. at CLI startup.
|
|
"""
|
|
if not tls_strict_disabled():
|
|
return False
|
|
|
|
strict_flag = getattr(ssl, "VERIFY_X509_STRICT", 0)
|
|
if not strict_flag:
|
|
return False
|
|
|
|
try:
|
|
import urllib3.util.ssl_ as _u3ssl
|
|
except Exception: # pragma: no cover - urllib3 always present in practice
|
|
logger.debug("event=ssl_urllib3_patch_skipped reason=import_failed")
|
|
return False
|
|
|
|
if getattr(_u3ssl.create_urllib3_context, "_headroom_strict_relaxed", False):
|
|
return True
|
|
|
|
_orig = _u3ssl.create_urllib3_context
|
|
|
|
def _relaxed_create_urllib3_context(*args: Any, **kwargs: Any) -> ssl.SSLContext:
|
|
# urllib3's create_urllib3_context signature varies across versions;
|
|
# forward verbatim and cast the (Any-typed) result back to SSLContext.
|
|
ctx = cast(ssl.SSLContext, _orig(*args, **kwargs))
|
|
if ctx.verify_flags & strict_flag:
|
|
ctx.verify_flags &= ~strict_flag
|
|
return ctx
|
|
|
|
_relaxed_create_urllib3_context._headroom_strict_relaxed = True # type: ignore[attr-defined]
|
|
_u3ssl.create_urllib3_context = _relaxed_create_urllib3_context # type: ignore[assignment]
|
|
logger.info("event=ssl_x509_strict_disabled reason=urllib3_global_patch")
|
|
return True
|