Skip to content

Commit 11c0fbe

Browse files
committed
fix(object_store): validate S3 bucket name before use
An invalid bucket name (e.g. `rw_data`, which contains an underscore) previously only surfaced as an opaque error like "Unexpected (permanent) at stat" on first access, making the real cause hard to diagnose. Add validate_s3_bucket_name() implementing the AWS S3 bucket naming rules, and call it eagerly in build_remote_object_store() for the s3:// scheme (both the native S3ObjectStore and OpenDAL S3 engine paths) so misconfiguration is caught at startup with a clear panic message instead of a confusing runtime error. Closes #20263
1 parent 1e5ea11 commit 11c0fbe

2 files changed

Lines changed: 135 additions & 8 deletions

File tree

src/object_store/src/object/mod.rs

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -884,8 +884,11 @@ pub async fn build_remote_object_store(
884884
tracing::debug!(config=?config, "object store {ident}");
885885
match url {
886886
s3 if s3.starts_with("s3://") => {
887+
let bucket = s3.strip_prefix("s3://").unwrap();
888+
if let Err(e) = crate::object::s3::validate_s3_bucket_name(bucket) {
889+
panic!("Invalid object store configuration: {e}");
890+
}
887891
if config.s3.developer.use_opendal {
888-
let bucket = s3.strip_prefix("s3://").unwrap();
889892
tracing::info!("Using OpenDAL to access s3, bucket is {}", bucket);
890893
ObjectStoreImpl::Opendal(
891894
OpendalObjectStore::new_s3_engine(
@@ -898,13 +901,9 @@ pub async fn build_remote_object_store(
898901
)
899902
} else {
900903
ObjectStoreImpl::S3(
901-
S3ObjectStore::new_with_config(
902-
s3.strip_prefix("s3://").unwrap().to_owned(),
903-
metrics.clone(),
904-
config.clone(),
905-
)
906-
.await
907-
.monitored(metrics, config),
904+
S3ObjectStore::new_with_config(bucket.to_owned(), metrics.clone(), config.clone())
905+
.await
906+
.monitored(metrics, config),
908907
)
909908
}
910909
}
@@ -1180,6 +1179,27 @@ mod tests {
11801179
let err = reader.read_to_end(&mut output).await.unwrap_err();
11811180
assert!(err.to_string().contains("injected stream error"));
11821181
}
1182+
1183+
/// Integration test for issue #20263: an invalid bucket name (e.g. containing an
1184+
/// underscore) must be rejected eagerly with a clear panic message, instead of
1185+
/// surfacing as an opaque request error only when the store is first accessed.
1186+
#[tokio::test]
1187+
#[should_panic(expected = "Invalid object store configuration")]
1188+
async fn test_build_remote_object_store_rejects_invalid_bucket_name() {
1189+
use std::sync::Arc;
1190+
1191+
use risingwave_common::config::ObjectStoreConfig;
1192+
1193+
use super::{ObjectStoreMetrics, build_remote_object_store};
1194+
1195+
build_remote_object_store(
1196+
"s3://rw_data",
1197+
Arc::new(ObjectStoreMetrics::unused()),
1198+
"test",
1199+
Arc::new(ObjectStoreConfig::default()),
1200+
)
1201+
.await;
1202+
}
11831203
}
11841204

11851205
#[derive(Debug, Clone, Copy)]

src/object_store/src/object/s3.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,113 @@ impl StreamingUploader for S3StreamingUploader {
403403
}
404404
}
405405

406+
/// Validates an S3 bucket name against the AWS bucket naming rules
407+
/// (<https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html>),
408+
/// so that a misconfigured bucket name is rejected with a clear error message
409+
/// instead of surfacing as an opaque request error at first access.
410+
pub fn validate_s3_bucket_name(bucket: &str) -> Result<(), String> {
411+
let len = bucket.len();
412+
if !(3..=63).contains(&len) {
413+
return Err(format!(
414+
"invalid S3 bucket name {bucket:?}: length must be between 3 and 63 characters, got {len}"
415+
));
416+
}
417+
if bucket
418+
.parse::<std::net::Ipv4Addr>()
419+
.is_ok_and(|_| bucket.split('.').count() == 4)
420+
{
421+
return Err(format!(
422+
"invalid S3 bucket name {bucket:?}: must not be formatted as an IP address"
423+
));
424+
}
425+
let is_valid_char =
426+
|c: char| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.';
427+
if !bucket.chars().all(is_valid_char) {
428+
return Err(format!(
429+
"invalid S3 bucket name {bucket:?}: only lowercase letters, numbers, dots and hyphens are allowed"
430+
));
431+
}
432+
let starts_ends_alnum = bucket
433+
.chars()
434+
.next()
435+
.is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
436+
&& bucket
437+
.chars()
438+
.next_back()
439+
.is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
440+
if !starts_ends_alnum {
441+
return Err(format!(
442+
"invalid S3 bucket name {bucket:?}: must start and end with a lowercase letter or number"
443+
));
444+
}
445+
if bucket.contains("..") {
446+
return Err(format!(
447+
"invalid S3 bucket name {bucket:?}: must not contain two adjacent periods"
448+
));
449+
}
450+
if bucket.starts_with("xn--") || bucket.ends_with("-s3alias") || bucket.ends_with("--ol-s3") {
451+
return Err(format!(
452+
"invalid S3 bucket name {bucket:?}: must not start with the prefix \"xn--\" or end with the suffix \"-s3alias\" or \"--ol-s3\""
453+
));
454+
}
455+
Ok(())
456+
}
457+
458+
#[cfg(test)]
459+
mod validate_s3_bucket_name_tests {
460+
use super::validate_s3_bucket_name;
461+
462+
#[test]
463+
fn test_valid_bucket_names() {
464+
assert!(validate_s3_bucket_name("rw-data").is_ok());
465+
assert!(validate_s3_bucket_name("my.bucket.123").is_ok());
466+
assert!(validate_s3_bucket_name("abc").is_ok());
467+
assert!(validate_s3_bucket_name(&"a".repeat(63)).is_ok());
468+
}
469+
470+
#[test]
471+
fn test_rejects_underscore() {
472+
// e.g. issue #20263: `rw_data` is not a valid bucket name.
473+
let err = validate_s3_bucket_name("rw_data").unwrap_err();
474+
assert!(err.contains("lowercase letters, numbers, dots and hyphens"));
475+
}
476+
477+
#[test]
478+
fn test_rejects_bad_length() {
479+
assert!(validate_s3_bucket_name("ab").is_err());
480+
assert!(validate_s3_bucket_name(&"a".repeat(64)).is_err());
481+
}
482+
483+
#[test]
484+
fn test_rejects_uppercase() {
485+
assert!(validate_s3_bucket_name("MyBucket").is_err());
486+
}
487+
488+
#[test]
489+
fn test_rejects_ip_address() {
490+
assert!(validate_s3_bucket_name("192.168.1.1").is_err());
491+
}
492+
493+
#[test]
494+
fn test_rejects_bad_start_end() {
495+
assert!(validate_s3_bucket_name("-mybucket").is_err());
496+
assert!(validate_s3_bucket_name("mybucket-").is_err());
497+
assert!(validate_s3_bucket_name(".mybucket").is_err());
498+
}
499+
500+
#[test]
501+
fn test_rejects_adjacent_periods() {
502+
assert!(validate_s3_bucket_name("my..bucket").is_err());
503+
}
504+
505+
#[test]
506+
fn test_rejects_reserved_prefix_suffix() {
507+
assert!(validate_s3_bucket_name("xn--bucket").is_err());
508+
assert!(validate_s3_bucket_name("mybucket-s3alias").is_err());
509+
assert!(validate_s3_bucket_name("mybucket--ol-s3").is_err());
510+
}
511+
}
512+
406513
fn get_upload_body(data: Vec<Bytes>) -> ByteStream {
407514
// `ByteStream` is retryable when created from in-memory data.
408515
// This code path is used for non-multipart uploads, so a copy is acceptable.

0 commit comments

Comments
 (0)