Skip to main content

cellos_ctl/cmd/
apply.rs

1//! `cellctl apply -f formation.yaml` → POST /v1/formations.
2//!
3//! YAML is parsed locally only to validate shape and convert to JSON. The
4//! authoritative validation happens on the server; we do a minimal local check
5//! to surface obvious schema errors with exit-code 3 before any network call.
6//!
7//! ## Red-team wave 2 hardening
8//!
9//! `serde_yaml` (the unmaintained YAML 1.1 parser still in the workspace) is
10//! known to be vulnerable to the "billion-laughs" alias-expansion attack: a
11//! ~1 KiB YAML document can expand to gigabytes of in-memory `Value` tree
12//! during deserialization. The full fix is to migrate off `serde_yaml`; until
13//! that lands, we defend in depth here:
14//!
15//! 1. **Hard byte cap on the source file.** A formation document is ~12 KiB
16//!    for a 64-cell formation; 256 KiB is two orders of magnitude over any
17//!    legitimate input and small enough that even an aggressive alias bomb
18//!    cannot inflate past a few hundred MiB of allocator pressure on the
19//!    parsing pass (still bad, but not unbounded-CPU bad).
20//! 2. **Reject symlinks at the final path component.** `cellctl apply -f
21//!    /tmp/attacker-controlled` cannot redirect the read to `/etc/shadow`
22//!    via a swapped-in symlink. Matches the SEC-15b / SEC-08 protection
23//!    applied to `CELLOS_POLICY_PACK_PATH` and the supervisor spec path.
24//! 3. **Reject non-regular files.** Named pipes, devices, and sockets are
25//!    not legitimate config sources — `cellctl apply -f /dev/zero` should
26//!    fail fast, not spin forever inside `read_to_string`.
27
28use std::path::Path;
29
30use serde_json::Value;
31
32use crate::client::CellosClient;
33use crate::exit::{CtlError, CtlResult};
34use crate::model::Formation;
35
36/// Maximum size of a `cellctl apply -f` source file. See module docs.
37pub(crate) const APPLY_MAX_INPUT_BYTES: u64 = 256 * 1024;
38
39pub async fn run(client: &CellosClient, path: &Path) -> CtlResult<()> {
40    let yaml = read_apply_input(path)?;
41
42    let body: Value = serde_yaml::from_str(&yaml)?;
43    validate_formation_spec(&body, path)?;
44
45    let created: Formation = client.post_json("/v1/formations", &body).await?;
46    let name = if !created.name.is_empty() {
47        &created.name
48    } else {
49        &created.id
50    };
51    println!("formation/{name} created (state={})", created.state);
52    Ok(())
53}
54
55/// Local shape check — the server is authoritative, but obvious mistakes
56/// should fail fast with exit code 3 (validation) rather than 2 (api).
57fn validate_formation_spec(v: &Value, path: &Path) -> CtlResult<()> {
58    let Some(obj) = v.as_object() else {
59        return Err(CtlError::validation(format!(
60            "{}: top-level must be a YAML mapping",
61            path.display()
62        )));
63    };
64
65    // Accept either `kind: Formation` (kubectl-style) OR a bare formation spec
66    // (which is what Session 15/16 sketched). Don't be pedantic — server decides.
67    if let Some(kind) = obj.get("kind").and_then(|k| k.as_str()) {
68        if kind != "Formation" {
69            return Err(CtlError::validation(format!(
70                "{}: kind must be \"Formation\" (got \"{kind}\")",
71                path.display()
72            )));
73        }
74    }
75
76    // Require either `name` or `metadata.name`.
77    let has_name = obj.get("name").and_then(|n| n.as_str()).is_some()
78        || obj
79            .get("metadata")
80            .and_then(|m| m.get("name"))
81            .and_then(|n| n.as_str())
82            .is_some();
83    if !has_name {
84        return Err(CtlError::validation(format!(
85            "{}: missing required field `name` (or `metadata.name`)",
86            path.display()
87        )));
88    }
89    Ok(())
90}
91
92/// Read `cellctl apply -f` input with red-team wave 2 hardening:
93/// - reject symlinks (`symlink_metadata` then refuse `is_symlink`),
94/// - reject non-regular files (named pipes, devices, sockets),
95/// - cap total bytes at [`APPLY_MAX_INPUT_BYTES`] before allocating the body.
96///
97/// See module docs for the threat model. All failures map to [`CtlError::usage`]
98/// (exit code 1) since they describe an invalid invocation, not a server-side
99/// validation failure of the document's contents.
100pub(crate) fn read_apply_input(path: &Path) -> CtlResult<String> {
101    use std::io::Read;
102    // symlink_metadata does NOT follow the final component, so an attacker who
103    // swapped /tmp/spec.yaml -> /etc/shadow between the user typing the command
104    // and us opening the file is caught here rather than silently followed.
105    let meta = std::fs::symlink_metadata(path)
106        .map_err(|e| CtlError::usage(format!("read {}: {e}", path.display())))?;
107    if meta.file_type().is_symlink() {
108        return Err(CtlError::usage(format!(
109            "{}: refusing to follow symlink (apply requires a regular file)",
110            path.display()
111        )));
112    }
113    if !meta.file_type().is_file() {
114        return Err(CtlError::usage(format!(
115            "{}: not a regular file (named pipes, devices, and sockets are rejected)",
116            path.display()
117        )));
118    }
119    // Pre-size check via metadata.len() — for regular files this is the exact
120    // byte count and lets us refuse before any allocation. We still cap the
121    // read itself with `take()` so a TOCTOU expansion between stat and open
122    // cannot blow past the limit.
123    if meta.len() > APPLY_MAX_INPUT_BYTES {
124        return Err(CtlError::usage(format!(
125            "{}: file is {} bytes; maximum is {} (resists serde_yaml billion-laughs)",
126            path.display(),
127            meta.len(),
128            APPLY_MAX_INPUT_BYTES,
129        )));
130    }
131
132    let mut file = std::fs::File::open(path)
133        .map_err(|e| CtlError::usage(format!("read {}: {e}", path.display())))?;
134    // +1 so we can detect a TOCTOU race that grew the file past the cap.
135    let mut buf = String::with_capacity(meta.len() as usize);
136    let limited = std::io::Read::take(&mut file, APPLY_MAX_INPUT_BYTES + 1);
137    let n = std::io::BufReader::new(limited)
138        .read_to_string(&mut buf)
139        .map_err(|e| CtlError::usage(format!("read {}: {e}", path.display())))?;
140    if n as u64 > APPLY_MAX_INPUT_BYTES {
141        return Err(CtlError::usage(format!(
142            "{}: file exceeded {} byte cap mid-read (TOCTOU growth?)",
143            path.display(),
144            APPLY_MAX_INPUT_BYTES,
145        )));
146    }
147    Ok(buf)
148}
149
150#[cfg(test)]
151mod tests {
152    //! Red-team wave 2 hostile-input tests for `cellctl apply` source files.
153    use super::*;
154    use std::io::Write;
155
156    /// Helper: a tempdir-scoped writable file.
157    fn write_temp(name: &str, contents: &[u8]) -> std::path::PathBuf {
158        let dir =
159            std::env::temp_dir().join(format!("cellctl-apply-rt2-{}-{}", name, std::process::id()));
160        std::fs::create_dir_all(&dir).expect("mkdir tempdir");
161        let p = dir.join(format!("{name}.yaml"));
162        let mut f = std::fs::File::create(&p).expect("create temp file");
163        f.write_all(contents).expect("write temp file");
164        p
165    }
166
167    /// Red-team W2C-M2 / W2C-H1: a 1 MiB YAML file exceeds the
168    /// `APPLY_MAX_INPUT_BYTES` cap and must be refused with a `usage` error
169    /// before `serde_yaml` is invoked. This defends against the
170    /// serde_yaml billion-laughs vector by capping the *source*: a 256 KiB
171    /// alias bomb cannot inflate past a few hundred MiB even at worst case,
172    /// far below "OOM the operator's laptop".
173    #[test]
174    fn rejects_oversized_input_before_parse() {
175        let big = vec![b'a'; 1024 * 1024];
176        let p = write_temp("oversized", &big);
177        let err = read_apply_input(&p).expect_err("oversized must fail");
178        assert!(
179            err.message.contains("maximum is")
180                || err.message.contains("byte cap")
181                || err.message.contains("billion-laughs"),
182            "expected size-cap error, got {err:?}",
183        );
184        let _ = std::fs::remove_file(&p);
185    }
186
187    /// Red-team W2C-L1: a symlink as the final path component is refused so
188    /// `cellctl apply -f /tmp/spec.yaml` cannot be redirected to `/etc/shadow`
189    /// or `/dev/zero` by an attacker who swapped the symlink in between
190    /// `argv` parsing and the open.
191    #[cfg(unix)]
192    #[test]
193    fn rejects_symlink_at_final_component() {
194        let target = write_temp("symlink-target", b"name: t\n");
195        let link_dir = target.parent().unwrap().join("link-rt2");
196        std::fs::create_dir_all(&link_dir).expect("mkdir link dir");
197        let link = link_dir.join("link.yaml");
198        let _ = std::fs::remove_file(&link);
199        std::os::unix::fs::symlink(&target, &link).expect("symlink");
200        let err = read_apply_input(&link).expect_err("symlink must be refused");
201        assert!(
202            err.message.contains("symlink"),
203            "expected symlink-refusal, got {err:?}",
204        );
205        let _ = std::fs::remove_file(&link);
206        let _ = std::fs::remove_file(&target);
207    }
208
209    /// Red-team: a tiny well-formed YAML still parses (regression guard).
210    #[test]
211    fn accepts_small_well_formed_yaml() {
212        let p = write_temp(
213            "small",
214            b"name: demo\ncoordinator: coord\nmembers:\n  - id: coord\n",
215        );
216        let s = read_apply_input(&p).expect("small file ok");
217        assert!(s.contains("coord"));
218        let _ = std::fs::remove_file(&p);
219    }
220
221    /// Red-team: `validate_formation_spec` must reject a `kind` that is the
222    /// right case-folded shape but the wrong literal value. We accept exact
223    /// "Formation" only — case-tweaked variants ("formation", "FORMATION") are
224    /// rejected because an admission-gate that accepts case variants is one
225    /// step from accepting Unicode lookalikes.
226    #[test]
227    fn validate_rejects_wrong_kind_case() {
228        let v: Value = serde_yaml::from_str(
229            "kind: formation\nname: demo\ncoordinator: coord\nmembers:\n  - id: coord\n",
230        )
231        .unwrap();
232        let err = validate_formation_spec(&v, std::path::Path::new("/dev/null"))
233            .expect_err("wrong kind must fail");
234        assert!(
235            err.message.contains("kind"),
236            "expected kind-mismatch, got {err:?}",
237        );
238    }
239
240    /// Red-team W2C-M2 (named-pipe variant): /dev/zero must fail fast with
241    /// "not a regular file" rather than spinning forever inside
242    /// `read_to_string`. On non-unix this test is skipped because the path
243    /// likely does not exist.
244    #[cfg(unix)]
245    #[test]
246    fn rejects_dev_zero() {
247        let p = std::path::Path::new("/dev/zero");
248        if !p.exists() {
249            return;
250        }
251        let err = read_apply_input(p).expect_err("/dev/zero must be refused");
252        assert!(
253            err.message.contains("not a regular file") || err.message.contains("symlink"),
254            "expected non-regular-file refusal, got {err:?}",
255        );
256    }
257}