Skip to content

Commit 4f6c565

Browse files
authored
fix: prevent API key auth bypass via double slashes (#588)
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/...`.
1 parent 7c33810 commit 4f6c565

2 files changed

Lines changed: 29 additions & 16 deletions

File tree

aw-client-rust/src/lib.rs

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ impl AwClient {
6060
}
6161

6262
pub async fn get_bucket(&self, bucketname: &str) -> Result<Bucket, reqwest::Error> {
63-
let url = format!("{}/api/0/buckets/{}", self.baseurl, bucketname);
63+
let url = format!("{}api/0/buckets/{}", self.baseurl, bucketname);
6464
let bucket = self
6565
.client
6666
.get(url)
@@ -73,12 +73,12 @@ impl AwClient {
7373
}
7474

7575
pub async fn get_buckets(&self) -> Result<HashMap<String, Bucket>, reqwest::Error> {
76-
let url = format!("{}/api/0/buckets/", self.baseurl);
76+
let url = format!("{}api/0/buckets/", self.baseurl);
7777
self.client.get(url).send().await?.json().await
7878
}
7979

8080
pub async fn create_bucket(&self, bucket: &Bucket) -> Result<(), reqwest::Error> {
81-
let url = format!("{}/api/0/buckets/{}", self.baseurl, bucket.id);
81+
let url = format!("{}api/0/buckets/{}", self.baseurl, bucket.id);
8282
self.client.post(url).json(bucket).send().await?;
8383
Ok(())
8484
}
@@ -104,7 +104,7 @@ impl AwClient {
104104
}
105105

106106
pub async fn delete_bucket(&self, bucketname: &str) -> Result<(), reqwest::Error> {
107-
let url = format!("{}/api/0/buckets/{}", self.baseurl, bucketname);
107+
let url = format!("{}api/0/buckets/{}", self.baseurl, bucketname);
108108
self.client.delete(url).send().await?;
109109
Ok(())
110110
}
@@ -114,7 +114,7 @@ impl AwClient {
114114
query: &str,
115115
timeperiods: Vec<(DateTime<Utc>, DateTime<Utc>)>,
116116
) -> Result<Vec<serde_json::Value>, reqwest::Error> {
117-
let url = reqwest::Url::parse(format!("{}/api/0/query", self.baseurl).as_str()).unwrap();
117+
let url = reqwest::Url::parse(format!("{}api/0/query", self.baseurl).as_str()).unwrap();
118118

119119
// Format timeperiods as ISO8601 strings, separated by /
120120
let timeperiods_str: Vec<String> = timeperiods
@@ -144,7 +144,7 @@ impl AwClient {
144144
limit: Option<u64>,
145145
) -> Result<Vec<Event>, reqwest::Error> {
146146
let mut url = reqwest::Url::parse(
147-
format!("{}/api/0/buckets/{}/events", self.baseurl, bucketname).as_str(),
147+
format!("{}api/0/buckets/{}/events", self.baseurl, bucketname).as_str(),
148148
)
149149
.unwrap();
150150

@@ -169,7 +169,7 @@ impl AwClient {
169169
bucketname: &str,
170170
event: &Event,
171171
) -> Result<(), reqwest::Error> {
172-
let url = format!("{}/api/0/buckets/{}/events", self.baseurl, bucketname);
172+
let url = format!("{}api/0/buckets/{}/events", self.baseurl, bucketname);
173173
let eventlist = vec![event.clone()];
174174
self.client.post(url).json(&eventlist).send().await?;
175175
Ok(())
@@ -180,7 +180,7 @@ impl AwClient {
180180
bucketname: &str,
181181
events: Vec<Event>,
182182
) -> Result<(), reqwest::Error> {
183-
let url = format!("{}/api/0/buckets/{}/events", self.baseurl, bucketname);
183+
let url = format!("{}api/0/buckets/{}/events", self.baseurl, bucketname);
184184
self.client.post(url).json(&events).send().await?;
185185
Ok(())
186186
}
@@ -192,7 +192,7 @@ impl AwClient {
192192
pulsetime: f64,
193193
) -> Result<(), reqwest::Error> {
194194
let url = format!(
195-
"{}/api/0/buckets/{}/heartbeat?pulsetime={}",
195+
"{}api/0/buckets/{}/heartbeat?pulsetime={}",
196196
self.baseurl, bucketname, pulsetime
197197
);
198198
self.client.post(url).json(&event).send().await?;
@@ -205,15 +205,15 @@ impl AwClient {
205205
event_id: i64,
206206
) -> Result<(), reqwest::Error> {
207207
let url = format!(
208-
"{}/api/0/buckets/{}/events/{}",
208+
"{}api/0/buckets/{}/events/{}",
209209
self.baseurl, bucketname, event_id
210210
);
211211
self.client.delete(url).send().await?;
212212
Ok(())
213213
}
214214

215215
pub async fn get_event_count(&self, bucketname: &str) -> Result<i64, reqwest::Error> {
216-
let url = format!("{}/api/0/buckets/{}/events/count", self.baseurl, bucketname);
216+
let url = format!("{}api/0/buckets/{}/events/count", self.baseurl, bucketname);
217217
let res = self
218218
.client
219219
.get(url)
@@ -230,17 +230,17 @@ impl AwClient {
230230
}
231231

232232
pub async fn get_info(&self) -> Result<aw_models::Info, reqwest::Error> {
233-
let url = format!("{}/api/0/info", self.baseurl);
233+
let url = format!("{}api/0/info", self.baseurl);
234234
self.client.get(url).send().await?.json().await
235235
}
236236

237237
pub async fn get_setting(&self, setting: &str) -> Result<serde_json::Value, reqwest::Error> {
238-
let url = format!("{}/api/0/settings/{}", self.baseurl, setting);
238+
let url = format!("{}api/0/settings/{}", self.baseurl, setting);
239239
self.client.get(url).send().await?.json().await
240240
}
241241

242242
pub async fn get_settings(&self) -> Result<aw_models::Settings, reqwest::Error> {
243-
let url = format!("{}/api/0/settings", self.baseurl);
243+
let url = format!("{}api/0/settings", self.baseurl);
244244
self.client.get(url).send().await?.json().await
245245
}
246246

aw-server/src/endpoints/apikey.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,18 @@ impl Fairing for ApiKeyCheck {
111111
return;
112112
}
113113

114+
let path = request.uri().path().as_str();
115+
116+
// Normalize leading slashes to prevent bypass via `//api/...`
117+
let normalized_path = format!("/{}", path.trim_start_matches('/'));
118+
114119
// Only gate API endpoints — static web UI assets are not under /api/
115-
if !request.uri().path().as_str().starts_with("/api/") {
120+
if !normalized_path.starts_with("/api/") {
116121
return;
117122
}
118123

119124
// Always allow public API paths (e.g. /api/0/info for health checks)
120-
if PUBLIC_PATHS.contains(&request.uri().path().as_str()) {
125+
if PUBLIC_PATHS.contains(&normalized_path.as_str()) {
121126
return;
122127
}
123128

@@ -205,6 +210,14 @@ mod tests {
205210
.header(Header::new("Host", "localhost:5600"))
206211
.dispatch();
207212
assert_eq!(res.status(), Status::Unauthorized);
213+
214+
// Double slash should also require auth
215+
let res = client
216+
.get("//api/0/buckets/")
217+
.header(ContentType::JSON)
218+
.header(Header::new("Host", "localhost:5600"))
219+
.dispatch();
220+
assert_eq!(res.status(), Status::Unauthorized);
208221
}
209222

210223
#[test]

0 commit comments

Comments
 (0)