Skip to content

Kestra KV store namespace access check uses startsWith without a dot separator: in Enterprise editions where an admin restricted Allowed Namespaces to com.a, a user may still read/write KV entries in com.aa or com.ab (parent-prefix collision)

Moderate
fhussonnois published GHSA-hxgr-m653-65pw Sep 4, 2026

Software

kestra-io/kestra-ee

Affected versions

>= 0.18.0
>= 0.18.0
>= 0.18.0

Patched versions

2.0.0-rc13
1.3.36
1.0.58

Description

Credit: Wenhao Wu, Southeast University

Allowed Namespaces bypass for KV via startsWith parent check (no '.' delimiter)

  • Product: Kestra OSS tree (2.0.0-SNAPSHOT), helper consumed by Enterprise Allowed Namespaces
  • Component: KVStoreService.checkAccessNamespaceIsAllowed / isNotParentNamespace
  • Type: Authorization skip / namespace-boundary confusion (ARPC)
  • Classification: GENUINE_ARPC
  • Label: logic_confirmed_real_components
  • Severity (suggested): Medium–High in EE (cross-namespace KV read + write). Not reachable as an ACL bypass in OSS (OSS has no namespace ACL).

Summary

Kestra documents two distinct KV access modes:

  1. Inheritance (OSS+EE): {{ kv('KEY') }} with no namespace argument walks dot-delimited parents (company.team → company).
  2. Explicit cross-namespace (EE Allowed Namespaces): {{ kv('KEY', namespace='other.ns') }} and KV tasks targeting another namespace must be allowed by the target namespace's allow-list.

The explicit path is implemented by KVStoreService.get(tenant, target, caller). Before calling NamespaceService.checkAllowedNamespace, it skips the check when it believes caller is a child of target:

return !childNamespace.startsWith(parentNamespace);

There is no '.' boundary. A caller in prod2 is treated as a child of prod; company.team2 is treated as a child of company.team. The skip fires, Allowed Namespaces is never consulted, and the KV store of the prefix-sibling is opened.

The same helper is the only gate on io.kestra.plugin.core.kv.Set (no acl().allowNamespace() call). Prefix-sibling writes are therefore in scope, not only reads.

Guarded siblings in the same tree:

  • io.kestra.plugin.core.kv.Get / Delete / GetKeys call runContext.acl().allowNamespace(ns).check() first (no parent shortcut).
  • secret('KEY', namespace=...) always calls NamespaceService.checkAllowedNamespace.
  • JDBC / NamespaceInterface.asTree / FlowWithDefaultCache already use namespace + ".".

product_claim_ref

  • KV Store: cross-namespace KV requires the caller to be listed in the target's Allowed Namespaces (since 0.20).
  • Namespace management (EE): Allowed Namespaces is the namespace-to-namespace gate for KV, secrets, files, subflows.
  • Naming conventions: hierarchy is dot-delimited (company → company.team → company.team.project).
  • Code: io.kestra.plugin.core.kv.Get documents "Respects namespace ACLs when reading other namespaces."
  • Code: KvFunction comment: "we didn't check allowedNamespace here as it's checked in the kvStoreService itself."

threat_actor

An EE principal who can create or run a flow in namespace N2 where N2 is a string prefix sibling of victim namespace N (e.g. prod2 vs prod, company.team2 vs company.team), but who is not on N's Allowed Namespaces list and has no KVSTORE binding on N.

Not in scope: OSS single-credential deployments (namespaces are not an authorization boundary there).

expected_source

Policy (product docs + EE Allowed Namespaces), not a harness assumption.

Root cause

src/core/src/main/java/io/kestra/core/services/KVStoreService.java

if (isNotSameNamespace && isNotParentNamespace(namespace, fromNamespace)) {
    namespaceService.checkAllowedNamespace(tenant, namespace, tenant, fromNamespace);
}
// ...
private static boolean isNotParentNamespace(final String parentNamespace, final String childNamespace) {
    return !childNamespace.startsWith(parentNamespace);
}

Callers:

Path File Extra ACL? Effect of skip
{{ kv('KEY', namespace=target) }} KvFunction.java no (delegates to this helper) read victim KV
RunContext.namespaceKv(ns) DefaultRunContext.java no any KV API
io.kestra.plugin.core.kv.Set Set.java no acl() call write victim KV
Get / Delete / GetKeys plugin kv yes acl().allowNamespace not bypassed by this helper

Correct pattern already in-tree (AbstractJdbcFlowRepository.java):

NAMESPACE_FIELD.eq(namespacePrefix).or(NAMESPACE_FIELD.startsWith(namespacePrefix + "."))

Repro (predicate oracle — this snapshot)

Environment: this OSS tree. Java 25 is required to compile Kestra; the audit host had Java 8, so the authorization skip is demonstrated by a byte-faithful reimplementation of the helper, not by a live EE HTTP 403. That is logic_confirmed_real_components, not full_deployment_confirmed.

Step 1 — Run the oracle

Action: python3 repro/kv_namespace_prefix_oracle.py

