From 00c6ed010fbfb946b9f92cd7754460f6f1dda686 Mon Sep 17 00:00:00 2001 From: Brayo Date: Wed, 22 Apr 2026 17:38:00 +0300 Subject: [PATCH] fix: prevent API key auth bypass via double slashes This commit fixes a vulnerability where API key authentication could be bypassed by sending a request with a double slash (e.g., `//api/0/buckets/...`). The `ApiKeyCheck` fairing relied on `request.uri().path().as_str().starts_with("/api/")`, which evaluated to false for paths starting with `//`, causing the middleware to skip the authentication check. Rocket would then internally normalize the path and route the request successfully, completely bypassing the security measure. The fix normalizes leading slashes in `ApiKeyCheck` before checking the path prefix, ensuring all API endpoints are properly authenticated regardless of duplicate slashes. Additionally, this commit updates `aw-client-rust` to prevent it from unintentionally generating requests with double slashes. Because `reqwest::Url`'s string representation automatically includes a trailing slash, the `format!` strings have been adjusted from `{}/api/...` to `{}api/...`. --- aw-client-rust/src/lib.rs | 28 ++++++++++++++-------------- aw-server/src/endpoints/apikey.rs | 17 +++++++++++++++-- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/aw-client-rust/src/lib.rs b/aw-client-rust/src/lib.rs index 42368604..ee235f57 100644 --- a/aw-client-rust/src/lib.rs +++ b/aw-client-rust/src/lib.rs @@ -60,7 +60,7 @@ impl AwClient { } pub async fn get_bucket(&self, bucketname: &str) -> Result { - let url = format!("{}/api/0/buckets/{}", self.baseurl, bucketname); + let url = format!("{}api/0/buckets/{}", self.baseurl, bucketname); let bucket = self .client .get(url) @@ -73,12 +73,12 @@ impl AwClient { } pub async fn get_buckets(&self) -> Result, reqwest::Error> { - let url = format!("{}/api/0/buckets/", self.baseurl); + let url = format!("{}api/0/buckets/", self.baseurl); self.client.get(url).send().await?.json().await } pub async fn create_bucket(&self, bucket: &Bucket) -> Result<(), reqwest::Error> { - let url = format!("{}/api/0/buckets/{}", self.baseurl, bucket.id); + let url = format!("{}api/0/buckets/{}", self.baseurl, bucket.id); self.client.post(url).json(bucket).send().await?; Ok(()) } @@ -104,7 +104,7 @@ impl AwClient { } pub async fn delete_bucket(&self, bucketname: &str) -> Result<(), reqwest::Error> { - let url = format!("{}/api/0/buckets/{}", self.baseurl, bucketname); + let url = format!("{}api/0/buckets/{}", self.baseurl, bucketname); self.client.delete(url).send().await?; Ok(()) } @@ -114,7 +114,7 @@ impl AwClient { query: &str, timeperiods: Vec<(DateTime, DateTime)>, ) -> Result, reqwest::Error> { - let url = reqwest::Url::parse(format!("{}/api/0/query", self.baseurl).as_str()).unwrap(); + let url = reqwest::Url::parse(format!("{}api/0/query", self.baseurl).as_str()).unwrap(); // Format timeperiods as ISO8601 strings, separated by / let timeperiods_str: Vec = timeperiods @@ -144,7 +144,7 @@ impl AwClient { limit: Option, ) -> Result, reqwest::Error> { let mut url = reqwest::Url::parse( - format!("{}/api/0/buckets/{}/events", self.baseurl, bucketname).as_str(), + format!("{}api/0/buckets/{}/events", self.baseurl, bucketname).as_str(), ) .unwrap(); @@ -169,7 +169,7 @@ impl AwClient { bucketname: &str, event: &Event, ) -> Result<(), reqwest::Error> { - let url = format!("{}/api/0/buckets/{}/events", self.baseurl, bucketname); + let url = format!("{}api/0/buckets/{}/events", self.baseurl, bucketname); let eventlist = vec![event.clone()]; self.client.post(url).json(&eventlist).send().await?; Ok(()) @@ -180,7 +180,7 @@ impl AwClient { bucketname: &str, events: Vec, ) -> Result<(), reqwest::Error> { - let url = format!("{}/api/0/buckets/{}/events", self.baseurl, bucketname); + let url = format!("{}api/0/buckets/{}/events", self.baseurl, bucketname); self.client.post(url).json(&events).send().await?; Ok(()) } @@ -192,7 +192,7 @@ impl AwClient { pulsetime: f64, ) -> Result<(), reqwest::Error> { let url = format!( - "{}/api/0/buckets/{}/heartbeat?pulsetime={}", + "{}api/0/buckets/{}/heartbeat?pulsetime={}", self.baseurl, bucketname, pulsetime ); self.client.post(url).json(&event).send().await?; @@ -205,7 +205,7 @@ impl AwClient { event_id: i64, ) -> Result<(), reqwest::Error> { let url = format!( - "{}/api/0/buckets/{}/events/{}", + "{}api/0/buckets/{}/events/{}", self.baseurl, bucketname, event_id ); self.client.delete(url).send().await?; @@ -213,7 +213,7 @@ impl AwClient { } pub async fn get_event_count(&self, bucketname: &str) -> Result { - let url = format!("{}/api/0/buckets/{}/events/count", self.baseurl, bucketname); + let url = format!("{}api/0/buckets/{}/events/count", self.baseurl, bucketname); let res = self .client .get(url) @@ -230,17 +230,17 @@ impl AwClient { } pub async fn get_info(&self) -> Result { - let url = format!("{}/api/0/info", self.baseurl); + let url = format!("{}api/0/info", self.baseurl); self.client.get(url).send().await?.json().await } pub async fn get_setting(&self, setting: &str) -> Result { - let url = format!("{}/api/0/settings/{}", self.baseurl, setting); + let url = format!("{}api/0/settings/{}", self.baseurl, setting); self.client.get(url).send().await?.json().await } pub async fn get_settings(&self) -> Result { - let url = format!("{}/api/0/settings", self.baseurl); + let url = format!("{}api/0/settings", self.baseurl); self.client.get(url).send().await?.json().await } diff --git a/aw-server/src/endpoints/apikey.rs b/aw-server/src/endpoints/apikey.rs index a651f211..f7bfcfe7 100644 --- a/aw-server/src/endpoints/apikey.rs +++ b/aw-server/src/endpoints/apikey.rs @@ -111,13 +111,18 @@ impl Fairing for ApiKeyCheck { return; } + let path = request.uri().path().as_str(); + + // Normalize leading slashes to prevent bypass via `//api/...` + let normalized_path = format!("/{}", path.trim_start_matches('/')); + // Only gate API endpoints — static web UI assets are not under /api/ - if !request.uri().path().as_str().starts_with("/api/") { + if !normalized_path.starts_with("/api/") { return; } // Always allow public API paths (e.g. /api/0/info for health checks) - if PUBLIC_PATHS.contains(&request.uri().path().as_str()) { + if PUBLIC_PATHS.contains(&normalized_path.as_str()) { return; } @@ -205,6 +210,14 @@ mod tests { .header(Header::new("Host", "localhost:5600")) .dispatch(); assert_eq!(res.status(), Status::Unauthorized); + + // Double slash should also require auth + let res = client + .get("//api/0/buckets/") + .header(ContentType::JSON) + .header(Header::new("Host", "localhost:5600")) + .dispatch(); + assert_eq!(res.status(), Status::Unauthorized); } #[test]