Skip to content

Commit 1c05735

Browse files
fix: Don't allow uppercase characters in Kubernetes object names (#1095)
* fix: separate between official and kubernetes-native RFC 1123 definition * add changelog entry * subdomain format also kubernetes-specific * adjust variable names and add docs, fix changelog * rename functions * fix links in docs * make const public * Update crates/stackable-operator/src/validation.rs Co-authored-by: Siegfried Weber <[email protected]> * Update crates/stackable-operator/src/validation.rs Co-authored-by: Siegfried Weber <[email protected]> * clarify comment --------- Co-authored-by: Siegfried Weber <[email protected]>
1 parent 9b8ea36 commit 1c05735

File tree

3 files changed

+101
-81
lines changed

3 files changed

+101
-81
lines changed

crates/stackable-operator/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,13 @@ All notable changes to this project will be documented in this file.
1212

1313
- BREAKING: Upgrade to `schemars` 1.0, `kube` 2.0 and `k8s-openapi` 0.26 (using Kubernetes 1.34) ([#1091]).
1414

15+
### Fixed
16+
17+
- BREAKING: Don't allow uppercase characters in Kubernetes object names ([#1095]).
18+
1519
[#1091]: https://github.com/stackabletech/operator-rs/pull/1091
1620
[#1094]: https://github.com/stackabletech/operator-rs/pull/1094
21+
[#1095]: https://github.com/stackabletech/operator-rs/pull/1095
1722

1823
## [0.97.0] - 2025-09-09
1924

crates/stackable-operator/src/builder/pod/container.rs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use {k8s_openapi::api::core::v1::PodSpec, std::collections::BTreeMap};
1212

1313
use crate::{
1414
commons::product_image_selection::ResolvedProductImage,
15-
validation::{self, is_rfc_1123_label},
15+
validation::{self, is_lowercase_rfc_1123_label},
1616
};
1717

1818
type Result<T, E = Error> = std::result::Result<T, E>;
@@ -351,10 +351,11 @@ impl ContainerBuilder {
351351
}
352352
}
353353

354-
/// Validates a container name is according to the [RFC 1123](https://www.ietf.org/rfc/rfc1123.txt) standard.
354+
/// Validates a container name is according to the kubernetes-specific [RFC 1123](https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names) standard.
355355
/// Returns [Ok] if the name is according to the standard, and [Err] if not.
356356
fn validate_container_name(container_name: &str) -> Result<()> {
357-
is_rfc_1123_label(container_name).context(InvalidContainerNameSnafu { container_name })
357+
is_lowercase_rfc_1123_label(container_name)
358+
.context(InvalidContainerNameSnafu { container_name })
358359
}
359360
}
360361

@@ -441,7 +442,7 @@ mod tests {
441442
resources::ResourceRequirementsBuilder,
442443
},
443444
commons::resources::ResourceRequirementsType,
444-
validation::RFC_1123_LABEL_FMT,
445+
validation::LOWERCASE_RFC_1123_LABEL_FMT,
445446
};
446447

447448
#[test]
@@ -604,11 +605,11 @@ mod tests {
604605
assert!(ContainerBuilder::new("name-with-hyphen").is_ok());
605606
assert_container_builder_err(
606607
ContainerBuilder::new("ends-with-hyphen-"),
607-
&format!(r#"regex used for validation is "{RFC_1123_LABEL_FMT}""#),
608+
&format!(r#"regex used for validation is "{LOWERCASE_RFC_1123_LABEL_FMT}""#),
608609
);
609610
assert_container_builder_err(
610611
ContainerBuilder::new("-starts-with-hyphen"),
611-
&format!(r#"regex used for validation is "{RFC_1123_LABEL_FMT}""#),
612+
&format!(r#"regex used for validation is "{LOWERCASE_RFC_1123_LABEL_FMT}""#),
612613
);
613614
}
614615

@@ -623,7 +624,7 @@ mod tests {
623624
assert_container_builder_err(
624625
ContainerBuilder::new("name_name"),
625626
&format!(
626-
r#"(e.g. "example-label", or "1-label-1", regex used for validation is "{RFC_1123_LABEL_FMT}""#
627+
r#"(e.g. "example-label", or "1-label-1", regex used for validation is "{LOWERCASE_RFC_1123_LABEL_FMT}""#
627628
),
628629
);
629630
}

crates/stackable-operator/src/validation.rs

Lines changed: 88 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,38 @@ use regex::Regex;
1616
use snafu::Snafu;
1717

1818
/// Minimal length required by RFC 1123 is 63. Up to 255 allowed, unsupported by k8s.
19-
const RFC_1123_LABEL_MAX_LENGTH: usize = 63;
20-
pub const RFC_1123_LABEL_FMT: &str = "[a-zA-Z0-9]([-a-zA-Z0-9]*[a-zA-Z0-9])?";
21-
const RFC_1123_LABEL_ERROR_MSG: &str = "a RFC 1123 label must consist of alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character";
19+
pub const RFC_1123_LABEL_MAX_LENGTH: usize = 63;
20+
// This is a modified RFC 1123 format according to the Kubernetes specification, see https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names
21+
pub const LOWERCASE_RFC_1123_LABEL_FMT: &str = "[a-z0-9]([-a-z0-9]*[a-z0-9])?";
22+
const LOWERCASE_RFC_1123_LABEL_ERROR_MSG: &str = "a lowercase RFC 1123 label must consist of lower case alphanumeric characters or '-', and must start and end with an alphanumeric character";
2223

23-
/// This is a subdomain's max length in DNS (RFC 1123)
24-
const RFC_1123_SUBDOMAIN_MAX_LENGTH: usize = 253;
25-
const RFC_1123_SUBDOMAIN_FMT: &str =
26-
concatcp!(RFC_1123_LABEL_FMT, "(\\.", RFC_1123_LABEL_FMT, ")*");
24+
// This is a RFC 1123 format, see https://www.rfc-editor.org/rfc/rfc1123
25+
const RFC_1123_LABEL_FMT: &str = "[a-zA-Z0-9]([-a-zA-Z0-9]*[a-zA-Z0-9])?";
2726

28-
const DOMAIN_MAX_LENGTH: usize = RFC_1123_SUBDOMAIN_MAX_LENGTH;
29-
/// Same as [`RFC_1123_SUBDOMAIN_FMT`], but allows a trailing dot
30-
const DOMAIN_FMT: &str = concatcp!(RFC_1123_SUBDOMAIN_FMT, "\\.?");
27+
/// This is a subdomain's max length in DNS (RFC 1123)
28+
pub const RFC_1123_SUBDOMAIN_MAX_LENGTH: usize = 253;
29+
pub const LOWERCASE_RFC_1123_SUBDOMAIN_FMT: &str = concatcp!(
30+
LOWERCASE_RFC_1123_LABEL_FMT,
31+
"(\\.",
32+
LOWERCASE_RFC_1123_LABEL_FMT,
33+
")*"
34+
);
35+
const LOWERCASE_RFC_1123_SUBDOMAIN_ERROR_MSG: &str = "a lowercase RFC 1123 subdomain must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character";
36+
37+
pub const DOMAIN_MAX_LENGTH: usize = RFC_1123_SUBDOMAIN_MAX_LENGTH;
38+
39+
/// String of one or multiple [`RFC_1123_LABEL_FMT`] separated by dots but also allowing a trailing dot
40+
const DOMAIN_FMT: &str = concatcp!(RFC_1123_LABEL_FMT, "(\\.", RFC_1123_LABEL_FMT, ")*\\.?");
3141
const DOMAIN_ERROR_MSG: &str = "a domain must consist of alphanumeric characters, '-' or '.', and must start with an alphanumeric character and end with an alphanumeric character or '.'";
3242

3343
// FIXME: According to https://www.rfc-editor.org/rfc/rfc1035#section-2.3.1 domain names must start with a letter
3444
// (and not a number).
35-
const RFC_1035_LABEL_FMT: &str = "[a-z]([-a-z0-9]*[a-z0-9])?";
36-
const RFC_1035_LABEL_ERROR_MSG: &str = "a DNS-1035 label must consist of lower case alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character";
45+
// This is a modified RFC 1035 format according to the Kubernetes specification, see https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#rfc-1035-label-names
46+
pub const LOWERCASE_RFC_1035_LABEL_FMT: &str = "[a-z]([-a-z0-9]*[a-z0-9])?";
47+
const LOWERCASE_RFC_1035_LABEL_ERROR_MSG: &str = "a DNS-1035 label must consist of lower case alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character";
3748

3849
// This is a label's max length in DNS (RFC 1035)
39-
const RFC_1035_LABEL_MAX_LENGTH: usize = 63;
50+
pub const RFC_1035_LABEL_MAX_LENGTH: usize = 63;
4051

4152
// Technically Kerberos allows more realm names
4253
// (https://web.mit.edu/kerberos/krb5-1.21/doc/admin/realm_config.html#realm-name),
@@ -54,12 +65,19 @@ pub(crate) static DOMAIN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
5465
Regex::new(&format!("^{DOMAIN_FMT}$")).expect("failed to compile domain regex")
5566
});
5667

57-
static RFC_1123_LABEL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
58-
Regex::new(&format!("^{RFC_1123_LABEL_FMT}$")).expect("failed to compile RFC 1123 label regex")
68+
static LOWERCASE_RFC_1123_LABEL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
69+
Regex::new(&format!("^{LOWERCASE_RFC_1123_LABEL_FMT}$"))
70+
.expect("failed to compile RFC 1123 label regex")
71+
});
72+
73+
static LOWERCASE_RFC_1123_SUBDOMAIN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
74+
Regex::new(&format!("^{LOWERCASE_RFC_1123_SUBDOMAIN_FMT}$"))
75+
.expect("failed to compile RFC 1123 subdomain regex")
5976
});
6077

