Skip to content

[Security] KubernetesLease decides expiry from the previous holder's own renewTime and uncapped leaseDurationSeconds, so one write pins the lease forever #598

Description

@pathosDev

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: lowNice-to-have / niche / demand-drivenproduction-goalBlocks or defines the path to production readinesssecuritySecurity-relevant — see severity label for impact tierseverity: lowMinor / informational / mitigated-by-design

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions