Skip to content

Commit 9bf7fb0

Browse files
committed
Minor fixes to address some vulnerabilities I found
Signed-off-by: Erick Bourgeois <erick@jeb.ca>
1 parent 33cfc8e commit 9bf7fb0

10 files changed

Lines changed: 654 additions & 24 deletions

File tree

.claude/CHANGELOG.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,77 @@ The format is based on the regulated environment requirements:
99

1010
---
1111

12+
## [2026-04-24 15:00] - Security audit remediation: grace period, retry-count poisoning, input bounds
13+
14+
**Author:** Erick Bourgeois
15+
16+
### Changed
17+
- `src/reconcilers/helpers.rs` (`check_grace_period_elapsed`): guard
18+
against negative `elapsed` durations produced by clock-skew (NTP step,
19+
VM freeze-thaw, backward wall-clock adjustment). The prior comparison
20+
`elapsed.num_seconds() >= timeout.as_secs() as i64` silently returned
21+
`false` when `elapsed` was negative, potentially bypassing the
22+
graceful-drain window entirely. Negative elapsed is now treated as
23+
"timeout reached" so the reconciler forces progress rather than
24+
stalling on a misbehaving clock.
25+
- `src/reconcilers/helpers.rs` (`error_policy`): replace
26+
`ctx.retry_counts.lock().unwrap_or_else(PoisonError::into_inner)`
27+
with an explicit `match` that aborts the error-policy pass and
28+
requeues after `ERROR_REQUEUE_SECS` on poison. The prior pattern
29+
silently recovered a potentially-inconsistent map and continued
30+
mutating it, corrupting the exponential-backoff schedule for every
31+
subsequent error.
32+
- `src/reconcilers/helpers.rs`: new `validate_cluster_name()` and
33+
`validate_kill_if_commands()` with bounds from `src/constants.rs`.
34+
Called early in `reconcile_inner` (defence-in-depth against clusters
35+
that have not enabled the ValidatingAdmissionPolicy) and re-called in
36+
`add_machine_to_cluster` before touching CAPI.
37+
- `src/constants.rs`: new `MAX_CLUSTER_NAME_LEN = 63` (RFC-1123 DNS
38+
label — the effective CAPI cluster-name cap), plus
39+
`MAX_KILL_IF_COMMANDS_COUNT = 100` and `MAX_KILL_IF_COMMAND_LEN = 256`.
40+
- `src/crd.rs`: attach bounded JSON-schema generators
41+
(`cluster_name_schema`, `kill_if_commands_schema`) so the generated
42+
CRD enforces the same limits at the kube API level.
43+
- `src/metrics.rs`: replace `unreachable!()` in the metric-registration
44+
fallback constructors with `panic!()` carrying
45+
`FALLBACK_METRIC_BUG_MSG` — a pointed diagnostic that identifies the
46+
offending metric name instead of a generic "entered unreachable code"
47+
message.
48+
- `deploy/admission/validatingadmissionpolicy.yaml`: add CEL expressions
49+
1b/1c/1d/1e mirroring the new runtime validators — `clusterName`
50+
length/charset, `killIfCommands` count/per-entry-length — so invalid
51+
specs are rejected at admission instead of only at reconciliation
52+
time.
53+
- `src/reconcilers/helpers_tests.rs`: 20 new tests covering the grace-
54+
period clock-skew fix, `validate_cluster_name` (happy path, empty,
55+
over-cap, control chars, non-ASCII), and `validate_kill_if_commands`
56+
(None/empty/typical/cap-boundary/over-cap/empty-entry).
57+
- `deploy/crds/scheduledmachine.yaml`: regenerated via `cargo run --bin
58+
crdgen` to pick up the new schema constraints.
59+
- `docs/src/reference/api.md`: regenerated via `make crddoc`.
60+
61+
### Why
62+
Findings from the deep security audit of the codebase (see
63+
conversation transcript 2026-04-24). Three issues were actionable: a
64+
clock-skew bypass of the graceful-drain window (critical; violates the
65+
contract of graceful shutdown under SOX §404 / NIST AC-3), an
66+
unbounded `killIfCommands` that could balloon Prometheus label
67+
cardinality and pin reclaim-agent CPU (high; DoS vector), and
68+
silent recovery from a poisoned retry-count mutex (medium; corrupts
69+
the exponential-backoff schedule). Cluster-name was also unbounded;
70+
the 63-char cap matches the effective CAPI constraint (cluster names
71+
flow into DNS labels downstream). Metric `unreachable!()` is a
72+
cosmetic bug-diagnostic improvement.
73+
74+
### Impact
75+
- [ ] Breaking change
76+
- [x] Requires cluster rollout (new CRD schema constraints; existing
77+
CRs that violate them will be rejected on next `kubectl apply`)
78+
- [ ] Config change only
79+
- [ ] Documentation only
80+
81+
---
82+
1283
## [2026-04-23 08:00] - Fix vexctl-install Linux path (wrong asset filename)
1384

