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:
- Inheritance (OSS+EE):
{{ kv('KEY') }} with no namespace argument walks dot-delimited parents (company.team → company).
- 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.
- As
prod admin: PUT /api/v1/{tenant}/namespaces/prod/kv/db_password = supersecret.
- 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
- Expected (policy): both tasks fail (Allowed Namespaces /
acl()).
- Predicted (this code):
kv() logs supersecret; Set writes planted=pwned into prod.
- 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.
Allowed Namespaces bypass for KV via
startsWithparent check (no '.' delimiter)2.0.0-SNAPSHOT), helper consumed by Enterprise Allowed NamespacesKVStoreService.checkAccessNamespaceIsAllowed/isNotParentNamespaceGENUINE_ARPClogic_confirmed_real_componentsSummary
Kestra documents two distinct KV access modes:
{{ kv('KEY') }}with no namespace argument walks dot-delimited parents (company.team→company).{{ 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 callingNamespaceService.checkAllowedNamespace, it skips the check when it believescalleris a child oftarget:There is no
'.'boundary. A caller inprod2is treated as a child ofprod;company.team2is treated as a child ofcompany.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(noacl().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/GetKeyscallrunContext.acl().allowNamespace(ns).check()first (no parent shortcut).secret('KEY', namespace=...)always callsNamespaceService.checkAllowedNamespace.NamespaceInterface.asTree/FlowWithDefaultCachealready usenamespace + ".".product_claim_ref
company→company.team→company.team.project).io.kestra.plugin.core.kv.Getdocuments "Respects namespace ACLs when reading other namespaces."KvFunctioncomment: "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
N2whereN2is a string prefix sibling of victim namespaceN(e.g.prod2vsprod,company.team2vscompany.team), but who is not onN's Allowed Namespaces list and has no KVSTORE binding onN.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.javaCallers:
{{ kv('KEY', namespace=target) }}KvFunction.javaRunContext.namespaceKv(ns)DefaultRunContext.javaio.kestra.plugin.core.kv.SetSet.javaacl()callGet/Delete/GetKeyskvacl().allowNamespaceCorrect pattern already in-tree (
AbstractJdbcFlowRepository.java):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, notfull_deployment_confirmed.Step 1 — Run the oracle
Action:
python3 repro/kv_namespace_prefix_oracle.pyObserved response: exit 0,
defect_count: 3, negative controlunrelated-siblinghasbuggy_skip_acl: false.Evidence:
repro/evidence/kv_namespace_prefix.jsonExcerpt:
company.team→companyother→prodprod2→prodcompany.team2→company.teamcompanyx→companyStep 2 — Negative control (authorization, not authentication)
Action: evaluate skip for caller
other, targetprod(unrelated; not a prefix).Observed:
buggy_skip_acl=false→checkAllowedNamespaceis invoked. In EE, a principal bound only tootheris 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, targetprod.Observed:
buggy_skip_acl=true→checkAllowedNamespaceis not invoked. EE allow-list onprodis 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):prodadmin vsprod2developer.prodadmin:PUT /api/v1/{tenant}/namespaces/prod/kv/db_password=supersecret.prod2developer: save+runacl()).kv()logssupersecret;Setwritesplanted=pwnedintoprod.namespace: otheras 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 EENamespaceService+ stub routing layer) andjavac/javaofdynamic/deploy/EeKvPrefixHarness.javaon host OpenJDK 8.Observed: Python
defect_count: 5,oracle_fired: true, Java harness exit 0 /NEGCTL_PASS=true. Prefix-siblingcom.aa → com.aOPENED on pebblekv()andkv.Set/kv.Put; unrelatedcom.b → com.aDENIED;kv.Get(acl first) DENIED even with the buggy skip. RESTget(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 on127.0.0.1:18080. The pull stalled (26+ min, 2821 MB layer never leftWaitingunder 12–18 concurrentdocker 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 remainslogic_confirmed_real_components. Evidence:dynamic/evidence/deploy_attempt.json.Impact
prod2/prod,company.team2/company.team). KV commonly holds offsets, tokens, and shared state.isAllowedNamespaceis documented always-true).Suggested fix
Replace the helper with the delimiter already used in JDBC and
NamespaceInterface.asTree:Skip Allowed Namespaces only for real descendants (and only if product intent is that inheritance also covers explicit
namespace=). Alignkv.SetwithGet/Delete/GetKeysby callingacl().allowNamespacebeforenamespaceKv. Add tests forprod2vsprodandcompany.team2vscompany.team.Limitations
com.aavscom.b) were used as principals because OSS Basic Auth is a singleton.return !childNamespace.startsWith(parentNamespace);).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.