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`].
30//! - **VolumeMount.subPath / subPathExpr:** must not contain `..`.
31//!
32//! Everything else is rejected. This is an allow-list, not a block-list, so
33//! future Volume variants added by Kubernetes are rejected by default.
34
35use crate::constants::{
36    ALLOWED_USER_CONFIGMAP_PREFIX, ALLOWED_USER_MOUNT_PREFIXES, ALLOWED_USER_PVC_PREFIX,
37    ALLOWED_USER_SECRET_PREFIX,
38};
39use k8s_openapi::api::core::v1::{Volume, VolumeMount};
40use thiserror::Error;
41
42/// Rejection reasons returned by [`validate_user_volumes`] and
43/// [`validate_user_volume_mounts`].
44///
45/// Each variant carries enough context for the reconciler to render a clear
46/// status condition on the offending CR.
47#[derive(Debug, Error, PartialEq, Eq)]
48pub enum VolumeRejection {
49    #[error(
50        "volume {name:?} uses forbidden source kind {kind}: only emptyDir, configMap, secret \
51         (with name prefix {ALLOWED_USER_SECRET_PREFIX:?}), or persistentVolumeClaim (with name \
52         prefix {ALLOWED_USER_PVC_PREFIX:?}) are permitted"
53    )]
54    ForbiddenSource { name: String, kind: &'static str },
55
56    #[error(
57        "volume {name:?} secret reference {secret:?} does not start with the required prefix \
58         {ALLOWED_USER_SECRET_PREFIX:?}"
59    )]
60    SecretNamePrefix { name: String, secret: String },
61
62    #[error(
63        "volume {name:?} configMap reference {config_map:?} does not start with the required \
64         prefix {ALLOWED_USER_CONFIGMAP_PREFIX:?}"
65    )]
66    ConfigMapNamePrefix { name: String, config_map: String },
67
68    #[error(
69        "volume {name:?} persistentVolumeClaim reference {pvc:?} does not start with the \
70         required prefix {ALLOWED_USER_PVC_PREFIX:?}"
71    )]
72    PvcNamePrefix { name: String, pvc: String },
73
74    #[error(
75        "volumeMount mountPath {path:?} is outside the allowed prefixes \
76         {ALLOWED_USER_MOUNT_PREFIXES:?}"
77    )]
78    MountPathOutsideAllowList { path: String },
79
80    #[error("volumeMount {field} {value:?} contains '..' (path traversal not permitted)")]
81    SubPathTraversal { field: &'static str, value: String },
82}
83
84/// Validate a slice of user-supplied [`Volume`] entries against the
85/// allow-list. Returns the first rejection encountered.
86///
87/// # Errors
88///
89/// Returns [`VolumeRejection`] for the first volume that fails any check.
90pub fn validate_user_volumes(vols: &[Volume]) -> Result<(), VolumeRejection> {
91    for v in vols {
92        validate_one_volume(v)?;
93    }
94    Ok(())
95}
96
97/// Validate the `Option<&Vec<Volume>>` that the resource builder passes
98/// around. Convenience wrapper so callers can skip the `if let Some` dance.
99///
100/// # Errors
101///
102/// Same as [`validate_user_volumes`].
103pub fn validate_optional_user_volumes(vols: Option<&Vec<Volume>>) -> Result<(), VolumeRejection> {
104    match vols {
105        Some(vs) => validate_user_volumes(vs),
106        None => Ok(()),
107    }
108}
109
110/// Validate a slice of user-supplied [`VolumeMount`] entries against the
111/// allow-list. Returns the first rejection encountered.
112///
113/// # Errors
114///
115/// Returns [`VolumeRejection`] for the first mount that fails any check.
116pub fn validate_user_volume_mounts(mounts: &[VolumeMount]) -> Result<(), VolumeRejection> {
117    for m in mounts {
118        validate_one_volume_mount(m)?;
119    }
120    Ok(())
121}
122
123/// Validate the `Option<&Vec<VolumeMount>>` that the resource builder passes
124/// around. Convenience wrapper.
125///
126/// # Errors
127///
128/// Same as [`validate_user_volume_mounts`].
129pub fn validate_optional_user_volume_mounts(
130    mounts: Option<&Vec<VolumeMount>>,
131) -> Result<(), VolumeRejection> {
132    match mounts {
133        Some(ms) => validate_user_volume_mounts(ms),
134        None => Ok(()),
135    }
136}
137
138// ----------------------------------------------------------------------
139// internals
140// ----------------------------------------------------------------------
141
142fn validate_one_volume(v: &Volume) -> Result<(), VolumeRejection> {
143    let name = v.name.clone();
144
145    // Reject every source kind that isn't on our allow-list. This is an
146    // explicit allow-list — anything not matched falls through to
147    // ForbiddenSource at the bottom.
148    if v.host_path.is_some() {
149        return forbid(name, "hostPath");
150    }
151    if v.csi.is_some() {
152        return forbid(name, "csi");
153    }
154    if v.flex_volume.is_some() {
155        return forbid(name, "flexVolume");
156    }
157    if v.nfs.is_some() {
158        return forbid(name, "nfs");
159    }
160    if v.iscsi.is_some() {
161        return forbid(name, "iscsi");
162    }
163    if v.rbd.is_some() {
164        return forbid(name, "rbd");
165    }
166    if v.cephfs.is_some() {
167        return forbid(name, "cephfs");
168    }
169    if v.glusterfs.is_some() {
170        return forbid(name, "glusterfs");
171    }
172    if v.azure_file.is_some() {
173        return forbid(name, "azureFile");
174    }
175    if v.azure_disk.is_some() {
176        return forbid(name, "azureDisk");
177    }
178    if v.gce_persistent_disk.is_some() {
179        return forbid(name, "gcePersistentDisk");
180    }
181    if v.aws_elastic_block_store.is_some() {
182        return forbid(name, "awsElasticBlockStore");
183    }
184    if v.cinder.is_some() {
185        return forbid(name, "cinder");
186    }
187    if v.fc.is_some() {
188        return forbid(name, "fc");
189    }
190    if v.flocker.is_some() {
191        return forbid(name, "flocker");
192    }
193    if v.photon_persistent_disk.is_some() {
194        return forbid(name, "photonPersistentDisk");
195    }
196    if v.portworx_volume.is_some() {
197        return forbid(name, "portworxVolume");
198    }
199    if v.quobyte.is_some() {
200        return forbid(name, "quobyte");
201    }
202    if v.scale_io.is_some() {
203        return forbid(name, "scaleIO");
204    }
205    if v.storageos.is_some() {
206        return forbid(name, "storageos");
207    }
208    if v.vsphere_volume.is_some() {
209        return forbid(name, "vsphereVolume");
210    }
211    if v.projected.is_some() {
212        return forbid(name, "projected");
213    }
214    if v.ephemeral.is_some() {
215        return forbid(name, "ephemeral");
216    }
217    if v.git_repo.is_some() {
218        return forbid(name, "gitRepo");
219    }
220    if v.downward_api.is_some() {
221        return forbid(name, "downwardAPI");
222    }
223
224    // Allow-listed sources, with name-prefix checks where applicable.
225    if v.empty_dir.is_some() {
226        return Ok(());
227    }
228    if let Some(ref s) = v.secret {
229        let secret = s.secret_name.clone().unwrap_or_default();
230        if secret.starts_with(ALLOWED_USER_SECRET_PREFIX) && !secret.is_empty() {
231            return Ok(());
232        }
233        return Err(VolumeRejection::SecretNamePrefix { name, secret });
234    }
235    if let Some(ref cm) = v.config_map {
236        let config_map = cm.name.clone();
237        if config_map.starts_with(ALLOWED_USER_CONFIGMAP_PREFIX) {
238            return Ok(());
239        }
240        return Err(VolumeRejection::ConfigMapNamePrefix { name, config_map });
241    }
242    if let Some(ref pvc) = v.persistent_volume_claim {
243        let pvc_name = pvc.claim_name.clone();
244        if pvc_name.starts_with(ALLOWED_USER_PVC_PREFIX) {
245            return Ok(());
246        }
247        return Err(VolumeRejection::PvcNamePrefix {
248            name,
249            pvc: pvc_name,
250        });
251    }
252
253    // No recognised source — reject by default. This catches both empty
254    // Volumes and any future variant Kubernetes adds.
255    forbid(name, "unknown/none")
256}
257
258fn forbid(name: String, kind: &'static str) -> Result<(), VolumeRejection> {
259    Err(VolumeRejection::ForbiddenSource { name, kind })
260}
261
262fn validate_one_volume_mount(m: &VolumeMount) -> Result<(), VolumeRejection> {
263    if !ALLOWED_USER_MOUNT_PREFIXES
264        .iter()
265        .any(|p| m.mount_path.starts_with(p))
266    {
267        return Err(VolumeRejection::MountPathOutsideAllowList {
268            path: m.mount_path.clone(),
269        });
270    }
271
272    if let Some(ref sub) = m.sub_path {
273        if sub.contains("..") {
274            return Err(VolumeRejection::SubPathTraversal {
275                field: "subPath",
276                value: sub.clone(),
277            });
278        }
279    }
280    if let Some(ref sub_expr) = m.sub_path_expr {
281        if sub_expr.contains("..") {
282            return Err(VolumeRejection::SubPathTraversal {
283                field: "subPathExpr",
284                value: sub_expr.clone(),
285            });
286        }
287    }
288    Ok(())
289}
290
291#[cfg(test)]
292#[path = "safe_volume_tests.rs"]
293mod safe_volume_tests;