1485
**Author:** Erick Bourgeois

deploy/admission/validatingadmissionpolicy.yaml

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,58 @@ spec:
3434

3535
validations:
3636

37-
# ── 1. clusterName ────────────────────────────────────────────────────────
37+
# ── 1. clusterName: non-empty ────────────────────────────────────────────
3838
- expression: "object.spec.clusterName.size() > 0"
3939
message: "spec.clusterName must not be empty"
4040
reason: Invalid
4141

42+
# ── 1b. clusterName: length ≤ MAX_CLUSTER_NAME_LEN (63) ──────────────────
43+
# Mirrors MAX_CLUSTER_NAME_LEN in src/constants.rs. 63 is the effective
44+
# CAPI cluster-name cap because the value flows downstream into DNS
45+
# labels (RFC-1123 limit) and into the
46+
# `cluster.x-k8s.io/cluster-name` label value. Rejecting longer values
47+
# at admission also bounds Prometheus label cardinality — the metrics
48+
# pipeline emits cluster_name as a label on several counters.
49+
- expression: "object.spec.clusterName.size() <= 63"
50+
message: >-
51+
spec.clusterName must be ≤ 63 characters (RFC-1123 DNS label limit;
52+
the effective CAPI cluster-name cap)
53+
reason: Invalid
54+
55+
# ── 1c. clusterName: safe character set ──────────────────────────────────
56+
# Restricts clusterName to ASCII alphanumerics, '-', '.', '_'. Blocks
57+
# log-injection via embedded newlines or NUL bytes and prevents exotic
58+
# Unicode from bloating Prometheus label cardinality. Mirrors the
59+
# runtime check in validate_cluster_name() (src/reconcilers/helpers.rs).
60+
- expression: "object.spec.clusterName.matches('^[A-Za-z0-9][A-Za-z0-9._-]*$')"
61+
message: >-
62+
spec.clusterName must start with an ASCII alphanumeric and contain
63+
only ASCII alphanumerics, '-', '.', or '_'
64+
reason: Invalid
65+
66+
# ── 1d. killIfCommands: count ≤ MAX_KILL_IF_COMMANDS_COUNT (100) ─────────
67+
# Mirrors MAX_KILL_IF_COMMANDS_COUNT in src/constants.rs. The per-node
68+
# reclaim agent evaluates every pattern against every PID in /proc; an
69+
# unbounded list lets a malicious CR pin agent CPU. Trivially true when
70+
# killIfCommands is absent (optional field).
71+
- expression: "!has(object.spec.killIfCommands) || object.spec.killIfCommands.size() <= 100"
72+
message: >-
73+
spec.killIfCommands must have at most 100 entries — each entry is
74+
evaluated against every /proc/<pid> by the per-node reclaim agent
75+
reason: Invalid
76+
77+
# ── 1e. killIfCommands: per-entry length ≤ MAX_KILL_IF_COMMAND_LEN (256) ─
78+
# Mirrors MAX_KILL_IF_COMMAND_LEN in src/constants.rs. Caps both agent
79+
# CPU per match attempt and the size of the per-node ConfigMap
80+
# projection. Empty strings are also rejected — they would match every
81+
# process and are almost certainly a mistake.
82+
- expression: |
83+
!has(object.spec.killIfCommands) ||
84+
object.spec.killIfCommands.all(c, c.size() >= 1 && c.size() <= 256)
85+
message: >-
86+
spec.killIfCommands entries must be 1..=256 characters each
87+
reason: Invalid
88+
4289
# ── 2. gracefulShutdownTimeout duration format ────────────────────────────
4390
# Must be <positive-integer><unit> where unit ∈ {s, m, h}.
4491
# Mirrors the parse_duration() check in src/reconcilers/helpers.rs.

