Repository navigation
fix: the spool is bounded, and its bound no longer depends on the cluster being healthy (ADR 0042) - #74
Merged
Conversation
…ster being healthy (ADR 0042)
The last audit finding whose victim is the customer's cluster rather than this
product. Three things were wrong with the spool's bound.
The age cap was conditional on the cluster being healthy. sweep was private and
called from one place — the end of WriteUsageSnapshot — because it rode the
snapshot cadence "so no extra timer exists". But WriteUsageSnapshot runs only
when there are usage records, and there are none when the kubelet cannot be
polled: nodes/proxy withheld, every kubelet unreachable, the poll loop stuck. On
exactly those clusters nothing was ever swept and the 24h cap never applied,
while the agent looked healthy. The second timer that comment was avoiding did
not need creating — the periodic flush loop was already there.
Age bounds nothing an adversary controls. WriteOOMKill writes one file per
(finishedAt, namespace, pod, container, restartCount) — the restart count is in
the name, so every restart is a new file, not an overwrite. Nothing acknowledges
payloads yet, so nothing removes them before 24h, and the emptyDir had no
sizeLimit. A pod OOM-killing in a loop fills the node's ephemeral storage, and a
full node makes the kubelet evict pods — other workloads' pods, on a node this
agent is a guest of. The restart journal was deliberately windowed against
exactly this ("a crash loop must not put the spool's file count under its own
control"); the OOM path beside it never was.
A caller-supplied string reached a filename. WriteProfile built its name from
key.Workload, which is ownerReferences[].name — a field the API server validates
for non-emptiness and nothing else. filepath.Join resolves "..".
- Sweep is exported and called unconditionally on the agent's own cadence.
- 512 MiB and 20000 files, oldest-first after the age cutoff. Both, because the
smallest payload is a few hundred bytes and a byte budget alone permits
millions of files.
- The emptyDir declares sizeLimit: 1Gi, deliberately above the agent's own
budget so the agent's bound acts and the kubelet's is the backstop. Asserted
as a relation to the constant so the two cannot drift into the wrong order.
- Every caller-supplied filename component goes through fileToken, not just the
one known to be attacker-controlled: "safe because that field happens to be
DNS-1123" is the reasoning that made this a finding.
- write refuses a name that is not a plain filename. It catches nothing today;
it is the choke point every payload passes through.
Checked in both directions. With the sanitizer removed the traversal tests fail
on five of eight inputs, including one that landed at
"profile-acme-..\..\windows-server-deadbeef-100-160.json". Worth stating
precisely: the write creates no intermediate directories, so it lands only where
the whole parent path already exists and is writable — in the shipped chart
readOnlyRootFilesystem makes most targets fail with EROFS, which is an accident
of an unrelated setting rather than anything this package did.
policy-e2e PASS 63.74s against the rebuilt image: the controller still writes
every payload with the sweep now running each tick.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The last audit finding whose victim is the customer's cluster rather than this product. Three things were wrong with the spool's bound.
1. The age cap was conditional on the cluster being healthy
sweepwas private and called from exactly one place — the end ofWriteUsageSnapshot— because it rode the snapshot cadence "so no extra timer exists".But
WriteUsageSnapshotruns only when there are usage records, and there are none when the kubelet cannot be polled:nodes/proxywithheld, every kubelet unreachable, the poll loop stuck. On exactly those clusters nothing was ever swept, the 24h cap never applied, and the spool grew without limit while the agent looked healthy.A bound that stops working on an unhealthy cluster is not a bound. And the second timer that comment was avoiding did not need creating — the periodic flush loop was already there.
2. Age bounds nothing an adversary controls
WriteOOMKillwrites one file per(finishedAt, namespace, pod, container, restartCount). The restart count is in the name, so every restart is a new file rather than an overwrite. Nothing acknowledges payloads yet, so nothing removes them before the 24h mark, and the controller'semptyDirhad nosizeLimit.A pod OOM-killing in a tight loop therefore consumes the node's ephemeral storage — and a node that runs out makes the kubelet start evicting pods. Other workloads' pods, on a node this agent is a guest of.
The irony is local: the restart journal was deliberately windowed with the reasoning that "a crash loop must not put the spool's file count under its own control". That reasoning was never applied to the OOM path beside it.
3. A caller-supplied string reached a filename
WriteProfilebuilt its name fromkey.Workload, which isownerReferences[].name— a field the API server validates for non-emptiness and nothing else: no DNS-1123, no character set.filepath.Joinresolves...Checked in both directions. With the sanitizer removed, the traversal tests fail on five of eight inputs:
Worth stating precisely rather than dramatically: the write creates no intermediate directories, so it lands only where the whole parent path already exists and is writable. In the shipped chart
readOnlyRootFilesystem: truemakes most targets fail with EROFS — a real mitigation, and an accident of an unrelated setting rather than anything this package did.What changes
Sweepis exported and called unconditionally from the agent's own periodic loop.emptyDirand no setting changes that (ADR 0026).emptyDir: {sizeLimit: 1Gi}, deliberately above the agent's own 512 MiB budget. The ordering is the point — the agent's bound is the one meant to act, the kubelet's is what holds if it ever fails. The chart test asserts it as a relation to the constant, not as a number, so the two cannot drift into the wrong order.fileToken, not just the one known to be attacker-controlled. Which Kubernetes fields are validated is not this package's business to track, and "safe because that field happens to be DNS-1123" is the reasoning that made this a finding.writerefuses a name that is not a plain filename. It catches nothing today; it is the choke point every payload passes through, and the next caller will not read the ADR.Named, not solved
A crash-looping pod can still push useful payloads out of the spool: it produces OOM payloads faster than anything else is written, and oldest-first eviction drops older usage windows first. The data is loss-harmless, and the alternative — ranking payload kinds against each other during eviction — is a policy with no obvious right answer and no honest way to test it. Windowing OOM events the way restarts are windowed would remove the pressure at its source and changes a payload's shape, so it belongs in its own record.
Verification
go test -race ./...,make lint(0 issues),make chart-lint,go vet -tags e2e ./...clean.make policy-e2ePASS 63.74s against the rebuilt image — the controller still writes every payload with the sweep now running each tick.docs/adr/0042-the-spool-is-bounded.mdamends 0007 §4 ("Retention is time-bounded, not size-unbounded"), with the mirror header.docs/security.md§6 Storage now states the three bounds in order, and says plainly that the earlier version of this did not work.🤖 Generated with Claude Code