61-
static RFC_1035_LABEL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
62-
Regex::new(&format!("^{RFC_1035_LABEL_FMT}$")).expect("failed to compile RFC 1035 label regex")
78+
static LOWERCASE_RFC_1035_LABEL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
79+
Regex::new(&format!("^{LOWERCASE_RFC_1035_LABEL_FMT}$"))
80+
.expect("failed to compile RFC 1035 label regex")
6381
});
6482

6583
pub(crate) static KERBEROS_REALM_NAME_REGEX: LazyLock<Regex> = LazyLock::new(|| {
@@ -198,28 +216,44 @@ pub fn is_domain(value: &str) -> Result {
198216
])
199217
}
200218

201-
/// Tests for a string that conforms to the definition of a label in DNS (RFC 1123).
219+
/// Tests for a string that conforms to the kubernetes-specific definition of a label in DNS (RFC 1123)
220+
/// used in Namespace names, see: [Kubernetes Docs](https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names)
202221
/// Maximum label length supported by k8s is 63 characters (minimum required).
203-
pub fn is_rfc_1123_label(value: &str) -> Result {
222+
pub fn is_lowercase_rfc_1123_label(value: &str) -> Result {
204223
validate_all([
205224
validate_str_length(value, RFC_1123_LABEL_MAX_LENGTH),
206225
validate_str_regex(
207226
value,
208-
&RFC_1123_LABEL_REGEX,
209-
RFC_1123_LABEL_ERROR_MSG,
227+
&LOWERCASE_RFC_1123_LABEL_REGEX,
228+
LOWERCASE_RFC_1123_LABEL_ERROR_MSG,
210229
&["example-label", "1-label-1"],
211230
),
212231
])
213232
}
214233

215-
/// Tests for a string that conforms to the definition of a label in DNS (RFC 1035).
216-
pub fn is_rfc_1035_label(value: &str) -> Result {
234+
/// Tests for a string that conforms to the kubernetes-specific definition of a subdomain in DNS (RFC 1123)
235+
/// used in ConfigMap names, see [Kubernetes Docs](https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names)
236+
pub fn is_lowercase_rfc_1123_subdomain(value: &str) -> Result {
237+
validate_all([
238+
validate_str_length(value, RFC_1123_SUBDOMAIN_MAX_LENGTH),
239+
validate_str_regex(
240+
value,
241+
&LOWERCASE_RFC_1123_SUBDOMAIN_REGEX,
242+
LOWERCASE_RFC_1123_SUBDOMAIN_ERROR_MSG,
243+
&["example.com"],
244+
),
245+
])
246+
}
247+
248+
/// Tests for a string that conforms to the kubernetes-specific definition of a label in DNS (RFC 1035)
249+
/// used in Service names, see: [Kubernetes Docs](https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#rfc-1035-label-names)
250+
pub fn is_lowercase_rfc_1035_label(value: &str) -> Result {
217251
validate_all([
218252
validate_str_length(value, RFC_1035_LABEL_MAX_LENGTH),
219253
validate_str_regex(
220254
value,
221-
&RFC_1035_LABEL_REGEX,
222-
RFC_1035_LABEL_ERROR_MSG,
255+
&LOWERCASE_RFC_1035_LABEL_REGEX,
256+
LOWERCASE_RFC_1035_LABEL_ERROR_MSG,
223257
&["my-name", "abc-123"],
224258
),
225259
])
@@ -261,7 +295,7 @@ pub fn name_is_dns_label(name: &str, prefix: bool) -> Result {
261295
name = mask_trailing_dash(name);
262296
}
263297

264-
is_rfc_1035_label(&name)
298+
is_lowercase_rfc_1035_label(&name)
265299
}
266300

267301
/// Validates a namespace name.
@@ -277,28 +311,14 @@ mod tests {
277311

278312
use super::*;
279313

280-
const RFC_1123_SUBDOMAIN_ERROR_MSG: &str = "a RFC 1123 subdomain must consist of alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character";
281-
282-
static RFC_1123_SUBDOMAIN_REGEX: LazyLock<Regex> = LazyLock::new(|| {
283-
Regex::new(&format!("^{RFC_1123_SUBDOMAIN_FMT}$"))
284-
.expect("failed to compile RFC 1123 subdomain regex")
285-
});
286-
287-
/// Tests for a string that conforms to the definition of a subdomain in DNS (RFC 1123).
288-
fn is_rfc_1123_subdomain(value: &str) -> Result {
289-
validate_all([
290-
validate_str_length(value, RFC_1123_SUBDOMAIN_MAX_LENGTH),
291-
validate_str_regex(
292-
value,
293-
&RFC_1123_SUBDOMAIN_REGEX,
294-
RFC_1123_SUBDOMAIN_ERROR_MSG,
295-
&["example.com"],
296-
),
297-
])
298-
}
299-
300314
#[rstest]
301315
#[case("")]
316+
#[case("A")]
317+
#[case("aBc")]
318+
#[case("ABC")]
319+
#[case("A1")]
320+
#[case("A-1")]
321+
#[case("1-A")]
302322
#[case("-")]
303323
#[case("a-")]
304324
#[case("-a")]
@@ -325,6 +345,24 @@ mod tests {
325345
#[case("1 ")]
326346
#[case(" 1")]
327347
#[case("1 2")]
348+
#[case("A.a")]
349+
#[case("aB.a")]
350+
#[case("ab.A")]
351+
#[case("A1.a")]
352+
#[case("a1.A")]
353+
#[case("A.1")]
354+
#[case("aB.1")]
355+
#[case("A1.1")]
356+
#[case("0.A")]
357+
#[case("01.A")]
358+
#[case("012.A")]
359+
#[case("1A.a")]
360+
#[case("1a.A")]
361+
#[case("1A.1")]
362+
#[case("a.B.c.d.e")]
363+
#[case("A.B.C.D.E")]
364+
#[case("aa.bB.cc.dd.ee")]
365+
#[case("AA.BB.CC.DD.EE")]
328366
#[case("a@b")]
329367
#[case("a,b")]
330368
#[case("a_b")]
@@ -335,77 +373,53 @@ mod tests {
335373
#[case("a$b")]
336374
#[case(&"a".repeat(254))]
337375
fn is_rfc_1123_subdomain_fail(#[case] value: &str) {
338-
assert!(is_rfc_1123_subdomain(value).is_err());
376+
assert!(is_lowercase_rfc_1123_subdomain(value).is_err());
339377
}
340378

341379
#[rstest]
342380
#[case("a")]
343-
#[case("A")]
344381
#[case("ab")]
345382
#[case("abc")]
346-
#[case("aBc")]
347-
#[case("ABC")]
348383
#[case("a1")]
349-
#[case("A1")]
350384
#[case("a-1")]
351-
#[case("A-1")]
352385
#[case("a--1--2--b")]
353386
#[case("0")]
354387
#[case("01")]
355388
#[case("012")]
356389
#[case("1a")]
357390
#[case("1-a")]
358-
#[case("1-A")]
359391
#[case("1--a--b--2")]
360392
#[case("a.a")]
361-
#[case("A.a")]
362393
#[case("ab.a")]
363-
#[case("aB.a")]
364-
#[case("ab.A")]
365394
#[case("abc.a")]
366395
#[case("a1.a")]
367-
#[case("A1.a")]
368-
#[case("a1.A")]
369396
#[case("a-1.a")]
370397
#[case("a--1--2--b.a")]
371398
#[case("a.1")]
372-
#[case("A.1")]
373399
#[case("ab.1")]
374-
#[case("aB.1")]
375400
#[case("abc.1")]
376401
#[case("a1.1")]
377-
#[case("A1.1")]
378402
#[case("a-1.1")]
379403
#[case("a--1--2--b.1")]
380404
#[case("0.a")]
381-
#[case("0.A")]
382405
#[case("01.a")]
383-
#[case("01.A")]
384406
#[case("012.a")]
385-
#[case("012.A")]
386407
#[case("1a.a")]
387-
#[case("1A.a")]
388-
#[case("1a.A")]
389408
#[case("1-a.a")]
390409
#[case("1--a--b--2")]
391410
#[case("0.1")]
392411
#[case("01.1")]
393412
#[case("012.1")]
394413
#[case("1a.1")]
395-
#[case("1A.1")]
396414
#[case("1-a.1")]
397415
#[case("1--a--b--2.1")]
398416
#[case("a.b.c.d.e")]
399-
#[case("a.B.c.d.e")]
400-
#[case("A.B.C.D.E")]
401417
#[case("aa.bb.cc.dd.ee")]
402-
#[case("aa.bB.cc.dd.ee")]
403-
#[case("AA.BB.CC.DD.EE")]
404418
#[case("1.2.3.4.5")]
405419
#[case("11.22.33.44.55")]
406420
#[case(&"a".repeat(253))]
407421
fn is_rfc_1123_subdomain_pass(#[case] value: &str) {
408-
assert!(is_rfc_1123_subdomain(value).is_ok());
422+
assert!(is_lowercase_rfc_1123_subdomain(value).is_ok());
409423
// Every valid RFC1123 is also a valid domain
410424
assert!(is_domain(value).is_ok());
411425
}
@@ -469,7 +483,7 @@ mod tests {
469483
#[case("1 2")]
470484
#[case(&"a".repeat(64))]
471485
fn is_rfc_1035_label_fail(#[case] value: &str) {
472-
assert!(is_rfc_1035_label(value).is_err());
486+
assert!(is_lowercase_rfc_1035_label(value).is_err());
473487
}
474488

475489
#[rstest]
@@ -481,6 +495,6 @@ mod tests {
481495
#[case("a--1--2--b")]
482496
#[case(&"a".repeat(63))]
483497
fn is_rfc_1035_label_pass(#[case] value: &str) {
484-
assert!(is_rfc_1035_label(value).is_ok());
498+
assert!(is_lowercase_rfc_1035_label(value).is_ok());
485499
}
486500
}

0 commit comments

Comments
 (0)