deploy/crds/scheduledmachine.yaml

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,19 @@ spec:
6363
- spec
6464
type: object
6565
clusterName:
66-
description: Name of the CAPI cluster this machine belongs to
66+
description: |-
67+
Name of the CAPI cluster this machine belongs to.
68+
69+
Bounded to 63 characters — the RFC-1123 DNS label limit and the
70+
effective CAPI cluster-name cap, since the value flows downstream
71+
into the `cluster.x-k8s.io/cluster-name` label and into generated
72+
DNS labels. The schema also restricts the charset to ASCII
73+
alphanumerics, `-`, `.`, and `_` to block log-injection via
74+
embedded control characters and to bound Prometheus label
75+
cardinality.
76+
maxLength: 63
77+
minLength: 1
78+
pattern: ^[A-Za-z0-9][A-Za-z0-9._-]*$
6779
type: string
6880
gracefulShutdownTimeout:
6981
default: 5m
@@ -104,9 +116,17 @@ spec:
104116
basename) and `/proc/<pid>/cmdline` (substring). See the
105117
`5spot-emergency-reclaim-by-process-match.md` roadmap for full
106118
semantics.
119+
120+
Bounded to 100 entries × 256 characters each. The caps guard the
121+
per-node agent's CPU (every pattern is evaluated against every
122+
`/proc/<pid>`) and cap the size of the per-node `ConfigMap`
123+
projection — an unbounded list is both an operator foot-gun and a
124+
denial-of-service vector when driven from a malicious CR.
107125
items:
126+
maxLength: 256
127+
minLength: 1
108128
type: string
109-
nullable: true
129+
maxItems: 100
110130
type: array
111131
killSwitch:
112132
default: false

src/constants.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,34 @@ pub const MAX_DURATION_SECS: u64 = 86_400;
486486
/// Maximum allowed timezone string length
487487
pub const MAX_TIMEZONE_LEN: usize = 64;
488488

489+
/// Maximum allowed length of `spec.clusterName`.
490+
///
491+
/// CAPI's `Cluster.metadata.name` inherits the Kubernetes DNS-1123 subdomain
492+
/// cap (253 chars), but cluster names are used downstream as DNS labels and
493+
/// as label values (`cluster.x-k8s.io/cluster-name`), both of which are
494+
/// bounded by RFC-1123's 63-character DNS label limit. 63 is therefore the
495+
/// effective CAPI constraint. Rejecting longer values at the CR boundary
496+
/// also bounds Prometheus label cardinality (metrics emit the cluster name
497+
/// via `CAPI_CLUSTER_NAME_LABEL`) and caps log-line width, closing a
498+
/// cheap log-injection / cardinality-DoS vector.
499+
pub const MAX_CLUSTER_NAME_LEN: usize = 63;
500+
501+
/// Maximum number of `spec.killIfCommands` patterns accepted on a single
502+
/// `ScheduledMachine`. The reclaim agent evaluates every pattern against
503+
/// every PID in `/proc`; an unbounded list lets a malicious (or
504+
/// misconfigured) CR pin agent CPU. 100 is well above any realistic
505+
/// workload — a single node rarely runs more than a dozen distinct
506+
/// kill-switchable processes — and keeps the worst-case match cost bounded.
507+
pub const MAX_KILL_IF_COMMANDS_COUNT: usize = 100;
508+
509+
/// Maximum length of a single `spec.killIfCommands` entry. Patterns are
510+
/// matched against `/proc/<pid>/comm` (15 chars max per the kernel) and
511+
/// `/proc/<pid>/cmdline` (longer, but arguments beyond 256 bytes are a red
512+
/// flag for the intended use case of process-basename matching). This bound
513+
/// also caps Prometheus label widths if a future metric tags reclaim
514+
/// outcomes by matched pattern.
515+
pub const MAX_KILL_IF_COMMAND_LEN: usize = 256;
516+
489517
/// Timeout for finalizer cleanup operations (10 minutes in seconds)
490518
pub const FINALIZER_CLEANUP_TIMEOUT_SECS: u64 = 600;
491519

