Component: src/coordination/leases/KubernetesLease.ts
Severity (assessment): LOW
CWE: CWE-807
isStillHeldByOther computes liveness as new Date(remoteRenewTime).getTime() + remoteDurationSeconds * 1000 > Date.now(). Both operands come from the Lease object written by whoever held it last; neither is bounded by the local, trusted options.ttlMs, and the timestamp is the holder's wall clock compared against the challenger's. A single write makes the lease permanently un-acquirable by every other node.
Exploit walkthrough
Any workload holding the RBAC the framework's own example prescribes (verbs: ["get","create","update","delete"] on coordination.k8s.io/leases, examples/coordination/k8s-lease-singleton.ts:129-132) — i.e. one compromised replica of the same Deployment — issues a single PUT setting spec.holderIdentity: 'attacker' and spec.leaseDurationSeconds: 2147483647 (or renewTime decades in the future; the API server validates the MicroTime format, not its plausibility) and then exits. Every other pod's acquire() now computes expiresAt ~68 years out, returns 'held-by-other', and acquireWithToken() returns null forever. ClusterSingletonManager never spawns the singleton, ShardCoordinator never starts, ReplicatedEventSourcedActor stays in observer mode and refuses to persist — a permanent, self-sustaining outage that survives every restart and needs a manual kubectl delete lease to clear. The benign variant of the same bug: a holder whose clock runs fast writes a future renewTime, delaying every failover by the skew; a holder whose clock runs slow lets peers steal a live lease early.
Evidence — src/coordination/leases/KubernetesLease.ts:184
src/coordination/leases/KubernetesLease.ts:179-188:
private isStillHeldByOther(lease: K8sLeaseObject): boolean {
const holder = lease.spec.holderIdentity;
if (!holder) return false; // unowned
if (holder === this.options.owner) return false; // we already hold it
const renewTime = lease.spec.renewTime;
const durationSec = lease.spec.leaseDurationSeconds ?? this.options.ttlMs / 1000;
if (!renewTime) return true; // owned but no time → assume live
const expiresAt = new Date(renewTime).getTime() + durationSec * 1000;
return expiresAt > Date.now();
}
Why the existing guard does not cover it
I grepped every use of the two fields: grep -rn 'leaseDurationSeconds' src/ yields only the three write sites (lines 141, 164, 240, all writing our own ttlSec) and this single read at line 184 — there is no Math.min(remote, this.options.ttlMs/1000) clamp and no sanity window on renewTime anywhere in the file or in k8sApi.ts. KubernetesLeaseOptionsValidator (KubernetesLeaseOptions.ts:71-80) only validates our own options, never the remote object. In tests/unit/coordination/KubernetesLease.test.ts the two contention tests seed leaseDurationSeconds: 30 / 5 — plausible values only; no test feeds a hostile duration or a future renewTime. The optimistic-concurrency (resourceVersion/409) machinery is real and works, but it only orders concurrent writers; it does not bound how long a written record claims to be valid.
Suggested fix
Clamp the remote duration to the locally-configured TTL — const durationSec = Math.min(lease.spec.leaseDurationSeconds ?? this.options.ttlMs / 1000, this.options.ttlMs / 1000) — and reject a renewTime more than one TTL in the future (treat it as expired, or as corrupt and refuse to act). Better still, follow client-go leaderelection: record observedAt = Date.now() locally each time metadata.resourceVersion changes and expire on observedAt + ttlMs < Date.now(), so liveness is timed by the challenger's own clock and never by a value the previous holder wrote.
Verification status
Found in the whole-framework security audit of 2026-08-01 (v0.12.0), then adjudicated by an independent verifier instructed to refute it.
Verifier note
The code is exactly as quoted. src/coordination/leases/KubernetesLease.ts:184 const durationSec = lease.spec.leaseDurationSeconds ?? this.options.ttlMs / 1000; and :186-187 const expiresAt = new Date(renewTime).getTime() + durationSec * 1000; return expiresAt > Date.now(); — both operands come from the remote object and neither is clamped. I confirmed the absence of a clamp: leaseDurationSeconds appears in src/ only at KubernetesLease.ts:141/164/240 (our own writes) and this one read, and k8sApi.ts:164-180 models the field as free-form number. KubernetesLeaseOptionsValidator (KubernetesLeaseOptions.ts:75-79) validates only local options.
Correction applied: Downgraded from high to low. The attacker must hold update on coordination.k8s.io/leases in the namespace — the same RBAC the framework's own participants need — and such an attacker can already deny the lease indefinitely by simply renewing it. The bug adds persistence-after-exit, not a new capability, and mutual exclusion (the actual safety property) is never broken; the impact is availability only. The stronger, non-adversarial part of the finding is the clock-skew / heterogeneous-ttlMs robustness case, which is a hardening gap rather than an exploit. The client-go observedAt remedy is the right fix, but this is defence-in-depth.
Component:
src/coordination/leases/KubernetesLease.tsSeverity (assessment): LOW
CWE: CWE-807
isStillHeldByOthercomputes liveness asnew Date(remoteRenewTime).getTime() + remoteDurationSeconds * 1000 > Date.now(). Both operands come from the Lease object written by whoever held it last; neither is bounded by the local, trustedoptions.ttlMs, and the timestamp is the holder's wall clock compared against the challenger's. A single write makes the lease permanently un-acquirable by every other node.Exploit walkthrough
Any workload holding the RBAC the framework's own example prescribes (
verbs: ["get","create","update","delete"]oncoordination.k8s.io/leases, examples/coordination/k8s-lease-singleton.ts:129-132) — i.e. one compromised replica of the same Deployment — issues a single PUT settingspec.holderIdentity: 'attacker'andspec.leaseDurationSeconds: 2147483647(orrenewTimedecades in the future; the API server validates the MicroTime format, not its plausibility) and then exits. Every other pod'sacquire()now computesexpiresAt~68 years out, returns 'held-by-other', andacquireWithToken()returns null forever.ClusterSingletonManagernever spawns the singleton,ShardCoordinatornever starts,ReplicatedEventSourcedActorstays in observer mode and refuses to persist — a permanent, self-sustaining outage that survives every restart and needs a manualkubectl delete leaseto clear. The benign variant of the same bug: a holder whose clock runs fast writes a futurerenewTime, delaying every failover by the skew; a holder whose clock runs slow lets peers steal a live lease early.Evidence —
src/coordination/leases/KubernetesLease.ts:184Why the existing guard does not cover it
I grepped every use of the two fields:
grep -rn 'leaseDurationSeconds' src/yields only the three write sites (lines 141, 164, 240, all writing our ownttlSec) and this single read at line 184 — there is noMath.min(remote, this.options.ttlMs/1000)clamp and no sanity window onrenewTimeanywhere in the file or ink8sApi.ts.KubernetesLeaseOptionsValidator(KubernetesLeaseOptions.ts:71-80) only validates our own options, never the remote object. In tests/unit/coordination/KubernetesLease.test.ts the two contention tests seedleaseDurationSeconds: 30/5— plausible values only; no test feeds a hostile duration or a futurerenewTime. The optimistic-concurrency (resourceVersion/409) machinery is real and works, but it only orders concurrent writers; it does not bound how long a written record claims to be valid.Suggested fix
Clamp the remote duration to the locally-configured TTL —
const durationSec = Math.min(lease.spec.leaseDurationSeconds ?? this.options.ttlMs / 1000, this.options.ttlMs / 1000)— and reject arenewTimemore than one TTL in the future (treat it as expired, or as corrupt and refuse to act). Better still, follow client-go leaderelection: recordobservedAt = Date.now()locally each timemetadata.resourceVersionchanges and expire onobservedAt + ttlMs < Date.now(), so liveness is timed by the challenger's own clock and never by a value the previous holder wrote.Verification status
Found in the whole-framework security audit of 2026-08-01 (
v0.12.0), then adjudicated by an independent verifier instructed to refute it.Verifier note
The code is exactly as quoted. src/coordination/leases/KubernetesLease.ts:184
const durationSec = lease.spec.leaseDurationSeconds ?? this.options.ttlMs / 1000;and :186-187const expiresAt = new Date(renewTime).getTime() + durationSec * 1000; return expiresAt > Date.now();— both operands come from the remote object and neither is clamped. I confirmed the absence of a clamp:leaseDurationSecondsappears in src/ only at KubernetesLease.ts:141/164/240 (our own writes) and this one read, and k8sApi.ts:164-180 models the field as free-formnumber. KubernetesLeaseOptionsValidator (KubernetesLeaseOptions.ts:75-79) validates only local options.Correction applied: Downgraded from high to low. The attacker must hold
updateon coordination.k8s.io/leases in the namespace — the same RBAC the framework's own participants need — and such an attacker can already deny the lease indefinitely by simply renewing it. The bug adds persistence-after-exit, not a new capability, and mutual exclusion (the actual safety property) is never broken; the impact is availability only. The stronger, non-adversarial part of the finding is the clock-skew / heterogeneous-ttlMs robustness case, which is a hardening gap rather than an exploit. The client-goobservedAtremedy is the right fix, but this is defence-in-depth.