The Default Only Fires When the Variable Is Missing: OpenTelemetry Python CVE-2026-112006 and Four Years of Optional TLS

At 12:17:12.927Z on 18 October 2026, in the same VulnCheck publication batch that produced LobsterAI and Omnigent, NVD received CVE-2026-112006: an improper certificate validation flaw (CWE-295) in opentelemetry-exporter-otlp-proto-http, the Python package that ships telemetry out of an application over OTLP/HTTP. VulnCheck rates it high, CVSS 4.0 8.3; NVD also carries a CVSS 3.1 vector of 6.5. The affected range is 1.5.0 before 1.45.0. The credited finder is Fawwaz Musaffa.

Three things make this worth a briefing rather than a line in a feed. The defect is a two-character difference between two ways of writing a default. It has been shippable since 26 August 2021. And of the fifty-six releases in the affected range, the vulnerability databases a dependency scanner actually queries know about sixteen.

An empty string is a value

Read at tag v1.44.0, the span exporter resolves its CA bundle like this:

self._certificate_file = certificate_file or environ.get(
    OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE,
    environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True),
)

The intent is clear and the default is correct: absent any configuration, self._certificate_file becomes True, which requests reads as “verify against the system trust store.” The problem is the mechanism. dict.get(key, default) returns the default only when the key is absent. An environment variable set to the empty string is not absent — it is present with the value "". So OTEL_EXPORTER_OTLP_CERTIFICATE="" does not fall through to True; it wins, and self._certificate_file becomes the empty string.

That value is handed straight to the transport, in the same file:

resp = self._session.post(
    url=self._endpoint,
    data=data,
    verify=self._certificate_file,
    timeout=timeout_sec,
    cert=self._client_cert,
)

And requests treats verify as a truth value before it treats it as a path. From requests/adapters.py at v2.32.3:

if url.lower().startswith("https") and verify:
    conn.cert_reqs = "CERT_REQUIRED"
    ...
else:
    conn.cert_reqs = "CERT_NONE"
    conn.ca_certs = None
    conn.ca_cert_dir = None

An empty string is falsy, so the else branch runs and the connection is made with CERT_NONE. Note what does not happen: there is no exception. A malformed path raises OSError with the message “Could not find a suitable TLS CA certificate bundle” — but only inside the truthy branch, which an empty string never reaches. The one input that turns verification off is also the one input that produces no error, no warning and no log line. The exporter keeps working, the dashboards keep filling, and the TLS handshake accepts anything.

What rides on that connection

Two payloads, and the second is the one that matters.

The first is the telemetry itself. Whoever answers the TCP connection reads every span, metric and log record the process exports, and — because nothing authenticates the far end — can alter them on the way through. For this audience the content is not abstract: the same distribution ships opentelemetry-semantic-conventions, whose incubating GenAI attribute module defines sixty constants including gen_ai.prompt, gen_ai.completion, gen_ai.input.messages and gen_ai.output.messages. Span attributes designed to carry model inputs and outputs are a documented, first-party use of this transport. If your LLM traces capture message content, that content is in the body.

The second is the credential. The exporter builds its session headers from OTEL_EXPORTER_OTLP_HEADERS, parsed at construction and applied with self._session.headers.update(self._headers). That variable is the conventional home of a telemetry backend’s API key or bearer token, and the CVE description names it explicitly. A man-in-the-middle does not merely observe one batch of spans; it collects a long-lived ingestion credential that keeps working after the connection ends. The CVSS 4.0 vector encodes this asymmetry honestly — VC:H/VI:L, confidentiality high, integrity low — and AT:P records that the attacker needs the empty variable and a network position, neither of which they control.

Three exporters, one citation

The CVE’s technical references point at two line ranges in the trace exporter. The pattern is not confined to it. Read at the same tag, the log exporter carries it at line 98 and the metric exporter at line 156, each substituting its own signal-specific variable into the identical shape:

# _log_exporter/__init__.py:98
self._certificate_file = certificate_file or environ.get(
    OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE,
    environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True),
)

So the shared variable OTEL_EXPORTER_OTLP_CERTIFICATE, set to an empty string, disarms all three signals at once, and each signal’s own variable disarms one. That is six strings, not one, and the advisory cites the lines for one of them. This is a recurring shape in our coverage — the identifier describes the instance the reporter happened to open, and the class is established by whoever reads the siblings.

The fix is real, and nobody called it one

The remediation is in v1.45.0, released 25 September 2026. The new code lives in a shared _common module:

verify: bool | str = (
    certificate_file
    or os.environ.get(certificate_env_var)
    or os.environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE)
    or True
)

The whole correction is the move from get(key, default) to an or-chain. get defaults on absence; or defaults on falsiness. An empty string is absent-shaped to or and present-shaped to get, and the exporter needed the first reading. It is also now written once rather than three times, which is the structural half of the fix and the half that keeps it fixed.