src/crd.rs

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,16 @@ pub struct ScheduledMachineSpec {
4545
/// Machine scheduling configuration
4646
pub schedule: ScheduleSpec,
4747

48-
/// Name of the CAPI cluster this machine belongs to
48+
/// Name of the CAPI cluster this machine belongs to.
49+
///
50+
/// Bounded to 63 characters — the RFC-1123 DNS label limit and the
51+
/// effective CAPI cluster-name cap, since the value flows downstream
52+
/// into the `cluster.x-k8s.io/cluster-name` label and into generated
53+
/// DNS labels. The schema also restricts the charset to ASCII
54+
/// alphanumerics, `-`, `.`, and `_` to block log-injection via
55+
/// embedded control characters and to bound Prometheus label
56+
/// cardinality.
57+
#[schemars(schema_with = "cluster_name_schema")]
4958
pub cluster_name: String,
5059

5160
/// Inline bootstrap configuration spec (e.g., `K0sWorkerConfig`)
@@ -101,7 +110,14 @@ pub struct ScheduledMachineSpec {
101110
/// basename) and `/proc/<pid>/cmdline` (substring). See the
102111
/// `5spot-emergency-reclaim-by-process-match.md` roadmap for full
103112
/// semantics.
113+
///
114+
/// Bounded to 100 entries × 256 characters each. The caps guard the
115+
/// per-node agent's CPU (every pattern is evaluated against every
116+
/// `/proc/<pid>`) and cap the size of the per-node `ConfigMap`
117+
/// projection — an unbounded list is both an operator foot-gun and a
118+
/// denial-of-service vector when driven from a malicious CR.
104119
#[serde(skip_serializing_if = "Option::is_none", default)]
120+
#[schemars(schema_with = "kill_if_commands_schema")]
105121
pub kill_if_commands: Option<Vec<String>>,
106122
}
107123

@@ -222,6 +238,35 @@ fn timezone_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
222238
})
223239
}
224240

241+
/// Schema for `spec.clusterName` — bounded to the effective CAPI cluster-name
242+
/// cap (RFC-1123 DNS label, 63 chars) with an ASCII-safe charset. Mirrors the
243+
/// runtime check in `validate_cluster_name()` (src/reconcilers/helpers.rs).
244+
fn cluster_name_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
245+
schemars::json_schema!({
246+
"type": "string",
247+
"minLength": 1,
248+
"maxLength": 63,
249+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
250+
})
251+
}
252+
253+
/// Schema for `spec.killIfCommands` — bounded list of bounded strings.
254+
/// Mirrors the runtime check in `validate_kill_if_commands()`
255+
/// (src/reconcilers/helpers.rs). 100 patterns × 256 chars is well above any
256+
/// realistic workload and caps both reclaim-agent CPU cost and the per-node
257+
/// `ConfigMap` projection size.
258+
fn kill_if_commands_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
259+
schemars::json_schema!({
260+
"type": "array",
261+
"maxItems": 100,
262+
"items": {
263+
"type": "string",
264+
"minLength": 1,
265+
"maxLength": 256
266+
}
267+
})
268+
}
269+
225270
/// Schema for `EmbeddedResource` — requires apiVersion, kind, and spec fields.
226271
/// The `spec` field uses `x-kubernetes-preserve-unknown-fields` to allow any
227272
/// provider-specific fields (`K0sWorkerConfig`, `RemoteMachine`, `AWSMachine`, etc.).