Observed response: exit 0, defect_count: 3, negative control unrelated-sibling has buggy_skip_acl: false.

Evidence: repro/evidence/kv_namespace_prefix.json

Excerpt:

caller → target intended buggy skip ACL correct skip ACL
company.team → company skip (real child) true true
other → prod check ACL (negctl) false false
prod2 → prod check ACL true (defect) false
company.team2 → company.team check ACL true (defect) false
companyx → company check ACL true (defect) false

Step 2 — Negative control (authorization, not authentication)

Action: evaluate skip for caller other, target prod (unrelated; not a prefix).

Observed: buggy_skip_acl=false → checkAllowedNamespace is invoked. In EE, a principal bound only to other is denied. This is the legitimate-other-token control. It is not "unauthenticated = 401".

Evidence: repro/evidence/kv_namespace_prefix.json → negative_control.

Step 3 — Attack case (same helper, prefix sibling)

Action: evaluate skip for caller prod2, target prod.

Observed: buggy_skip_acl=true → checkAllowedNamespace is not invoked. EE allow-list on prod is never consulted.

Evidence: repro/evidence/kv_namespace_prefix.json → attack_case.

Step 4 — EE live sequence (not executed here)

Requires Enterprise with Allowed Namespaces on prod = {prod} only, two identities (or two namespace bindings): prod admin vs prod2 developer.

  1. As prod admin: PUT /api/v1/{tenant}/namespaces/prod/kv/db_password = supersecret.
  2. As prod2 developer: save+run
id: steal_kv
namespace: prod2
tasks:
  - id: read
    type: io.kestra.plugin.core.log.Log
    message: "{{ kv('db_password', namespace='prod') }}"
  - id: write
    type: io.kestra.plugin.core.kv.Set
    namespace: prod
    key: planted
    value: pwned
  1. Expected (policy): both tasks fail (Allowed Namespaces / acl()).
  2. Predicted (this code): kv() logs supersecret; Set writes planted=pwned into prod.
  3. Repeat with namespace: other as negative control → deny.

Do not treat step 4 as verified against this snapshot.

Dynamic verification (2026-08-23)

Action: python3 dynamic/scripts/verify.py (predicate + stub EE NamespaceService + stub routing layer) and javac/java of dynamic/deploy/EeKvPrefixHarness.java on host OpenJDK 8.

Observed: Python defect_count: 5, oracle_fired: true, Java harness exit 0 / NEGCTL_PASS=true. Prefix-sibling com.aa → com.a OPENED on pebble kv() and kv.Set/kv.Put; unrelated com.b → com.a DENIED; kv.Get (acl first) DENIED even with the buggy skip. REST get(tenant, ns) does not consult the parent check.

Evidence: dynamic/kestra_dynamic_2026_08_23.json, dynamic/evidence/harness.json, dynamic/kestra-dynamic-note-2026-08-23.md.

OSS kestra/kestra:latest (~3 GiB, Java 25) was attempted on 127.0.0.1:18080. The pull stalled (26+ min, 2821 MB layer never left Waiting under 12–18 concurrent docker pulls on the host). Fallback: this harness. OSS has a single Basic Auth identity; a second user/role cannot be created. Allowed Namespaces is EE. Live HTTP 403 on Enterprise was still not collected. Label remains logic_confirmed_real_components. Evidence: dynamic/evidence/deploy_attempt.json.

Impact

  • EE: read and overwrite KV in a namespace the caller is not allowed to touch, whenever the caller's namespace is a string prefix of the victim's (prod2/prod, company.team2/company.team). KV commonly holds offsets, tokens, and shared state.
  • OSS: no extra impact (single Basic Auth identity; isAllowedNamespace is documented always-true).

Suggested fix

Replace the helper with the delimiter already used in JDBC and NamespaceInterface.asTree:

private static boolean isDescendantOrSelf(String parent, String child) {
    return child.equals(parent) || child.startsWith(parent + ".");
}

Skip Allowed Namespaces only for real descendants (and only if product intent is that inheritance also covers explicit namespace=). Align kv.Set with Get/Delete/GetKeys by calling acl().allowNamespace before namespaceKv. Add tests for prod2 vs prod and company.team2 vs company.team.

Limitations

  • No EE binary in this audit tree; live 403/200 on Enterprise was not collected.
  • OSS runtime cannot demonstrate a deny (by design). Two flow namespaces (com.aa vs com.b) were used as principals because OSS Basic Auth is a singleton.
  • Predicate oracle / Java harness reimplement a private method; they match the source at the audit snapshot (return !childNamespace.startsWith(parentNamespace);).
  • KV REST is not the vulnerable path (fromNamespace == namespace). Cross-prefix REST success on OSS is single-credential by-design.

An independent adversarial reviewer (separate session, not involved in the finding or verification) re-derived the chain from raw evidence and sustained this finding.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N

CVE ID

No known CVE

Weaknesses

Improper Access Control

The product does not restrict or incorrectly restricts access to a resource from an unauthorized actor. Learn more on MITRE.

Incorrect Authorization

The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check. Learn more on MITRE.

Credits