The provenance is the interesting part. The commit the CVE cites, 89cc68b9 by Lukas Hering, landed on 12 August 2026 and is titled “opentelemetry-exporter-otlp-proto-http: refactor to use shared opentelemetry-exporter-otlp-common and opentelemetry-exporter-http-transport packages (#5389).” It touches 23 files for +2,925/−2,279. It is a packaging refactor, and the TLS correction is collateral — the nearest thing to an acknowledgement anywhere in it is a bullet in the commit body reading “remove ability to pass cert file as bool.”

Downstream, the labelling holds. We read the v1.45.0 changelog section in full: it has three subsections, Added, Changed and Fixed, and no security heading. PR #5389 appears once, under Changed, as:

- `opentelemetry-exporter-otlp-proto-http`: refactor to use shared
  opentelemetry-exporter-otlp-common and opentelemetry-exporter-http-transport
  packages and switch default HTTP backend to urllib3

No string matching cert, TLS, verif, security or CVE appears anywhere in that section. There is no GitHub Security Advisory for this package: querying the global advisory database for opentelemetry-exporter-otlp-proto-http in the pip ecosystem returns zero records. An operator reading the release notes on 25 September had no way to learn that an upgrade they were treating as a refactor was closing a certificate-validation hole, and an operator who deferred the upgrade had nothing telling them to reconsider.

The scanner gap, measured

This is the part we did not expect, and it is worse than the usual version of this finding.

In our recent coverage — the 122-CVE VulnCheck batch, Arcane, Coder — the excuse for an empty scanner result was always structural: the affected thing was an application deployed as a container, not a package consumed from a registry, so there was no coordinate to record. That excuse does not apply here. opentelemetry-exporter-otlp-proto-http is a PyPI package with fifty-eight releases. It appears by name in lockfiles, in requirements.txt, in SBOMs. The coordinate is perfect. It is still not recorded.

Measured directly, one request each:

  • GET api.osv.dev/v1/vulns/CVE-2026-112006 → 200. The record exists, auto-generated from the CVE List.
  • POST api.osv.dev/v1/query for opentelemetry-exporter-otlp-proto-http on PyPI at version 1.44.0 → {}. Zero results, against the last vulnerable release.
  • GET api.github.com/advisories?affects=opentelemetry-exporter-otlp-proto-http&ecosystem=pip → 0.

The reason is visible in the OSV record. Its single affected entry has no package name and no ecosystem — only a GIT range naming the repository, introduced at 3b190f5a (the 1.5.0 version bump, 26 August 2021) and fixed at 4f0fcfa5 (“Prepare release 1.45.0/0.66b0”, 25 September 2026). Alongside it sits an enumerated versions list, and that list is the sharpest artefact in this briefing. It contains sixteen entries, running from v1.5.0 up to v1.13.0 and stopping there. Counted against PyPI, there are fifty-six releases in the declared affected range. Forty are missing — every release from 1.14.0 (3 November 2022) through 1.44.0 (16 July 2026).

So the failure is not that the data is absent. It is that the data is present, wrong, and wrong in the direction of silence. A tool that matches on the enumerated list will flag a four-year-old pin and clear a pin from last quarter. The record says 1.5.0 through 1.44.0 in prose and says 1.5.0 through 1.13.0 in the field a machine reads, and the second is the one that runs.

What to do

Upgrade to 1.45.0 or later; current is 1.45.1. Note that 1.45.0 also switches the default HTTP backend to urllib3, so treat it as a behavioural upgrade and not only a patch.

Then do the thing the version number cannot do for you: grep your deployment surface for the variable, not the version. Any OTEL_EXPORTER_OTLP_CERTIFICATE, OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE, OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE or OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE whose value is the empty string is the exact trigger, and empty strings are exactly what Helm templates, Compose files and CI matrices produce when an optional value is left unset but the key is kept. A declared-but-empty key is the normal output of templating, which is why this precondition is more common than it sounds.

Rotate the token in OTEL_EXPORTER_OTLP_HEADERS if any affected process ran with an empty certificate variable on a network you do not own. Evidence of exposure lives in your telemetry backend’s auth logs, not in your application’s.

And generalise the class rather than the CVE. In any configuration loader, os.environ.get(KEY, SAFE_DEFAULT) is a security decision disguised as a convenience, and it is the wrong one wherever an empty value is a plausible mistake and the safe default is the strict option. Audit for the shape: a security parameter whose default is supplied through a mechanism that only fires on absence, consumed downstream by a library that evaluates truthiness. The two halves are individually reasonable. The composition opts out.

CVE-2026-112006 is not in the CISA KEV catalogue (checked 19 October 2026 against catalogue version 2026.10.16, 1,748 entries, which contains no OpenTelemetry records of any kind), and no exploitation has been reported. For a passive, network-position-dependent credential disclosure with no log signal on either side, that absence carries less information than usual.

Our verification was read-only. CVE-2026-112006’s identifier, publication timestamp (2026-10-18T12:17:12.927Z), vulnStatus (Received), source (VulnCheck), description, CVSS 4.0 and 3.1 vectors, CWE-295 assignment and references were read first-hand from the NVD 2.0 API; its severity (high) and finder credit (Fawwaz Musaffa) from the VulnCheck advisory page. The vulnerable construct was read first-hand in the trace, log and metric exporters at tag v1.44.0 and every quoted line is verbatim from those files; the corrected construct from the shared _common module at tag v1.45.0. The cert_verify logic is quoted verbatim from requests v2.32.3. Commit 89cc68b9’s author, date, title, file list and line counts, and commits 3b190f5a and 4f0fcfa5, were read first-hand from the GitHub commits API. The v1.45.0 changelog section was read in full and searched directly for security terms. Release dates and the fifty-six-release count in the affected range were computed from the PyPI JSON API. The GenAI attribute constants were read first-hand from opentelemetry-semantic-conventions at v1.45.0. All OSV lookups, the OSV version enumeration, the PyPI version query and the GitHub global advisory query were measured directly, one request each. KEV status was checked directly against the published catalogue. Brave Search was used for discovery only and returned no independent technical analysis — solely automated mirrors (Threat Radar, strix.ai, threatint, OpenCVE). We installed nothing, configured no exporter, started no collector, intercepted no connection and executed no proof of concept.

Sources: