Skip to content

Commit 53945ea

Browse files
authored
feat: Add format-specific annotations to override secret file names (#572)
* feat: Add format-specific annotations to override secret file names * docs: Add annotations to volume page * test: Add custom secret name tests * feat: Add check against path traversal * refactor: Flatten struct, use moved value * refactor: Flatten compat struct, use moved value * test: Add unit test for selector deserialization * refactor: Adjust path traversal checks Path::canonicalize will return an error if the path does not exist. The path we are checking obviously doesn't exist yet, because we want to prevent path traversals and the file at that path will only exist after we are done with the check. So using canonicalize does not work in this use-case. * test: Add custom CA name * chore: Use quoted paths in error messages * refactor: Ensure all components of a path a normal * chore: Add changelog entry
1 parent 9591acd commit 53945ea

File tree

9 files changed

+309
-45
lines changed

9 files changed

+309
-45
lines changed

CHANGELOG.md

+12
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- Add format-specific annotations to override secret file names ([#572]). The following new
10+
annotations are available:
11+
- `secrets.stackable.tech/format.tls-pkcs12.keystore-name`
12+
- `secrets.stackable.tech/format.tls-pkcs12.truststore-name`
13+
- `secrets.stackable.tech/format.tls-pem.cert-name`
14+
- `secrets.stackable.tech/format.tls-pem.key-name`
15+
- `secrets.stackable.tech/format.tls-pem.ca-name`
16+
17+
[#572]: https://github.com/stackabletech/secret-operator/pull/572
18+
719
## [25.3.0] - 2025-03-21
820

921
### Removed

docs/modules/secret-operator/pages/volume.adoc

+55
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,61 @@ The xref:secretclass.adoc#format[format] that the secret should be written as.
5050

5151
This can be either the default output format of the xref:secretclass.adoc#backend[backend], or a format that it defines a conversion into.
5252

53+
=== `secrets.stackable.tech/format.tls-pkcs12.keystore-name`
54+
55+
*Required*: false
56+
57+
*Default value*: `keystore.p12`
58+
59+
*Backends*: xref:secretclass.adoc#backend-autotls[]
60+
61+
An alternative name for the keystore file.
62+
Has no effect if the `format` is not `tls-pkcs12`.
63+
64+
=== `secrets.stackable.tech/format.tls-pkcs12.truststore-name`
65+
66+
*Required*: false
67+
68+
*Default value*: `truststore.p12`
69+
70+
*Backends*: xref:secretclass.adoc#backend-autotls[]
71+
72+
An alternative name for the truststore file.
73+
Has no effect if the `format` is not `tls-pkcs12`.
74+
75+
=== `secrets.stackable.tech/format.tls-pem.cert-name`
76+
77+
*Required*: false
78+
79+
*Default value*: `tls.crt`
80+
81+
*Backends*: xref:secretclass.adoc#backend-autotls[]
82+
83+
An alternative name for TLS PEM certificate.
84+
Has no effect if the `format` is not `tls-pem`.
85+
86+
=== `secrets.stackable.tech/format.tls-pem.key-name`
87+
88+
*Required*: false
89+
90+
*Default value*: `tls.key`
91+
92+
*Backends*: xref:secretclass.adoc#backend-autotls[]
93+
94+
An alternative name for TLS PEM certificate key.
95+
Has no effect if the `format` is not `tls-pem`.
96+
97+
=== `secrets.stackable.tech/format.tls-pem.ca-name`
98+
99+
*Required*: false
100+
101+
*Default value*: `ca.crt`
102+
103+
*Backends*: xref:secretclass.adoc#backend-autotls[]
104+
105+
An alternative name for TLS PEM certificate authority.
106+
Has no effect if the `format` is not `tls-pem`.
107+
53108
=== `secrets.stackable.tech/backend.autotls.cert.lifetime`
54109

55110
*Required*: false

rust/operator-binary/src/backend/mod.rs

+63-11
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ use stackable_operator::{
2525
pub use tls::TlsGenerate;
2626

2727
use self::pod_info::SchedulingPodInfo;
28-
use crate::format::{SecretData, SecretFormat};
28+
use crate::format::{
29+
well_known::{CompatibilityOptions, NamingOptions},
30+
SecretData, SecretFormat,
31+
};
2932

3033
/// Configuration provided by the `Volume` selecting what secret data should be provided
3134
///
@@ -85,16 +88,13 @@ pub struct SecretVolumeSelector {
8588
)]
8689
pub kerberos_service_names: Vec<String>,
8790

88-
/// The password used to encrypt the TLS PKCS#12 keystore
89-
///
90-
/// Required for some applications that misbehave with blank keystore passwords (such as Hadoop).
91-
/// Has no effect if `format` is not `tls-pkcs12`.
92-
#[serde(
93-
rename = "secrets.stackable.tech/format.compatibility.tls-pkcs12.password",
94-
deserialize_with = "SecretVolumeSelector::deserialize_some",
95-
default
96-
)]
97-
pub compat_tls_pkcs12_password: Option<String>,
91+
/// Compatibility options used by (legacy) applications.
92+
#[serde(flatten)]
93+
pub compat: CompatibilityOptions,
94+
95+
/// The (custom) filenames used by secrets.
96+
#[serde(flatten)]
97+
pub names: NamingOptions,
9898

9999
/// The TLS cert lifetime (when using the [`tls`] backend).
100100
/// The format is documented in <https://docs.stackable.tech/home/nightly/concepts/duration>.
@@ -297,3 +297,55 @@ impl SecretBackendError for Infallible {
297297
match *self {}
298298
}
299299
}
300+
301+
#[cfg(test)]
302+
mod tests {
303+
use std::collections::HashMap;
304+
305+
use serde::de::{value::MapDeserializer, IntoDeserializer};
306+
307+
use super::*;
308+
309+
fn required_fields_map() -> HashMap<String, String> {
310+
HashMap::from([
311+
(
312+
"secrets.stackable.tech/class".to_owned(),
313+
"my-class".to_owned(),
314+
),
315+
(
316+
"csi.storage.k8s.io/pod.name".to_owned(),
317+
"my-pod".to_owned(),
318+
),
319+
(
320+
"csi.storage.k8s.io/pod.namespace".to_owned(),
321+
"my-namespace".to_owned(),
322+
),
323+
])
324+
}
325+
326+
#[test]
327+
fn deserialize_selector() {
328+
let map = required_fields_map();
329+
330+
let _ =
331+
SecretVolumeSelector::deserialize::<MapDeserializer<'_, _, serde::de::value::Error>>(
332+
map.into_deserializer(),
333+
)
334+
.unwrap();
335+
}
336+
337+
#[test]
338+
fn deserialize_selector_compat() {
339+
let mut map = required_fields_map();
340+
map.insert(
341+
"secrets.stackable.tech/format.compatibility.tls-pkcs12.password".to_owned(),
342+
"supersecret".to_owned(),
343+
);
344+
345+
let _ =
346+
SecretVolumeSelector::deserialize::<MapDeserializer<'_, _, serde::de::value::Error>>(
347+
map.into_deserializer(),
348+
)
349+
.unwrap();
350+
}
351+
}

rust/operator-binary/src/csi_server/node.rs

+57-15
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
use std::{
22
fs::Permissions,
33
os::unix::prelude::PermissionsExt,
4-
path::{Path, PathBuf},
4+
path::{Component, Path, PathBuf},
55
};
66

77
use openssl::sha::Sha256;
88
use serde::{de::IntoDeserializer, Deserialize};
9-
use snafu::{ResultExt, Snafu};
9+
use snafu::{ensure, ResultExt, Snafu};
1010
use stackable_operator::{
1111
builder::meta::ObjectMetaBuilder,
1212
k8s_openapi::api::core::v1::Pod,
@@ -23,9 +23,15 @@ use tonic::{Request, Response, Status};
2323
use super::controller::TOPOLOGY_NODE;
2424
use crate::{
2525
backend::{
26-
self, pod_info, pod_info::PodInfo, SecretBackendError, SecretContents, SecretVolumeSelector,
26+
self,
27+
pod_info::{self, PodInfo},
28+
SecretBackendError, SecretContents, SecretVolumeSelector,
29+
},
30+
format::{
31+
self,
32+
well_known::{CompatibilityOptions, NamingOptions},
33+
SecretFormat,
2734
},
28-
format::{self, well_known::CompatibilityOptions, SecretFormat},
2935
grpc::csi::v1::{
3036
node_server::Node, NodeExpandVolumeRequest, NodeExpandVolumeResponse,
3137
NodeGetCapabilitiesRequest, NodeGetCapabilitiesResponse, NodeGetInfoRequest,
@@ -59,13 +65,13 @@ enum PublishError {
5965
#[snafu(display("backend failed to get secret data"))]
6066
BackendGetSecretData { source: backend::dynamic::DynError },
6167

62-
#[snafu(display("failed to create secret parent dir {}", path.display()))]
68+
#[snafu(display("failed to create secret parent dir {path:?}"))]
6369
CreateDir {
6470
source: std::io::Error,
6571
path: PathBuf,
6672
},
6773

68-
#[snafu(display("failed to mount volume mount directory {}", path.display()))]
74+
#[snafu(display("failed to mount volume mount directory {path:?}"))]
6975
Mount {
7076
source: std::io::Error,
7177
path: PathBuf,
@@ -74,24 +80,30 @@ enum PublishError {
7480
#[snafu(display("failed to convert secret data into desired format"))]
7581
FormatData { source: format::IntoFilesError },
7682

77-
#[snafu(display("failed to set volume permissions for {}", path.display()))]
83+
#[snafu(display("failed to set volume permissions for {path:?}"))]
7884
SetDirPermissions {
7985
source: std::io::Error,
8086
path: PathBuf,
8187
},
8288

83-
#[snafu(display("failed to create secret file {}", path.display()))]
89+
#[snafu(display("failed to create secret file {path:?}"))]
8490
CreateFile {
8591
source: std::io::Error,
8692
path: PathBuf,
8793
},
8894

89-
#[snafu(display("failed to write secret file {}", path.display()))]
95+
#[snafu(display("failed to write secret file {path:?}"))]
9096
WriteFile {
9197
source: std::io::Error,
9298
path: PathBuf,
9399
},
94100

101+
#[snafu(display("file path {path:?} must only contain normal components"))]
102+
InvalidComponents { path: PathBuf },
103+
104+
#[snafu(display("file path {path:?} must not be absolute"))]
105+
InvalidAbsolutePath { path: PathBuf },
106+
95107
#[snafu(display("failed to tag pod with expiry metadata"))]
96108
TagPod {
97109
source: stackable_operator::client::Error,
@@ -120,6 +132,8 @@ impl From<PublishError> for Status {
120132
PublishError::SetDirPermissions { .. } => Status::unavailable(full_msg),
121133
PublishError::CreateFile { .. } => Status::unavailable(full_msg),
122134
PublishError::WriteFile { .. } => Status::unavailable(full_msg),
135+
PublishError::InvalidComponents { .. } => Status::unavailable(full_msg),
136+
PublishError::InvalidAbsolutePath { .. } => Status::unavailable(full_msg),
123137
PublishError::TagPod { .. } => Status::unavailable(full_msg),
124138
PublishError::BuildAnnotation { .. } => Status::unavailable(full_msg),
125139
}
@@ -209,7 +223,8 @@ impl SecretProvisionerNode {
209223
target_path: &Path,
210224
data: SecretContents,
211225
format: Option<SecretFormat>,
212-
compat: &CompatibilityOptions,
226+
names: NamingOptions,
227+
compat: CompatibilityOptions,
213228
) -> Result<(), PublishError> {
214229
let create_secret = {
215230
let mut opts = OpenOptions::new();
@@ -223,10 +238,37 @@ impl SecretProvisionerNode {
223238
};
224239
for (k, v) in data
225240
.data
226-
.into_files(format, compat)
241+
.into_files(format, names, compat)
227242
.context(publish_error::FormatDataSnafu)?
228243
{
229-
let item_path = target_path.join(k);
244+
// The following few lines of code do some basic checks against
245+
// unwanted path traversals. In the future, we want to leverage
246+
// capability based filesystem operations (openat) to prevent these
247+
// traversals.
248+
249+
// First, let's turn the (potentially custom) file path into a path.
250+
let file_path = PathBuf::from(k);
251+
252+
// Next, ensure the path is not absolute (does not contain root),
253+
// because joining an absolute path with a different path will
254+
// replace the exiting path entirely.
255+
ensure!(
256+
!file_path.has_root(),
257+
publish_error::InvalidAbsolutePathSnafu { path: &file_path }
258+
);
259+
260+
// Ensure that the file path only contains normal components. This
261+
// prevents any path traversals up the path using '..'.
262+
ensure!(
263+
file_path
264+
.components()
265+
.all(|c| matches!(c, Component::Normal(_))),
266+
publish_error::InvalidComponentsSnafu { path: &file_path }
267+
);
268+
269+
// Now, we can join the base and file path
270+
let item_path = target_path.join(file_path);
271+
230272
if let Some(item_path_parent) = item_path.parent() {
231273
create_dir_all(item_path_parent)
232274
.await
@@ -383,10 +425,10 @@ impl Node for SecretProvisionerNode {
383425
self.save_secret_data(
384426
&target_path,
385427
data,
428+
// NOTE (@Techassi): At this point, we might want to pass the whole selector instead
386429
selector.format,
387-
&CompatibilityOptions {
388-
tls_pkcs12_password: selector.compat_tls_pkcs12_password,
389-
},
430+
selector.names,
431+
selector.compat,
390432
)
391433
.await?;
392434
Ok(Response::new(NodePublishVolumeResponse {}))

rust/operator-binary/src/format/convert.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use crate::format::utils::split_pem_certificates;
1616
pub fn convert(
1717
from: WellKnownSecretData,
1818
to: SecretFormat,
19-
compat: &CompatibilityOptions,
19+
compat: CompatibilityOptions,
2020
) -> Result<WellKnownSecretData, ConvertError> {
2121
match (from, to) {
2222
// Converting into the current format is always a no-op

rust/operator-binary/src/format/mod.rs

+5-3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub use self::{
77
convert::ConvertError,
88
well_known::{FromFilesError as ParseError, SecretFormat, WellKnownSecretData},
99
};
10+
use crate::format::well_known::NamingOptions;
1011

1112
mod convert;
1213
mod utils;
@@ -30,13 +31,14 @@ impl SecretData {
3031
pub fn into_files(
3132
self,
3233
format: Option<SecretFormat>,
33-
compat: &CompatibilityOptions,
34+
names: NamingOptions,
35+
compat: CompatibilityOptions,
3436
) -> Result<SecretFiles, IntoFilesError> {
3537
if let Some(format) = format {
36-
Ok(self.parse()?.convert_to(format, compat)?.into_files())
38+
Ok(self.parse()?.convert_to(format, compat)?.into_files(names))
3739
} else {
3840
Ok(match self {
39-
SecretData::WellKnown(data) => data.into_files(),
41+
SecretData::WellKnown(data) => data.into_files(names),
4042
SecretData::Unknown(files) => files,
4143
})
4244
}

0 commit comments

Comments
 (0)