bindy/
safe_volume.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Strict allow-list validators for user-supplied `Volume` and `VolumeMount`
5//! entries on `Bind9Instance` / `Bind9Cluster` CRDs.
6//!
7//! # Why
8//!
9//! `Bind9Instance.spec.volumes`, `Bind9Instance.spec.volumeMounts`, and the
10//! same fields on `Bind9ClusterCommonSpec` are typed as the full
11//! `k8s_openapi::api::core::v1::Volume` / `VolumeMount`. Without filtering, a
12//! namespace-tenant who can create a `Bind9Instance` could mount the host
13//! filesystem (`hostPath`), an arbitrary Secret in the target namespace
14//! (`secret`), or other dangerous volume sources into a Pod the operator
15//! stamps with cluster-wide RBAC. This module enforces an allow-list so the
16//! reconciler can refuse the CR with a clear status condition before any
17//! Pod is built.
18//!
19//! Closes audit finding F-001.
20//!
21//! # Allow-list
22//!
23//! - **Volume sources:** `emptyDir`, `configMap` (name must start with
24//!   [`crate::constants::ALLOWED_USER_CONFIGMAP_PREFIX`]), `secret` (name
25//!   must start with [`crate::constants::ALLOWED_USER_SECRET_PREFIX`]),
26//!   `persistentVolumeClaim` (name must start with
27//!   [`crate::constants::ALLOWED_USER_PVC_PREFIX`]).
28//! - **VolumeMount.mountPath:** must begin with one of
29//!   [`crate::constants::ALLOWED_USER_MOUNT_PREFIXES`] and must not contain
30//!   `..` (a `..` segment can escape the prefix onto an operator-owned path).
31//! - **VolumeMount.subPath / subPathExpr:** must not contain `..`.
32//!
33//! Everything else is rejected. This is an allow-list, not a block-list, so
34//! future Volume variants added by Kubernetes are rejected by default.
35
36use crate::constants::{
37    ALLOWED_USER_CONFIGMAP_PREFIX, ALLOWED_USER_MOUNT_PREFIXES, ALLOWED_USER_PVC_PREFIX,
38    ALLOWED_USER_SECRET_PREFIX,
39};
40use k8s_openapi::api::core::v1::{Volume, VolumeMount};
41use thiserror::Error;
42
43/// Rejection reasons returned by [`validate_user_volumes`] and
44/// [`validate_user_volume_mounts`].
45///
46/// Each variant carries enough context for the reconciler to render a clear
47/// status condition on the offending CR.
48#[derive(Debug, Error, PartialEq, Eq)]
49pub enum VolumeRejection {
50    #[error(
51        "volume {name:?} uses forbidden source kind {kind}: only emptyDir, configMap, secret \
52         (with name prefix {ALLOWED_USER_SECRET_PREFIX:?}), or persistentVolumeClaim (with name \
53         prefix {ALLOWED_USER_PVC_PREFIX:?}) are permitted"
54    )]
55    ForbiddenSource { name: String, kind: &'static str },
56
57    #[error(
58        "volume {name:?} secret reference {secret:?} does not start with the required prefix \
59         {ALLOWED_USER_SECRET_PREFIX:?}"
60    )]
61    SecretNamePrefix { name: String, secret: String },
62
63    #[error(
64        "volume {name:?} configMap reference {config_map:?} does not start with the required \
65         prefix {ALLOWED_USER_CONFIGMAP_PREFIX:?}"
66    )]
67    ConfigMapNamePrefix { name: String, config_map: String },
68
69    #[error(
70        "volume {name:?} persistentVolumeClaim reference {pvc:?} does not start with the \
71         required prefix {ALLOWED_USER_PVC_PREFIX:?}"
72    )]
73    PvcNamePrefix { name: String, pvc: String },
74
75    #[error(
76        "volumeMount mountPath {path:?} is outside the allowed prefixes \
77         {ALLOWED_USER_MOUNT_PREFIXES:?}"
78    )]
79    MountPathOutsideAllowList { path: String },
80
81    #[error("volumeMount mountPath {path:?} contains '..' (path traversal not permitted)")]
82    MountPathTraversal { path: String },
83
84    #[error("volumeMount {field} {value:?} contains '..' (path traversal not permitted)")]
85    SubPathTraversal { field: &'static str, value: String },
86
87    #[error(
88        "DNSSEC keysFrom secret reference {secret:?} does not start with the required prefix \
89         {ALLOWED_USER_SECRET_PREFIX:?}"
90    )]
91    DnssecKeySecretPrefix { secret: String },
92}
93
94/// Validate that a user-supplied DNSSEC key Secret name obeys the same
95/// name-prefix allow-list as user `secret:` volumes.
96///
97/// `spec.dnssec.signing.keysFrom.secretRef` is mounted into the operand Pod
98/// outside the normal `spec.volumes` path, so without this check a tenant can
99/// mount an arbitrary Secret in the namespace (e.g. another tenant's RNDC/TSIG
100/// key), bypassing both [`validate_user_volumes`] and the pod-shape admission
101/// policy. Closes audit finding H2.
102///
103/// # Errors
104///
105/// Returns [`VolumeRejection::DnssecKeySecretPrefix`] if `name` does not start
106/// with [`crate::constants::ALLOWED_USER_SECRET_PREFIX`].
107pub fn validate_dnssec_key_secret_name(name: &str) -> Result<(), VolumeRejection> {
108    if name.starts_with(ALLOWED_USER_SECRET_PREFIX) {
109        return Ok(());
110    }
111    Err(VolumeRejection::DnssecKeySecretPrefix {
112        secret: name.to_string(),
113    })
114}
115
116/// Validate a slice of user-supplied [`Volume`] entries against the
117/// allow-list. Returns the first rejection encountered.
118///
119/// # Errors
120///
121/// Returns [`VolumeRejection`] for the first volume that fails any check.
122pub fn validate_user_volumes(vols: &[Volume]) -> Result<(), VolumeRejection> {
123    for v in vols {
124        validate_one_volume(v)?;
125    }
126    Ok(())
127}
128
129/// Validate the `Option<&Vec<Volume>>` that the resource builder passes
130/// around. Convenience wrapper so callers can skip the `if let Some` dance.
131///
132/// # Errors
133///
134/// Same as [`validate_user_volumes`].
135pub fn validate_optional_user_volumes(vols: Option<&Vec<Volume>>) -> Result<(), VolumeRejection> {
136    match vols {
137        Some(vs) => validate_user_volumes(vs),
138        None => Ok(()),
139    }
140}
141
142/// Validate a slice of user-supplied [`VolumeMount`] entries against the
143/// allow-list. Returns the first rejection encountered.
144///
145/// # Errors
146///
147/// Returns [`VolumeRejection`] for the first mount that fails any check.
148pub fn validate_user_volume_mounts(mounts: &[VolumeMount]) -> Result<(), VolumeRejection> {
149    for m in mounts {
150        validate_one_volume_mount(m)?;
151    }
152    Ok(())
153}
154
155/// Validate the `Option<&Vec<VolumeMount>>` that the resource builder passes
156/// around. Convenience wrapper.
157///
158/// # Errors
159///
160/// Same as [`validate_user_volume_mounts`].
161pub fn validate_optional_user_volume_mounts(
162    mounts: Option<&Vec<VolumeMount>>,
163) -> Result<(), VolumeRejection> {
164    match mounts {
165        Some(ms) => validate_user_volume_mounts(ms),
166        None => Ok(()),
167    }
168}
169
170// ----------------------------------------------------------------------
171// internals
172// ----------------------------------------------------------------------
173
174fn validate_one_volume(v: &Volume) -> Result<(), VolumeRejection> {
175    let name = v.name.clone();
176
177    // Reject every source kind that isn't on our allow-list. This is an
178    // explicit allow-list — anything not matched falls through to
179    // ForbiddenSource at the bottom.
180    if v.host_path.is_some() {
181        return forbid(name, "hostPath");
182    }
183    if v.csi.is_some() {
184        return forbid(name, "csi");
185    }
186    if v.flex_volume.is_some() {
187        return forbid(name, "flexVolume");
188    }
189    if v.nfs.is_some() {
190        return forbid(name, "nfs");
191    }
192    if v.iscsi.is_some() {
193        return forbid(name, "iscsi");
194    }
195    if v.rbd.is_some() {
196        return forbid(name, "rbd");
197    }
198    if v.cephfs.is_some() {
199        return forbid(name, "cephfs");
200    }
201    if v.glusterfs.is_some() {
202        return forbid(name, "glusterfs");
203    }
204    if v.azure_file.is_some() {
205        return forbid(name, "azureFile");
206    }
207    if v.azure_disk.is_some() {
208        return forbid(name, "azureDisk");
209    }
210    if v.gce_persistent_disk.is_some() {
211        return forbid(name, "gcePersistentDisk");
212    }
213    if v.aws_elastic_block_store.is_some() {
214        return forbid(name, "awsElasticBlockStore");
215    }
216    if v.cinder.is_some() {
217        return forbid(name, "cinder");
218    }
219    if v.fc.is_some() {
220        return forbid(name, "fc");
221    }
222    if v.flocker.is_some() {
223        return forbid(name, "flocker");
224    }
225    if v.photon_persistent_disk.is_some() {
226        return forbid(name, "photonPersistentDisk");
227    }
228    if v.portworx_volume.is_some() {
229        return forbid(name, "portworxVolume");
230    }
231    if v.quobyte.is_some() {
232        return forbid(name, "quobyte");
233    }
234    if v.scale_io.is_some() {
235        return forbid(name, "scaleIO");
236    }
237    if v.storageos.is_some() {
238        return forbid(name, "storageos");
239    }
240    if v.vsphere_volume.is_some() {
241        return forbid(name, "vsphereVolume");
242    }
243    if v.projected.is_some() {
244        return forbid(name, "projected");
245    }
246    if v.ephemeral.is_some() {
247        return forbid(name, "ephemeral");
248    }
249    if v.git_repo.is_some() {
250        return forbid(name, "gitRepo");
251    }
252    if v.downward_api.is_some() {
253        return forbid(name, "downwardAPI");
254    }
255
256    // Allow-listed sources, with name-prefix checks where applicable.
257    if v.empty_dir.is_some() {
258        return Ok(());
259    }
260    if let Some(ref s) = v.secret {
261        let secret = s.secret_name.clone().unwrap_or_default();
262        if secret.starts_with(ALLOWED_USER_SECRET_PREFIX) && !secret.is_empty() {
263            return Ok(());
264        }
265        return Err(VolumeRejection::SecretNamePrefix { name, secret });
266    }
267    if let Some(ref cm) = v.config_map {
268        let config_map = cm.name.clone();
269        if config_map.starts_with(ALLOWED_USER_CONFIGMAP_PREFIX) {
270            return Ok(());
271        }
272        return Err(VolumeRejection::ConfigMapNamePrefix { name, config_map });
273    }
274    if let Some(ref pvc) = v.persistent_volume_claim {
275        let pvc_name = pvc.claim_name.clone();
276        if pvc_name.starts_with(ALLOWED_USER_PVC_PREFIX) {
277            return Ok(());
278        }
279        return Err(VolumeRejection::PvcNamePrefix {
280            name,
281            pvc: pvc_name,
282        });
283    }
284
285    // No recognised source — reject by default. This catches both empty
286    // Volumes and any future variant Kubernetes adds.
287    forbid(name, "unknown/none")
288}
289
290fn forbid(name: String, kind: &'static str) -> Result<(), VolumeRejection> {
291    Err(VolumeRejection::ForbiddenSource { name, kind })
292}
293
294fn validate_one_volume_mount(m: &VolumeMount) -> Result<(), VolumeRejection> {
295    // Reject path traversal FIRST: a `..` segment lets an attacker satisfy the
296    // prefix allow-list (e.g. `/data/../etc/bind/named.conf` starts with
297    // `/data/`) while the kubelet resolves the mount onto an operator-owned
298    // path. This guard must run before the prefix check, which would otherwise
299    // accept the escaping path. Closes audit finding C1.
300    if m.mount_path.contains("..") {
301        return Err(VolumeRejection::MountPathTraversal {
302            path: m.mount_path.clone(),
303        });
304    }
305
306    if !ALLOWED_USER_MOUNT_PREFIXES
307        .iter()
308        .any(|p| m.mount_path.starts_with(p))
309    {
310        return Err(VolumeRejection::MountPathOutsideAllowList {
311            path: m.mount_path.clone(),
312        });
313    }
314
315    if let Some(ref sub) = m.sub_path {
316        if sub.contains("..") {
317            return Err(VolumeRejection::SubPathTraversal {
318                field: "subPath",
319                value: sub.clone(),
320            });
321        }
322    }
323    if let Some(ref sub_expr) = m.sub_path_expr {
324        if sub_expr.contains("..") {
325            return Err(VolumeRejection::SubPathTraversal {
326                field: "subPathExpr",
327                value: sub_expr.clone(),
328            });
329        }
330    }
331    Ok(())
332}
333
334#[cfg(test)]
335#[path = "safe_volume_tests.rs"]
336mod safe_volume_tests;