src/metrics.rs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,28 +39,41 @@ use prometheus::{
3939
//
4040
// If metric registration fails (e.g., duplicate name in tests), we log a
4141
// warning and fall back to an *unregistered* metric so the process continues.
42-
// The fallback constructors use hardcoded valid names and cannot panic.
42+
// The fallback constructors use hardcoded metric names that the Prometheus
43+
// crate must accept (ASCII alphanumerics + underscores, non-empty, not
44+
// starting with a digit). They have never failed in practice — but the
45+
// contract is enforced by `prometheus`, not us, so we guard with `expect()`
46+
// carrying a pointed diagnostic rather than `unreachable!()` which compiles
47+
// to a panic with a misleading message. Either way a failure here is a
48+
// programming bug (likely a rename that introduced an invalid character),
49+
// not a runtime configuration issue.
4350
// ============================================================================
4451

52+
/// Error message used by every fallback constructor — identifies the failing
53+
/// metric so a crash log points straight at the offending hardcoded name.
54+
const FALLBACK_METRIC_BUG_MSG: &str = "BUG: hardcoded metric name failed Prometheus validation; \
55+
this is a programming error, not a runtime issue — \
56+
see src/metrics.rs for the offending static";
57+
4558
/// Create an *unregistered* `CounterVec` used as a no-op fallback when
4659
/// `register_counter_vec!` fails (e.g. duplicate name in test processes).
4760
fn fallback_counter_vec(name: &str, help: &str, labels: &[&str]) -> CounterVec {
4861
CounterVec::new(Opts::new(name, help), labels)
49-
.unwrap_or_else(|_| unreachable!("hardcoded metric name '{name}' is always valid"))
62+
.unwrap_or_else(|e| panic!("{FALLBACK_METRIC_BUG_MSG}: name={name:?} err={e}"))
5063
}
5164

5265
/// Create an *unregistered* `Gauge` used as a no-op fallback when
5366
/// `register_gauge!` fails.
5467
fn fallback_gauge(name: &str, help: &str) -> Gauge {
5568
Gauge::new(name, help)
56-
.unwrap_or_else(|_| unreachable!("hardcoded metric name '{name}' is always valid"))
69+
.unwrap_or_else(|e| panic!("{FALLBACK_METRIC_BUG_MSG}: name={name:?} err={e}"))
5770
}
5871

5972
/// Create an *unregistered* `GaugeVec` used as a no-op fallback when
6073
/// `register_gauge_vec!` fails.
6174
fn fallback_gauge_vec(name: &str, help: &str, labels: &[&str]) -> GaugeVec {
6275
GaugeVec::new(Opts::new(name, help), labels)
63-
.unwrap_or_else(|_| unreachable!("hardcoded metric name '{name}' is always valid"))
76+
.unwrap_or_else(|e| panic!("{FALLBACK_METRIC_BUG_MSG}: name={name:?} err={e}"))
6477
}
6578

6679
/// Create an *unregistered* `HistogramVec` used as a no-op fallback when
@@ -75,7 +88,7 @@ fn fallback_histogram_vec(
7588
prometheus::HistogramOpts::new(name, help).buckets(buckets),
7689
labels,
7790
)
78-
.unwrap_or_else(|_| unreachable!("hardcoded metric name '{name}' is always valid"))
91+
.unwrap_or_else(|e| panic!("{FALLBACK_METRIC_BUG_MSG}: name={name:?} err={e}"))
7992
}
8093

8194
// ============================================================================

0 commit comments

Comments
 (0)