Skip to content

Commit f53d619

Browse files
authored
Watch-mapper status-amplification mitigation (#48)
Signed-off-by: Erick Bourgeois <erick@jeb.ca>
1 parent 22ca77c commit f53d619

5 files changed

Lines changed: 428 additions & 12 deletions

File tree

.claude/CHANGELOG.md

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

1010
---
1111

12+
## [2026-04-25 22:00] - Phase 3 of security audit: route Node→SM via canonical CAPI Machine
13+
14+
**Author:** Erick Bourgeois
15+
16+
### Changed
17+
- `src/reconcilers/helpers.rs`: new
18+
`node_to_scheduled_machines_via_machine(node, machines)` mapper. Routes
19+
Node events to owning ScheduledMachines via the canonical CAPI Machine
20+
ownership chain — `Machine.status.nodeRef.name` (written only by the
21+
CAPI controller) plus the controller-stamped
22+
`LABEL_SCHEDULED_MACHINE` label on the Machine. Both legs are
23+
write-controlled by trusted controllers, not by the tenant.
24+
- `src/reconcilers/helpers.rs`: `node_to_scheduled_machines` (the
25+
pre-existing mapper that trusted `SM.status.nodeRef.name`) marked
26+
`#[deprecated(since = "0.1.2")]` with a note pointing at the
27+
replacement. Kept exported and tested for one release so any external
28+
caller (none expected) gets a compile-time pointer.
29+
- `src/reconcilers/mod.rs`: re-export the new symbol; gate the legacy
30+
re-export with `#[allow(deprecated)]`.
31+
- `src/main.rs`: wire a standalone `kube::runtime::reflector` for CAPI
32+
Machines (filtered by `LABEL_SCHEDULED_MACHINE`). Spawn its watcher in
33+
the background; pass the reader handle into the Node watch closure.
34+
Switch the Node mapper from `node_to_scheduled_machines` to
35+
`node_to_scheduled_machines_via_machine`. The
36+
`Controller::watches_with` Machine watch keeps its own internal
37+
reflector for the existing Machine→SM route — kube-rs does not expose
38+
that internal store, so the secondary watch is intentional. Doubled
39+
watch traffic is minor (same kind, same label selector).
40+
- `src/reconcilers/helpers_tests.rs`: 9 new tests covering the
41+
canonical-Machine mapper:
42+
- happy-path single-Machine match returns owning SM
43+
- Node with no owning Machine → NOT enqueued (closes spoof window)
44+
- Machine for a different node → NOT enqueued (canonical wins over
45+
spoofed SM status)
46+
- unlabelled / namespace-less / no-nodeRef Machine → ignored
47+
- Node without metadata.name → ignored
48+
- whitespace-only label value → ignored (mirrors
49+
`machine_to_scheduled_machine`'s defensive trim)
50+
- multiple Machines for same Node → both owning SMs enqueued
51+
- Existing `test_node_to_sms_*` tests for the deprecated mapper kept
52+
with per-test `#[allow(deprecated)]` annotations so the legacy
53+
surface stays covered until removal.
54+
55+
### Why
56+
Phase 3 of the 2026-04-25 security audit roadmap. The pre-existing
57+
`node_to_scheduled_machines` mapper filtered SMs by
58+
`status.nodeRef.name`, which is writable by anyone with `patch
59+
scheduledmachines/status` on the resource. A tenant could write a
60+
fast-changing node name (e.g. a busy worker that's frequently
61+
updating) into status to amplify how many SM reconciliations they
62+
caused per Node update — a CPU DoS amplifier. The drain target itself
63+
was never affected (the reconciler always reads canonical
64+
`Machine.status.nodeRef` via `get_node_from_machine`), so this was
65+
verified as Low severity, but the routing change closes the
66+
amplification regardless of how the reconciler evolves.
67+
68+
### Impact
69+
- [ ] Breaking change (deprecated symbol still exported)
70+
- [ ] Requires cluster rollout (no schema or RBAC changes)
71+
- [x] Config change only (in-binary; one extra Machine watch on the
72+
same selector that already exists)
73+
- [ ] Documentation only
74+
75+
---
76+
1277
## [2026-04-25 18:00] - Phase 2 of security audit: finalizer cleanup force-remove on PDB stall
1378

1479
**Author:** Erick Bourgeois

src/main.rs

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,19 @@ use five_spot::health::{start_health_server, HealthState};
2828
use five_spot::labels::LABEL_SCHEDULED_MACHINE;
2929
use five_spot::metrics::init_controller_info;
3030
use five_spot::reconcilers::{
31-
error_policy, machine_to_scheduled_machine, node_to_scheduled_machines,
31+
error_policy, machine_to_scheduled_machine, node_to_scheduled_machines_via_machine,
3232
reconcile_scheduled_machine, Context,
3333
};
34-
use futures::StreamExt;
34+
use futures::{StreamExt, TryStreamExt};
3535
use k8s_openapi::api::core::v1::Node;
3636
use kube::{
3737
api::{ApiResource, GroupVersionKind, ListParams},
3838
core::DynamicObject,
39-
runtime::{watcher::Config, Controller},
39+
runtime::{
40+
reflector,
41+
watcher::{self, Config},
42+
Controller, WatchStreamExt,
43+
},
4044
Api, Client,
4145
};
4246
use tracing::{error, info, warn};
@@ -276,19 +280,51 @@ async fn main() -> Result<()> {
276280
info!("Starting controller for ScheduledMachine resources");
277281

278282
// Secondary watches — event-driven reactivity without polling.
279-
// 1. CAPI Machine (dynamic GVK) — filtered by the scheduled-machine label we
280-
// already stamp on every Machine we create. Reverse-mapped via that label.
281-
// 2. Kubernetes Node — name-matched against every SM's status.nodeRef.name
282-
// using a snapshot of the controller's own primary-resource Store.
283+
//
284+
// 1. CAPI Machine (dynamic GVK), filtered by the scheduled-machine label
285+
// we stamp on every Machine we create. Reverse-mapped via that label
286+
// (`machine_to_scheduled_machine`). The `Controller::watches_with`
287+
// call below maintains its own internal reflector for this watch.
288+
//
289+
// 2. Kubernetes Node, mapped to owning ScheduledMachines via the
290+
// canonical CAPI Machine ownership chain
291+
// (`node_to_scheduled_machines_via_machine`). This requires its own
292+
// Machine reflector store, separate from #1's internal one — kube-rs
293+
// `watches_with` does not expose its internal store. The doubled
294+
// watch traffic is minor: same kind, same label selector. Routing
295+
// via the canonical Machine instead of the tenant-writable
296+
// `SM.status.nodeRef.name` closes the watch-amplification vector
297+
// documented in Phase 3 of the 2026-04-25 security audit roadmap.
283298
let machine_ar = ApiResource::from_gvk_with_plural(
284299
&GroupVersionKind::gvk(CAPI_GROUP, CAPI_MACHINE_API_VERSION, "Machine"),
285300
CAPI_RESOURCE_MACHINES,
286301
);
287302
let machines_api: Api<DynamicObject> = Api::all_with(client.clone(), &machine_ar);
288303
let nodes_api: Api<Node> = Api::all(client.clone());
289304

305+
// Standalone Machine reflector for the Node→SM mapper. We build its
306+
// store here, spawn the watcher in the background, and clone the
307+
// reader handle into the Node closure below. The store starts empty
308+
// and fills as Machine watch events arrive — Node events that fire
309+
// during the warmup window will see an empty store and enqueue
310+
// nothing, which is correct (no Machine yet ⇒ no SM owns this Node).
311+
let machine_writer: reflector::store::Writer<DynamicObject> =
312+
reflector::store::Writer::new(machine_ar.clone());
313+
let machine_store = machine_writer.as_reader();
314+
let machine_reflector_machines_api = machines_api.clone();
315+
tokio::spawn(async move {
316+
let stream = watcher::watcher(
317+
machine_reflector_machines_api,
318+
watcher::Config::default().labels(LABEL_SCHEDULED_MACHINE),
319+
)
320+
.reflect(machine_writer)
321+
.applied_objects();
322+
if let Err(e) = stream.try_for_each(|_| async { Ok(()) }).await {
323+
error!(error = %e, "Machine reflector for Node→SM mapping terminated");
324+
}
325+
});
326+
290327
let controller = Controller::new(scheduled_machines, Config::default());
291-
let sm_store = controller.store();
292328

293329
controller
294330
.watches_with(
@@ -298,8 +334,14 @@ async fn main() -> Result<()> {
298334
|machine: DynamicObject| machine_to_scheduled_machine(&machine),
299335
)
300336
.watches(nodes_api, Config::default(), move |node: Node| {
301-
let snapshot = sm_store.state();
302-
node_to_scheduled_machines(&node, snapshot.iter().map(std::convert::AsRef::as_ref))
337+
// Snapshot the standalone Machine reflector's store. Each
338+
// entry is an Arc<DynamicObject>; deref through AsRef to feed
339+
// the pure mapper.
340+
let machines_snapshot = machine_store.state();
341+
node_to_scheduled_machines_via_machine(
342+
&node,
343+
machines_snapshot.iter().map(std::convert::AsRef::as_ref),
344+
)
303345
})
304346
.shutdown_on_signal()
305347
.run(reconcile_scheduled_machine, error_policy, context)

src/reconcilers/helpers.rs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1600,10 +1600,32 @@ pub fn machine_to_scheduled_machine(
16001600
/// Map a `Node` event to all `ScheduledMachine`s whose
16011601
/// `status.nodeRef.name == node.metadata.name`.
16021602
///
1603+
/// **DEPRECATED — replaced by [`node_to_scheduled_machines_via_machine`].**
1604+
/// This function trusts the SM's own `status.nodeRef.name`, which is
1605+
/// writable by anyone with `patch scheduledmachines/status` on the
1606+
/// resource. A tenant could write a fast-changing node name into
1607+
/// status to amplify how many SM reconciliations they trigger from
1608+
/// unrelated Node events (CPU DoS amplifier — see Phase 3 of the
1609+
/// 2026-04-25 security audit roadmap).
1610+
///
1611+
/// The replacement routes Node → Machine (canonical
1612+
/// `Machine.status.nodeRef.name` written by CAPI) → SM (via the
1613+
/// controller-stamped `LABEL_SCHEDULED_MACHINE` label on the Machine),
1614+
/// closing the spoof window.
1615+
///
1616+
/// Kept exported with `#[deprecated]` for one release so external
1617+
/// callers (none expected) get a compile-time pointer at the
1618+
/// replacement.
1619+
///
16031620
/// Runs `O(N)` over the supplied SM iterator. Fine at small scale (tens to
16041621
/// hundreds of SMs); if the cluster ever hosts thousands, swap in a reverse
16051622
/// index keyed by the last-observed `nodeRef.name`.
16061623
#[must_use]
1624+
#[deprecated(
1625+
since = "0.1.2",
1626+
note = "Trusts tenant-writable SM.status.nodeRef.name; use \
1627+
node_to_scheduled_machines_via_machine for canonical-Machine routing."
1628+
)]
16071629
pub fn node_to_scheduled_machines<'a, I>(
16081630
node: &k8s_openapi::api::core::v1::Node,
16091631
scheduled_machines: I,
@@ -1634,6 +1656,95 @@ where
16341656
.collect()
16351657
}
16361658

1659+
/// Map a `Node` event to all `ScheduledMachine`s whose **owning CAPI Machine**
1660+
/// has `status.nodeRef.name == node.metadata.name`.
1661+
///
1662+
/// This is the canonical-Machine replacement for
1663+
/// [`node_to_scheduled_machines`]. Routing via the CAPI Machine instead of the
1664+
/// SM's own status closes the watch-amplification vector where a tenant with
1665+
/// `patch scheduledmachines/status` could spoof `SM.status.nodeRef.name` to
1666+
/// pin controller CPU on unrelated Node updates: only the CAPI controller
1667+
/// writes `Machine.status.nodeRef`, and only the 5-Spot controller stamps
1668+
/// the [`crate::labels::LABEL_SCHEDULED_MACHINE`] label that walks back to
1669+
/// the SM. Both legs are write-controlled by trusted controllers, not by
1670+
/// the tenant.
1671+
///
1672+
/// Algorithm (per Machine in the supplied iterator):
1673+
/// 1. Read `metadata.namespace` — required to build a namespaced
1674+
/// `ObjectRef`. Skip Machines without one.
1675+
/// 2. Read `status.nodeRef.name` from the Machine's dynamic `data` field
1676+
/// (`DynamicObject` because CAPI types are not statically modelled in
1677+
/// this codebase). Skip Machines whose `nodeRef` is absent or doesn't
1678+
/// match the node's name.
1679+
/// 3. Read `metadata.labels[LABEL_SCHEDULED_MACHINE]` to identify the
1680+
/// owning SM. Skip if the label is missing, empty, or whitespace-only
1681+
/// (mirrors the same defensive trim+empty check in
1682+
/// [`machine_to_scheduled_machine`]).
1683+
/// 4. Emit one [`kube::runtime::reflector::ObjectRef`] per match.
1684+
///
1685+
/// Returns an empty `Vec` for the degenerate case where the Node itself
1686+
/// has no `metadata.name` — there is nothing meaningful to match against.
1687+
///
1688+
/// Runs `O(N)` over the supplied Machine iterator. With one Machine per
1689+
/// SM and tens-to-hundreds of SMs per cluster this is comfortably cheap;
1690+
/// at thousands of SMs, build a reverse index keyed by node name.
1691+
///
1692+
/// # Threat model context
1693+
/// Filed as Phase 3 of the 2026-04-25 security audit roadmap (severity:
1694+
/// Low — CPU amplification, not a drain pivot). The reconciler always
1695+
/// reads canonical `Machine.status.nodeRef` via `get_node_from_machine`
1696+
/// before draining, so the spoof never reached the drain target — but
1697+
/// it still amplified per-SM CPU cost. This routing change makes the
1698+
/// amplification impossible regardless of how the reconciler evolves.
1699+
#[must_use]
1700+
pub fn node_to_scheduled_machines_via_machine<'a, M>(
1701+
node: &k8s_openapi::api::core::v1::Node,
1702+
machines: M,
1703+
) -> Vec<kube::runtime::reflector::ObjectRef<ScheduledMachine>>
1704+
where
1705+
M: IntoIterator<Item = &'a kube::core::DynamicObject>,
1706+
{
1707+
let Some(node_name) = node.metadata.name.as_deref() else {
1708+
return Vec::new();
1709+
};
1710+
if node_name.is_empty() {
1711+
return Vec::new();
1712+
}
1713+
1714+
machines
1715+
.into_iter()
1716+
.filter_map(|machine| {
1717+
// Canonical nodeRef from Machine.status — written only by the
1718+
// CAPI controller, not by the tenant.
1719+
let machine_node_name = machine
1720+
.data
1721+
.get("status")?
1722+
.get("nodeRef")?
1723+
.get("name")?
1724+
.as_str()?;
1725+
if machine_node_name.is_empty() || machine_node_name != node_name {
1726+
return None;
1727+
}
1728+
1729+
// Walk to the owning SM via the controller-stamped label —
1730+
// same primitive used by machine_to_scheduled_machine.
1731+
let labels = machine.metadata.labels.as_ref()?;
1732+
let raw_sm_name = labels.get(crate::labels::LABEL_SCHEDULED_MACHINE)?;
1733+
let sm_name = raw_sm_name.trim();
1734+
if sm_name.is_empty() {
1735+
return None;
1736+
}
1737+
1738+
let sm_namespace = machine.metadata.namespace.as_deref()?;
1739+
1740+
Some(
1741+
kube::runtime::reflector::ObjectRef::<ScheduledMachine>::new(sm_name)
1742+
.within(sm_namespace),
1743+
)
1744+
})
1745+
.collect()
1746+
}
1747+
16371748
/// Cordon a Kubernetes Node (mark as unschedulable)
16381749
///
16391750
/// # Errors

0 commit comments

Comments
 (0)