object-storage-proxy 0.2.16__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/Cargo.lock +2 -1
  2. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/Cargo.toml +2 -1
  3. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/PKG-INFO +1 -1
  4. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/src/credentials/signer.rs +235 -74
  5. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/src/lib.rs +133 -25
  6. object_storage_proxy-0.3.1/src/parsers/credentials.rs +234 -0
  7. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/src/parsers/keystore.rs +1 -1
  8. object_storage_proxy-0.3.1/test_integration.sh +57 -0
  9. object_storage_proxy-0.2.16/src/parsers/credentials.rs +0 -82
  10. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/.cargo/config.toml +0 -0
  11. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/.github/workflows/ci.yml +0 -0
  12. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/.gitignore +0 -0
  13. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/LICENSE +0 -0
  14. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/README.md +0 -0
  15. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/img/logo.svg +0 -0
  16. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/img/request_lifecycle.svg +0 -0
  17. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/img/request_stages.svg +0 -0
  18. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/pyproject.toml +0 -0
  19. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/requirements.txt +0 -0
  20. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/src/credentials/hmac_keystore.rs +0 -0
  21. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/src/credentials/mod.rs +0 -0
  22. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/src/credentials/models.rs +0 -0
  23. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/src/credentials/secrets_proxy.rs +0 -0
  24. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/src/object_storage_proxy.pyi +0 -0
  25. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/src/parsers/cos_map.rs +0 -0
  26. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/src/parsers/mod.rs +0 -0
  27. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/src/parsers/path.rs +0 -0
  28. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/src/utils/mod.rs +0 -0
  29. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/src/utils/validator.rs +0 -0
  30. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/test_server.py +0 -0
  31. {object_storage_proxy-0.2.16 → object_storage_proxy-0.3.1}/uv.lock +0 -0
@@ -1701,7 +1701,7 @@ dependencies = [
1701
1701
 
1702
1702
  [[package]]
1703
1703
  name = "object-storage-proxy"
1704
- version = "0.2.16"
1704
+ version = "0.3.1"
1705
1705
  dependencies = [
1706
1706
  "async-trait",
1707
1707
  "chrono",
@@ -1713,6 +1713,7 @@ dependencies = [
1713
1713
  "http",
1714
1714
  "log",
1715
1715
  "nom 8.0.0",
1716
+ "percent-encoding",
1716
1717
  "pingora",
1717
1718
  "prometheus 0.14.0",
1718
1719
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "object-storage-proxy"
3
- version = "0.2.16"
3
+ version = "0.3.1"
4
4
  edition = "2024"
5
5
 
6
6
  [dependencies]
@@ -33,6 +33,7 @@ sha256 = "1.6.0"
33
33
  ring = "0.17.14"
34
34
  hex = "0.4.3"
35
35
  regex = "1.11.1"
36
+ percent-encoding = "2.3.1"
36
37
 
37
38
  # [build-dependencies]
38
39
  # openssl-sys = { version = "0.9", features = ["vendored"] }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: object-storage-proxy
3
- Version: 0.2.16
3
+ Version: 0.3.1
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Programming Language :: Python :: Implementation :: PyPy
@@ -1,11 +1,9 @@
1
1
  use chrono::{DateTime, NaiveDateTime, Utc};
2
2
  use http::header::HeaderMap;
3
3
  use pingora::{http::RequestHeader, proxy::Session};
4
- use rustls::crypto::hash::Hash;
5
4
  use sha256::digest;
6
- use tokio::sync::RwLock;
7
- use tracing::{debug, error, info};
8
- use std::{collections::{HashMap, HashSet}, fmt, sync::Arc};
5
+ use tracing::{debug, error};
6
+ use std::{collections::HashMap, fmt};
9
7
  use url::Url;
10
8
 
11
9
  use crate::parsers::{cos_map::CosMapItem, credentials::{parse_credential_scope, parse_token_from_header}};
@@ -62,6 +60,25 @@ where
62
60
  body: &'a [u8],
63
61
  }
64
62
 
63
+ /// Create a new AwsSign instance
64
+ ///
65
+ /// # Arguments
66
+ ///
67
+ /// * `method` - HTTP method (GET, POST, etc.)
68
+ /// * `url` - URL to sign
69
+ /// * `datetime` - Date and time of the request
70
+ /// * `headers` - HTTP headers
71
+ /// * `region` - AWS region
72
+ /// * `access_key` - AWS access key
73
+ /// * `secret_key` - AWS secret key
74
+ /// * `service` - AWS service code
75
+ /// * `body` - Request body
76
+ /// * `signed_headers` - Optional list of signed headers, used to check inbound request signature
77
+ ///
78
+ /// # Returns
79
+ ///
80
+ /// A new instance of `AwsSign`
81
+ ///
65
82
  impl<'a> AwsSign<'a, HashMap<String, String>> {
66
83
  pub fn new<B: AsRef<[u8]> + ?Sized>(
67
84
  method: &'a str,
@@ -75,6 +92,8 @@ impl<'a> AwsSign<'a, HashMap<String, String>> {
75
92
  body: &'a B,
76
93
  signed_headers: Option<&'a Vec<String>>,
77
94
  ) -> Self {
95
+
96
+
78
97
  let allowed: Vec<&str> = if let Some(sh) = signed_headers {
79
98
  sh.iter().map(String::as_str).collect()
80
99
  } else {
@@ -86,15 +105,8 @@ impl<'a> AwsSign<'a, HashMap<String, String>> {
86
105
  "x-amz-security-token",
87
106
  ]
88
107
  };
89
- // let allowed = [
90
- // "host",
91
- // "x-amz-date",
92
- // "range",
93
- // "x-amz-content-sha256",
94
- // "x-amz-security-token",
95
- // ];
96
108
 
97
- dbg!(&url);
109
+ debug!("{:#?}", &url);
98
110
  let url: Url = url.parse().unwrap();
99
111
  let headers: HashMap<String, String> = headers
100
112
  .iter()
@@ -124,6 +136,7 @@ impl<'a> AwsSign<'a, HashMap<String, String>> {
124
136
  }
125
137
  }
126
138
 
139
+
127
140
  /// custom debug implementation to redact secret_key
128
141
  impl<'a, T> fmt::Debug for AwsSign<'a, T>
129
142
  where
@@ -140,6 +153,7 @@ where
140
153
  .field("service", &self.service)
141
154
  .field("body", &self.body)
142
155
  .field("headers", &self.headers)
156
+ .field("payload_override", &self.payload_override)
143
157
  .finish()
144
158
  }
145
159
  }
@@ -148,6 +162,10 @@ impl<'a, T> AwsSign<'a, T>
148
162
  where
149
163
  &'a T: std::iter::IntoIterator<Item = (&'a String, &'a String)>, T: std::fmt::Debug
150
164
  {
165
+ /// for streaming uploads, we need to override the payload hash
166
+ /// with the actual payload hash
167
+ /// this is used for the `UNSIGNED-PAYLOAD` case
168
+ /// and for the `payload_override` case
151
169
  pub fn set_payload_override(&mut self, h: String) {
152
170
  self.payload_override = Some(h);
153
171
  }
@@ -201,14 +219,16 @@ where
201
219
  let signature = hex::encode(tag.as_ref());
202
220
  let signed_headers = self.signed_header_string();
203
221
 
204
- format!(
222
+ let sign_string = format!(
205
223
  "AWS4-HMAC-SHA256 Credential={access_key}/{scope},\
206
224
  SignedHeaders={signed_headers},Signature={signature}",
207
225
  access_key = self.access_key,
208
226
  scope = scope_string(self.datetime, self.region, self.service),
209
227
  signed_headers = signed_headers,
210
228
  signature = signature
211
- )
229
+ );
230
+ debug!("sign_string: {}", sign_string);
231
+ sign_string
212
232
  }
213
233
  }
214
234
 
@@ -290,6 +310,14 @@ pub fn signing_key(
290
310
  Ok(signing_tag.as_ref().to_vec())
291
311
  }
292
312
 
313
+
314
+ /// Sign the request with the AWS V4 signature
315
+ /// # Arguments
316
+ /// * `request` - The request to sign
317
+ /// * `cos_map` - The COS map item containing the credentials
318
+ /// # Returns
319
+ /// * `Ok(())` if the request was signed successfully
320
+ /// * `Err` if there was an error signing the request
293
321
  pub(crate) async fn sign_request(
294
322
  request: &mut RequestHeader,
295
323
  cos_map: &CosMapItem,
@@ -360,22 +388,53 @@ pub(crate) async fn sign_request(
360
388
  }
361
389
 
362
390
 
363
- pub async fn signature_is_valid(
391
+ /// Core signature validation: compares provided vs computed
392
+ async fn signature_is_valid_core(
393
+ method: &str,
394
+ provided_signature: &str,
395
+ region: &str,
396
+ service: &str,
397
+ datetime: DateTime<Utc>,
398
+ full_url: &str,
399
+ headers: &HeaderMap,
400
+ payload_override: Option<String>,
401
+ access_key: &str,
402
+ secret_key: &str,
403
+ signed_headers: &Vec<String>,
404
+ body_bytes: &[u8],
405
+ ) -> Result<bool, Box<dyn std::error::Error>> {
406
+ // Build AwsSign for authorization header style
407
+ debug!("{:#?}", &headers);
408
+ let mut signer = AwsSign::new(
409
+ method,
410
+ full_url,
411
+ &datetime,
412
+ headers,
413
+ region,
414
+ access_key,
415
+ secret_key,
416
+ service,
417
+ body_bytes,
418
+ Some(&signed_headers),
419
+ );
420
+ if let Some(ov) = payload_override {
421
+ signer.set_payload_override(ov);
422
+ }
423
+ let signature = signer.sign();
424
+ let computed = signature.split("Signature=").nth(1).unwrap_or_default();
425
+ debug!("Provided signature: {}", provided_signature);
426
+ debug!("Computed signature: {}", computed);
427
+ Ok(computed == provided_signature)
428
+ }
429
+
430
+ /// Validate standard S3 Authorization header
431
+ pub async fn signature_is_valid_for_request(
364
432
  auth_header: &str,
365
433
  session: &Session,
366
434
  secret_key: &str,
367
435
  ) -> Result<bool, Box<dyn std::error::Error>> {
368
436
 
369
- let signed_headers_str = auth_header
370
- .split("SignedHeaders=")
371
- .nth(1)
372
- .and_then(|s| s.split(',').next())
373
- .unwrap_or("");
374
- let signed_headers: Vec<String> =
375
- signed_headers_str.split(';').map(|s| s.to_lowercase()).collect();
376
-
377
-
378
- // extract access key from Authorization header
437
+
379
438
  let (_, local_access_key) = parse_token_from_header(auth_header)
380
439
  .map_err(|_| pingora::Error::new_str("Failed to parse token"))?;
381
440
  let local_access_key = local_access_key.to_string();
@@ -383,49 +442,26 @@ pub async fn signature_is_valid(
383
442
  error!("Missing access key");
384
443
  return Ok(false);
385
444
  }
386
-
387
- // extract provided signature
388
445
  let provided_signature = auth_header
389
446
  .split("Signature=")
390
447
  .nth(1)
391
- .ok_or_else(|| pingora::Error::new_str("Invalid Authorization header: no Signature"))?
392
- .to_string();
393
-
394
- // parse x-amz-date header
395
- let dt_header = session
396
- .req_header()
397
- .headers
398
- .get("x-amz-date")
399
- .ok_or_else(|| pingora::Error::new_str("Missing x-amz-date header"))?
400
- .to_str()?;
401
-
402
- // use NaiveDateTime then assign Utc timezone (format: %Y%m%dT%H%M%SZ)
403
- let naive = NaiveDateTime::parse_from_str(dt_header, LONG_DATETIME)
404
- .map_err(|_| {
405
- pingora::Error::new_str("invalid date")
406
- })?;
407
- let datetime = naive.and_utc();
408
-
409
-
410
- info!("parsing the region and service");
448
+ .ok_or("Missing Signature")?;
411
449
 
412
450
  let (_, (region, service)) = parse_credential_scope(auth_header)
413
451
  .map_err(|_| pingora::Error::new_str("Invalid Credential scope"))?;
414
452
 
415
- dbg!(&region);
416
- dbg!(&service);
453
+ let method = session.req_header().method.to_string();
454
+ // Parse date header
455
+ let dt_header = session.req_header().headers.get("x-amz-date").unwrap().to_str()?;
456
+ let datetime = NaiveDateTime::parse_from_str(dt_header, LONG_DATETIME)?.and_utc();
457
+
417
458
 
418
- // determine payload hash header
419
459
  let content_sha256 = session
420
460
  .req_header()
421
461
  .headers
422
462
  .get("x-amz-content-sha256")
423
463
  .and_then(|h| h.to_str().ok())
424
464
  .ok_or_else(|| pingora::Error::new_str("Missing x-amz-content-sha256 header"))?;
425
- // let body_bytes: &[u8] = match content_sha256 {
426
- // "UNSIGNED-PAYLOAD" => b"UNSIGNED-PAYLOAD",
427
- // _ => &[],
428
- // };
429
465
 
430
466
  let (body_bytes, payload_override) = if content_sha256 == "UNSIGNED-PAYLOAD" {
431
467
  (b"UNSIGNED-PAYLOAD" as &[u8], None)
@@ -435,6 +471,7 @@ pub async fn signature_is_valid(
435
471
  (&[] as &[u8], Some(content_sha256.to_owned()))
436
472
  };
437
473
 
474
+ // Build full URL
438
475
  let original_uri = session.req_header().uri.to_string();
439
476
  let full_url = if original_uri.starts_with('/') {
440
477
  let host = session
@@ -448,36 +485,159 @@ pub async fn signature_is_valid(
448
485
  original_uri
449
486
  };
450
487
 
451
- // construct AwsSign and compute signature
452
- let method = session.req_header().method.to_string();
453
- let mut signer = AwsSign::new(
488
+ // Signed headers list
489
+ let signed_headers_str = auth_header
490
+ .split("SignedHeaders=")
491
+ .nth(1)
492
+ .unwrap()
493
+ .split(',')
494
+ .next()
495
+ .unwrap();
496
+
497
+ let signed_headers: Vec<String> = signed_headers_str.split(';').map(str::to_string).collect();
498
+
499
+ signature_is_valid_core(
454
500
  &method,
501
+ provided_signature,
502
+ region,
503
+ service,
504
+ datetime,
455
505
  &full_url,
456
- &datetime,
457
506
  &session.req_header().headers,
458
- region,
507
+ payload_override,
459
508
  &local_access_key,
460
- &secret_key,
461
- service,
509
+ secret_key,
510
+ &signed_headers,
462
511
  body_bytes,
463
- Some(&signed_headers),
464
- );
512
+ )
513
+ .await
514
+ }
465
515
 
466
- if let Some(ov) = payload_override {
467
- signer.set_payload_override(ov);
516
+
517
+ /// Validate presigned URL signature
518
+ pub async fn signature_is_valid_for_presigned(
519
+ session: &Session,
520
+ secret_key: &str,
521
+ ) -> Result<bool, Box<dyn std::error::Error>> {
522
+ // Extract query params from the URL
523
+
524
+ let uri = session.req_header().uri.to_string();
525
+ let full_uri = if uri.starts_with('/') {
526
+ // build an absolute URL: scheme://host + path?query
527
+ let host = session
528
+ .req_header()
529
+ .headers
530
+ .get("host")
531
+ .ok_or("Missing host header")?
532
+ .to_str()?;
533
+ format!("https://{}{}", host, uri)
534
+ } else {
535
+ uri
536
+ };
537
+
538
+
539
+ let mut url = Url::parse(&full_uri)?;
540
+ debug!("full_url: {}", url);
541
+ let mut provided_signature = None;
542
+ let mut qp: Vec<(String,String)> = vec![];
543
+ for (k, v) in url.query_pairs() {
544
+ if k == "X-Amz-Signature" {
545
+ provided_signature = Some(v.into_owned());
546
+ } else {
547
+ qp.push((k.into_owned(), v.into_owned()));
548
+ }
549
+ }
550
+ let provided_signature = provided_signature.ok_or("Missing X-Amz-Signature")?;
551
+
552
+ // rebuild query string without the signature
553
+ qp.sort();
554
+ let new_query = qp.iter()
555
+ .map(|(k,v)| format!("{k}={v}"))
556
+ .collect::<Vec<_>>()
557
+ .join("&");
558
+ url.set_query(Some(&new_query));
559
+
560
+ // params map (also without the signature)
561
+ let params: HashMap<_, _> = qp.into_iter().collect();
562
+ debug!("params: {:?}", params);
563
+ debug!("url: {:?}", url);
564
+
565
+ debug!("provided signature: {}", provided_signature);
566
+ let credential = params
567
+ .get("X-Amz-Credential")
568
+ .ok_or("Missing X-Amz-Credential")?;
569
+
570
+ debug!("credential: {}", credential);
571
+
572
+ // Parse credential: <access_key>/<date>/<region>/<service>/aws4_request
573
+ let mut parts = credential.split('/');
574
+ let access_key = parts.next().ok_or("Malformed Credential")?;
575
+ let _credential_date = parts.next().ok_or("Malformed Credential")?;
576
+ let region = parts.next().ok_or("Malformed Credential")?;
577
+ let service = parts.next().ok_or("Malformed Credential")?;
578
+
579
+ debug!("access_key: {}", access_key);
580
+ debug!("region: {}", region);
581
+ debug!("service: {}", service);
582
+
583
+ // Parse date from query
584
+ let date_str = params
585
+ .get("X-Amz-Date")
586
+ .ok_or("Missing X-Amz-Date")?;
587
+ let datetime = NaiveDateTime::parse_from_str(date_str, LONG_DATETIME)?.and_utc();
588
+
589
+ debug!("datetime: {}", datetime);
590
+
591
+ let body_bytes: &[u8] = b"UNSIGNED-PAYLOAD";
592
+ let payload_override = None;
593
+
594
+ debug!("body_bytes: {:?}", body_bytes);
595
+
596
+ // Collect signed headers list
597
+ let signed_headers = params
598
+ .get("X-Amz-SignedHeaders")
599
+ .unwrap()
600
+ .split(';')
601
+ .map(str::to_string)
602
+ .collect::<Vec<_>>();
603
+
604
+ let mut signed_hdrs = HeaderMap::new();
605
+
606
+ let host_header = match url.port_or_known_default() {
607
+ Some(443) | Some(80) | None => url.host_str().unwrap().to_owned(),
608
+ Some(p) => format!("{}:{}", url.host_str().unwrap(), p),
609
+ };
610
+
611
+ signed_hdrs.insert("host", host_header.parse()?);
612
+
613
+ // copy any additional headers that appear in X-Amz-SignedHeaders (rare)
614
+ for h in &["x-amz-date", "x-amz-content-sha256", "range", "x-amz-security-token"] {
615
+ if signed_headers.contains(&h.to_string()) {
616
+ if let Some(v) = session.req_header().headers.get(*h) {
617
+ signed_hdrs.insert(*h, v.clone());
618
+ }
619
+ }
468
620
  }
469
621
 
470
- let signature = signer.sign();
471
- let computed_signature = signature
472
- .split("Signature=")
473
- .nth(1)
474
- .unwrap_or_default();
475
622
 
476
- info!("Provided signature: {}", provided_signature);
477
- info!("Computed signature: {}", computed_signature);
478
- Ok(computed_signature == provided_signature)
479
- }
480
623
 
624
+ debug!("signed_headers: {:?}", signed_headers);
625
+ // Delegate to core validator
626
+ signature_is_valid_core(
627
+ session.req_header().method.as_str(),
628
+ &provided_signature,
629
+ region,
630
+ service,
631
+ datetime,
632
+ url.as_str(),
633
+ &signed_hdrs,
634
+ payload_override,
635
+ access_key,
636
+ secret_key,
637
+ &signed_headers,
638
+ body_bytes,
639
+ ).await
640
+ }
481
641
 
482
642
  #[cfg(test)]
483
643
  mod tests {
@@ -671,4 +831,5 @@ mod tests {
671
831
  let qs = canonical_query_string(&parsed);
672
832
  assert_eq!(qs, "a=1%20space&b=2");
673
833
  }
834
+
674
835
  }
@@ -1,11 +1,12 @@
1
1
  #![warn(clippy::all)]
2
2
  use async_trait::async_trait;
3
- use credentials::signer::signature_is_valid;
3
+ use credentials::signer::{signature_is_valid_for_presigned, signature_is_valid_for_request};
4
4
  use dotenv::dotenv;
5
5
  use http::Uri;
6
6
  use http::uri::Authority;
7
7
  use parsers::cos_map::{CosMapItem, parse_cos_map};
8
8
  use parsers::keystore::parse_hmac_list;
9
+ use pingora::http::ResponseHeader;
9
10
  use pingora::Result;
10
11
  use pingora::proxy::{ProxyHttp, Session};
11
12
  use pingora::server::Server;
@@ -28,7 +29,7 @@ use tracing_subscriber::EnvFilter;
28
29
  use tracing_subscriber::fmt::time::ChronoLocal;
29
30
 
30
31
  pub mod parsers;
31
- use parsers::credentials::parse_token_from_header;
32
+ use parsers::credentials::{parse_presigned_params, parse_token_from_header};
32
33
  use parsers::path::parse_path;
33
34
 
34
35
  pub mod credentials;
@@ -197,6 +198,7 @@ pub struct MyCtx {
197
198
  validator: Option<PyObject>,
198
199
  bucket_creds_fetcher: Option<PyObject>,
199
200
  hmac_fetcher: Option<PyObject>,
201
+ is_presigned: Option<bool>,
200
202
 
201
203
  }
202
204
 
@@ -221,6 +223,7 @@ impl ProxyHttp for MyProxy {
221
223
  .hmac_fetcher
222
224
  .as_ref()
223
225
  .map(|v| Python::with_gil(|py| v.clone_ref(py))),
226
+ is_presigned: None,
224
227
  }
225
228
  }
226
229
 
@@ -311,19 +314,30 @@ impl ProxyHttp for MyProxy {
311
314
  let map = ctx.cos_mapping.read().await;
312
315
  map.get(bucket).and_then(|c| c.ttl).unwrap_or(0)
313
316
  };
317
+ let mut access_key: String = String::new();
318
+
319
+ if auth_header.is_empty() {
320
+ if let Some(q) = session.req_header().uri.query() {
321
+ if q.contains("X-Amz-Credential") {
322
+ let (_, p) = parse_presigned_params(&format!("?{q}"))
323
+ .map_err(|_| pingora::Error::new_str("Failed to parse presigned params"))?;
324
+ access_key = p.access_key.clone();
325
+ }
326
+ }
327
+ } else {
328
+ access_key = parse_token_from_header(&auth_header)
329
+ .map_err(|_| pingora::Error::new_str("Failed to parse access_key"))?
330
+ .1
331
+ .to_string();
332
+ }
314
333
 
315
334
  let is_authorized = if let Some(py_cb) = &ctx.validator {
316
- let access_key = parse_token_from_header(&auth_header)
317
- .map_err(|_| pingora::Error::new_str("Failed to parse access_key"))?
318
- .1
319
- .to_string();
320
-
321
- let is_multipart = session
322
- .req_header()
323
- .uri
324
- .query()
325
- .map_or(false, |q| q.contains("uploadId="));
326
335
 
336
+ let is_multipart = session
337
+ .req_header()
338
+ .uri
339
+ .query()
340
+ .map_or(false, |q| q.contains("uploadId="));
327
341
 
328
342
 
329
343
  info!("CHECKING SIGNATURE");
@@ -333,6 +347,62 @@ impl ProxyHttp for MyProxy {
333
347
  // continue
334
348
 
335
349
  } else {
350
+ // presigned
351
+ info!("Checking presigned signature");
352
+ let uri_q = session.req_header().uri.query().unwrap_or("");
353
+
354
+ if auth_header.is_empty() && uri_q.contains("X-Amz-Signature") {
355
+ ctx.is_presigned = Some(true);
356
+
357
+ // ensure we have the secret_key in the keystore
358
+ if !ctx.hmac_keystore.read().await.contains_key(&access_key) {
359
+ debug!("No key in keystore, trying to fetch via hmac_fetcher for ->{}<-", access_key);
360
+ // fetch via hmac_fetcher exactly as you do below…
361
+ if let Some(py_fetcher) = &ctx.hmac_fetcher {
362
+ // call Python callback
363
+ let cb = py_fetcher;
364
+ let secret: PyResult<String> = Python::with_gil(|py| {
365
+ cb.call1(py, (&access_key,))
366
+ .and_then(|r| r.extract(py))
367
+ });
368
+ debug!("Got secret: {:#?}", secret);
369
+ match secret {
370
+ Ok(secret_key) => {
371
+ debug!("got key and inserting into keystore");
372
+ ctx.hmac_keystore.write().await.insert(access_key.clone().to_string(), secret_key);
373
+ }
374
+ Err(_) => {
375
+ // no key → unauthorized
376
+ session.respond_error(401).await?;
377
+ return Ok(true);
378
+ }
379
+ }
380
+ } else {
381
+ session.respond_error(401).await?;
382
+ return Ok(true);
383
+ }
384
+
385
+ }
386
+ debug!("now checking if the signature is valid for presigned...");
387
+ let sk = ctx.hmac_keystore.read().await.get(&access_key).unwrap().clone();
388
+ debug!("got secret {} from keystore", sk);
389
+ debug!("RAW_PATH = {}", &session.req_header().uri);
390
+ debug!("RAW_HOST_HDR = {:?}", &session.req_header().headers.get("host"));
391
+ let ok = match signature_is_valid_for_presigned(&session, &sk).await {
392
+ Ok(b) => b,
393
+ Err(e) => {
394
+ error!("presigned-URL validation error: {e}"); // <-- keep the info
395
+ return Err(pingora::Error::new_str("Failed to check signature"));
396
+ }
397
+ };
398
+ info!("is signature valid?: {}", ok);
399
+ if !ok {
400
+ session.respond_error(401).await?;
401
+ return Ok(true);
402
+ }
403
+ } else {
404
+ info!("processing a regular request");
405
+
336
406
  let has_key = {
337
407
  let map = ctx.hmac_keystore.read().await;
338
408
  map.contains_key(&access_key)
@@ -361,12 +431,12 @@ impl ProxyHttp for MyProxy {
361
431
  }
362
432
  }
363
433
  let secret_key = {
364
- let map = ctx.hmac_keystore.read().await;
365
- map.get(&access_key).cloned()
366
- };
434
+ let map = ctx.hmac_keystore.read().await;
435
+ map.get(&access_key).cloned()
436
+ };
367
437
 
368
438
  info!("Checking signature");
369
- let sig_ok = match signature_is_valid(
439
+ let sig_ok = match signature_is_valid_for_request(
370
440
  &auth_header,
371
441
  &session,
372
442
  &secret_key.unwrap(),
@@ -389,17 +459,19 @@ impl ProxyHttp for MyProxy {
389
459
  session.respond_error(401).await?;
390
460
  return Ok(true);
391
461
  }
462
+ }
392
463
  }
393
464
  }
394
-
395
- let cache_key = format!("{}:{}", access_key, bucket);
465
+ info!("Signature check passed, continuing now onto the bespoke validation");
466
+ let cache_key = format!("{}:{}", &access_key, bucket);
467
+ debug!("Cache key: {}", cache_key);
396
468
 
397
469
  let bucket_clone = bucket.to_string();
398
470
  let callback_clone: PyObject = Python::with_gil(|py| py_cb.clone_ref(py));
399
-
471
+ let move_access_key = access_key.clone();
400
472
  ctx.auth_cache
401
473
  .get_or_validate(&cache_key, Duration::from_secs(ttl), move || {
402
- let tk = access_key.clone();
474
+ let tk = move_access_key.clone();
403
475
  let bu = bucket_clone.clone();
404
476
  let cb = Python::with_gil(|py| callback_clone.clone_ref(py));
405
477
  async move {
@@ -423,10 +495,8 @@ impl ProxyHttp for MyProxy {
423
495
  let map = ctx.cos_mapping.read().await;
424
496
  map.get(&hdr_bucket).cloned()
425
497
  };
426
- let access_key = parse_token_from_header(&auth_header)
427
- .map_err(|_| pingora::Error::new_str("Failed to parse access_key"))?
428
- .1
429
- .to_string();
498
+
499
+ debug!("Access key: {}", &access_key);
430
500
 
431
501
  // we have to check for some available credentials here to be able to return unauthorized already if not
432
502
  match bucket_config.clone() {
@@ -473,6 +543,31 @@ impl ProxyHttp for MyProxy {
473
543
  ctx: &mut Self::CTX,
474
544
  ) -> Result<()> {
475
545
 
546
+ if let Some(presigned) = ctx.is_presigned {
547
+ if presigned {
548
+ debug!("upstream_request_filter::presigned");
549
+ let cleaned_q = upstream_request
550
+ .uri
551
+ .query()
552
+ .unwrap_or("")
553
+ .split('&')
554
+ .filter(|kv| !kv.starts_with("X-Amz-"))
555
+ .collect::<Vec<_>>()
556
+ .join("&");
557
+
558
+ let _ = upstream_request.remove_header("authorization");
559
+
560
+ let new_path_and_query = if cleaned_q.is_empty() {
561
+ upstream_request.uri.path().to_owned()
562
+ } else {
563
+ format!("{}?{}", upstream_request.uri.path(), cleaned_q)
564
+ };
565
+
566
+ upstream_request.set_uri(new_path_and_query.try_into().unwrap());
567
+
568
+ }
569
+ };
570
+
476
571
  let _ = upstream_request.remove_header("accept-encoding");
477
572
 
478
573
  debug!("upstream_request_filter::start");
@@ -587,6 +682,19 @@ impl ProxyHttp for MyProxy {
587
682
  Ok(())
588
683
  }
589
684
 
685
+ async fn response_filter(
686
+ &self,
687
+ _session: &mut Session,
688
+ resp: &mut ResponseHeader,
689
+ _ctx: &mut Self::CTX,
690
+ ) -> Result<()> {
691
+ let _ = resp.remove_header("server");
692
+
693
+ let _ = resp.insert_header("Server", "Object-Storage-Proxy");
694
+
695
+ Ok(())
696
+ }
697
+
590
698
  }
591
699
 
592
700
  pub fn init_tracing() {
@@ -618,7 +726,7 @@ pub fn run_server(py: Python, run_args: &ProxyServerConfig) {
618
726
  parse_hmac_list(py, &run_args.hmac_keystore).unwrap_or(HashMap::new())
619
727
  };
620
728
 
621
- info!("HMAC keys: {:#?}", &local_hmac_map);
729
+ debug!("HMAC keys: {:#?}", &local_hmac_map);
622
730
 
623
731
  let cosmap = Arc::new(RwLock::new(parse_cos_map(py, &run_args.cos_map).unwrap()));
624
732
  let hmac_keystore = Arc::new(RwLock::new(local_hmac_map));
@@ -0,0 +1,234 @@
1
+ use std::collections::HashMap;
2
+
3
+ use nom::{
4
+ bytes::complete::{tag, take_until, take_while1}, multi::separated_list1, sequence::{preceded, separated_pair}, IResult, Parser
5
+ };
6
+
7
+ use nom::character::complete::char as nomchar;
8
+ use percent_encoding::percent_decode_str;
9
+ use nom::error::{Error, ErrorKind, make_error};
10
+
11
+ fn miss<'a>(i: &'a str) -> nom::Err<Error<&'a str>> {
12
+ nom::Err::Error(make_error(i, ErrorKind::Tag))
13
+ }
14
+
15
+ pub fn parse_token_from_header(header: &str) -> IResult<&str, &str> {
16
+ let (_, token) =
17
+ (preceded(tag("AWS4-HMAC-SHA256 Credential="), take_until("/"))).parse(header)?;
18
+
19
+ Ok(("", token))
20
+ }
21
+
22
+ pub fn parse_credential_scope(input: &str) -> IResult<&str, (&str, &str)> {
23
+ let (input, _) = take_until("Credential=")(input)?;
24
+ let (remaining, (_, _, _, _, _, region, _, service, _)) = (
25
+ tag("Credential="), // prefix
26
+ take_until("/"), // access key
27
+ tag("/"),
28
+ take_until("/"), // date
29
+ tag("/"),
30
+ take_until("/"), // region
31
+ tag("/"),
32
+ take_until("/aws4_request"),// service
33
+ tag("/aws4_request"), // trailing
34
+ ).parse(input)?;
35
+ Ok((remaining, (region, service)))
36
+ }
37
+
38
+ #[derive(Debug, PartialEq)]
39
+ pub struct PresignedParams {
40
+ pub algorithm: String,
41
+ pub access_key: String,
42
+ pub credential_date: String,
43
+ pub region: String,
44
+ pub service: String,
45
+ pub amz_date: String,
46
+ pub expires: String,
47
+ pub signed_headers: String,
48
+ pub signature: String,
49
+ }
50
+
51
+ /// key chars: letters, digits, dash, dot
52
+ fn is_key_char(c: char) -> bool {
53
+ c.is_alphanumeric() || c == '-' || c == '.'
54
+ }
55
+ /// val chars: anything except `&`
56
+ fn is_val_char(c: char) -> bool {
57
+ c != '&'
58
+ }
59
+
60
+ /// Parse the `?` and then a list of `key=val` pairs separated by `&`
61
+ fn query_pairs(input: &str) -> IResult<&str, Vec<(&str, &str)>> {
62
+ // skip everything up to the '?'
63
+ let (input, _) = if let Some(i) = input.find('?') {
64
+ // consume everything up to – and including – the first '?'
65
+ nom::bytes::complete::take::<_, _, nom::error::Error<_>>(i + 1usize)(input)?
66
+ } else {
67
+ // the input *is* the query string, start parsing immediately
68
+ ("", input)
69
+ }; // then parse key=val (& key=val)* until the end
70
+ separated_list1(
71
+ nomchar('&'),
72
+ separated_pair(
73
+ take_while1(is_key_char),
74
+ nomchar('='),
75
+ take_while1(is_val_char),
76
+ ),
77
+ ).parse(input)
78
+ }
79
+
80
+ /// Top-level parser
81
+ pub fn parse_presigned_params(input: &str) -> IResult<&str, PresignedParams> {
82
+ let (rest, pairs) = query_pairs(input)?;
83
+ // build a little map
84
+ let mut m = HashMap::new();
85
+ for (k, v) in pairs {
86
+ // percent-decode the value
87
+ let val = percent_decode_str(v).decode_utf8_lossy().into_owned();
88
+ m.insert(k, val);
89
+ }
90
+
91
+ // pull out each required field (error if missing)
92
+ let algorithm = m.remove("X-Amz-Algorithm" ).ok_or_else(|| miss(rest))?;
93
+ let credential_raw = m.remove("X-Amz-Credential" ).ok_or_else(|| miss(rest))?;
94
+ let amz_date = m.remove("X-Amz-Date" ).ok_or_else(|| miss(rest))?;
95
+ let expires = m.remove("X-Amz-Expires" ).ok_or_else(|| miss(rest))?;
96
+ let signed_headers = m.remove("X-Amz-SignedHeaders" ).ok_or_else(|| miss(rest))?;
97
+ let signature = m.remove("X-Amz-Signature" ).ok_or_else(|| miss(rest))?;
98
+
99
+ // split the credential into its components
100
+ // format is: ACCESSKEY/DATE/REGION/SERVICE/aws4_request
101
+ let mut parts = credential_raw.split('/');
102
+ let access_key = parts.next().unwrap_or("").to_string();
103
+ let credential_date= parts.next().unwrap_or("").to_string();
104
+ let region = parts.next().unwrap_or("").to_string();
105
+ let service = parts.next().unwrap_or("").to_string();
106
+ // ignore the final “aws4_request”
107
+
108
+ Ok((rest, PresignedParams {
109
+ algorithm,
110
+ access_key,
111
+ credential_date,
112
+ region,
113
+ service,
114
+ amz_date,
115
+ expires,
116
+ signed_headers,
117
+ signature,
118
+ }))
119
+ }
120
+
121
+ #[cfg(test)]
122
+ mod tests {
123
+ use super::*;
124
+ use nom::Err;
125
+
126
+ #[test]
127
+ fn test_parse_token_from_header() {
128
+ let input = "AWS4-HMAC-SHA256 Credential=MYLOCAL123/20250417/eu-west-3/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=ec323a7db4d0b8bd27eced3b2bb0d59f9b9dd";
129
+ let result = parse_token_from_header(input);
130
+ assert_eq!(result, Ok(("", ("MYLOCAL123"))));
131
+ }
132
+
133
+ #[test]
134
+ fn parse_token_from_header_success_and_error() {
135
+ let input = "AWS4-HMAC-SHA256 Credential=TOKEN123/20250417/eu-west-1/s3/aws4_request, SignedHeaders=host,Signature=abc";
136
+ let result = parse_token_from_header(input);
137
+ assert!(result.is_ok());
138
+ let (remaining, token) = result.unwrap();
139
+ assert_eq!(token, "TOKEN123");
140
+ assert_eq!(remaining, "");
141
+
142
+ let bad = "NoCredentialHere";
143
+ assert!(parse_token_from_header(bad).is_err());
144
+ }
145
+
146
+
147
+ #[test]
148
+ fn test_parse_valid_scope() {
149
+ let header = "Credential=AKIAEXAMPLE/20250425/us-west-2/s3/aws4_request, SignedHeaders=host;x-amz-date";
150
+ let (rem, (region, service)) = parse_credential_scope(header).expect("parse failed");
151
+ assert_eq!(region, "us-west-2");
152
+ assert_eq!(service, "s3");
153
+ assert!(rem.starts_with(", SignedHeaders"));
154
+ }
155
+
156
+ #[test]
157
+ fn test_parse_invalid_scope() {
158
+ let header = "Credential=AKIAEXAMPLE/20250425/us-west-2/s3/some_request";
159
+ assert!(matches!(parse_credential_scope(header), Err(Err::Error(_))));
160
+ }
161
+
162
+ #[test]
163
+ fn test_parse_with_prefix() {
164
+ let header = "Authorization: AWS4-HMAC-SHA256 Credential=XYZ/20250425/eu-central-1/dynamodb/aws4_request/extra";
165
+ let idx = header.find("Credential=").unwrap();
166
+ let substr = &header[idx..];
167
+ let (rem, (region, service)) = parse_credential_scope(substr).expect("parse failed");
168
+ assert_eq!(region, "eu-central-1");
169
+ assert_eq!(service, "dynamodb");
170
+ assert!(rem.starts_with("/extra"));
171
+ }
172
+
173
+ #[test]
174
+ fn parses_all_fields() {
175
+ let url = "http://localhost:6190/proxy-aws-bucket01/mandelbrot/?\
176
+ X-Amz-Algorithm=AWS4-HMAC-SHA256&\
177
+ X-Amz-Credential=MYLOCAL123%2F20250426%2Feu-west-3%2Fs3%2Faws4_request&\
178
+ X-Amz-Date=20250426T143249Z&\
179
+ X-Amz-Expires=3600&\
180
+ X-Amz-SignedHeaders=host&\
181
+ X-Amz-Signature=53cb3d8a12c8c1078fba3fcd55ced9c93fcdc8e2f98184e9ffea50245f4ebea5";
182
+
183
+ let (_, p) = parse_presigned_params(url).unwrap();
184
+ assert_eq!(p.algorithm, "AWS4-HMAC-SHA256");
185
+ assert_eq!(p.access_key, "MYLOCAL123");
186
+ assert_eq!(p.credential_date, "20250426");
187
+ assert_eq!(p.region, "eu-west-3");
188
+ assert_eq!(p.service, "s3");
189
+ assert_eq!(p.amz_date, "20250426T143249Z");
190
+ assert_eq!(p.expires, "3600");
191
+ assert_eq!(p.signed_headers, "host");
192
+ assert_eq!(p.signature, "53cb3d8a12c8c1078fba3fcd55ced9c93fcdc8e2f98184e9ffea50245f4ebea5");
193
+ }
194
+
195
+ #[test]
196
+ fn fails_if_missing_signature() {
197
+ let url = "https://example.com/?X-Amz-Credential=AK/20250426/us-west-2/s3/aws4_request";
198
+ assert!(parse_presigned_params(url).is_err());
199
+ }
200
+
201
+ #[test]
202
+ fn parses_presigned_params_from_raw_query() {
203
+ // the very same query string that shows up in the log
204
+ let q = "X-Amz-Algorithm=AWS4-HMAC-SHA256&\
205
+ X-Amz-Credential=MYLOCAL123%2F20250426%2Feu-west-3%2Fs3%2Faws4_request&\
206
+ X-Amz-Date=20250426T143249Z&\
207
+ X-Amz-Expires=3600&\
208
+ X-Amz-SignedHeaders=host&\
209
+ X-Amz-Signature=53cb3d8a12c8c1078fba3fcd55ced9c93fcdc8e2f98184e9ffea50245f4ebea5";
210
+
211
+ // ❌ This is what the proxy does today — and it should FAIL until we fix the parser.
212
+ assert!(
213
+ parse_presigned_params(q).is_err(),
214
+ "the parser should reject a query that has no leading '?'"
215
+ );
216
+
217
+ // ✅ This is what the proxy *should* do (or what the parser should accept):
218
+ let wrapped = format!("?{q}");
219
+ let (_, p) = parse_presigned_params(&wrapped)
220
+ .expect("parser must succeed when a leading '?' is present");
221
+
222
+ assert_eq!(p.algorithm, "AWS4-HMAC-SHA256");
223
+ assert_eq!(p.access_key, "MYLOCAL123");
224
+ assert_eq!(p.credential_date, "20250426");
225
+ assert_eq!(p.region, "eu-west-3");
226
+ assert_eq!(p.service, "s3");
227
+ assert_eq!(p.amz_date, "20250426T143249Z");
228
+ assert_eq!(p.expires, "3600");
229
+ assert_eq!(p.signed_headers, "host");
230
+ assert_eq!(p.signature, "53cb3d8a12c8c1078fba3fcd55ced9c93fcdc8e2f98184e9ffea50245f4ebea5");
231
+ }
232
+ }
233
+
234
+
@@ -1,6 +1,6 @@
1
1
  use std::collections::HashMap;
2
2
 
3
- use pyo3::{exceptions, prelude::*, types::{PyDict, PyList}};
3
+ use pyo3::{exceptions, prelude::*};
4
4
 
5
5
 
6
6
  pub fn parse_hmac_list(
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env bash
2
+
3
+ cd /Users/jeroen/projects/mandelbrot
4
+
5
+ echo "generate testfile"
6
+ dd if=/dev/random of=/Users/jeroen/projects/mandelbrot/testfile_10 bs=1M count=10
7
+
8
+ echo "uploading 10MB file."
9
+ aws s3 cp testfile_10 s3://proxy-aws-bucket01/mandelbrot/testfile_10bis --profile osp
10
+ if [ $? -eq 0 ]; then
11
+ echo -e "\033[1;32mOK\033[0m"
12
+ else
13
+ echo -e "\033[1;31mERROR\033[0m"
14
+ fi
15
+ echo "listing .."
16
+ aws s3 ls s3://proxy-aws-bucket01/mandelbrot/testfile_10bis --profile osp
17
+ if [ $? -eq 0 ]; then
18
+ echo -e "\033[1;32mOK\033[0m"
19
+ else
20
+ echo -e "\033[1;31mERROR\033[0m"
21
+ fi
22
+
23
+ echo "download a 10MB file"
24
+ aws s3 cp s3://proxy-aws-bucket01/mandelbrot/testfile_10bis testfile_10bis --profile osp
25
+ if [ $? -eq 0 ]; then
26
+ echo -e "\033[1;32mOK\033[0m"
27
+ else
28
+ echo -e "\033[1;31mERROR\033[0m"
29
+ fi
30
+
31
+
32
+ echo "listing .."
33
+ ls -latrh testfile_10bis
34
+
35
+ if [ $? -eq 0 ]; then
36
+ echo -e "\033[1;32mOK\033[0m"
37
+ else
38
+ echo -e "\033[1;31mERROR\033[0m"
39
+ fi
40
+
41
+
42
+ echo "deleting .."
43
+ aws s3 rm s3://proxy-aws-bucket01/mandelbrot/testfile_10bis --profile osp
44
+ if [ $? -eq 0 ]; then
45
+ echo -e "\033[1;32mOK\033[0m"
46
+ else
47
+ echo -e "\033[1;31mERROR\033[0m"
48
+ fi
49
+
50
+
51
+ echo "listing .."
52
+ aws s3 ls s3://proxy-aws-bucket01/mandelbrot/testfile_10bis --profile osp
53
+ if [ $? -eq 1 ]; then
54
+ echo -e "\033[1;32mOK\033[0m"
55
+ else
56
+ echo -e "\033[1;31mERROR\033[0m"
57
+ fi
@@ -1,82 +0,0 @@
1
- use nom::{
2
- bytes::complete::{tag, take_until}, sequence::{preceded, tuple}, IResult, Parser
3
- };
4
-
5
- pub fn parse_token_from_header(header: &str) -> IResult<&str, &str> {
6
- let (_, token) =
7
- (preceded(tag("AWS4-HMAC-SHA256 Credential="), take_until("/"))).parse(header)?;
8
-
9
- Ok(("", token))
10
- }
11
-
12
- pub fn parse_credential_scope(input: &str) -> IResult<&str, (&str, &str)> {
13
- let (input, _) = take_until("Credential=")(input)?;
14
- let (remaining, (_, _, _, _, _, region, _, service, _)) = (
15
- tag("Credential="), // prefix
16
- take_until("/"), // access key
17
- tag("/"),
18
- take_until("/"), // date
19
- tag("/"),
20
- take_until("/"), // region
21
- tag("/"),
22
- take_until("/aws4_request"),// service
23
- tag("/aws4_request"), // trailing
24
- ).parse(input)?;
25
- Ok((remaining, (region, service)))
26
- }
27
-
28
-
29
- #[cfg(test)]
30
- mod tests {
31
- use super::*;
32
- use nom::Err;
33
-
34
- #[test]
35
- fn test_parse_token_from_header() {
36
- let input = "AWS4-HMAC-SHA256 Credential=MYLOCAL123/20250417/eu-west-3/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=ec323a7db4d0b8bd27eced3b2bb0d59f9b9dd";
37
- let result = parse_token_from_header(input);
38
- assert_eq!(result, Ok(("", ("MYLOCAL123"))));
39
- }
40
-
41
- #[test]
42
- fn parse_token_from_header_success_and_error() {
43
- let input = "AWS4-HMAC-SHA256 Credential=TOKEN123/20250417/eu-west-1/s3/aws4_request, SignedHeaders=host,Signature=abc";
44
- let result = parse_token_from_header(input);
45
- assert!(result.is_ok());
46
- let (remaining, token) = result.unwrap();
47
- assert_eq!(token, "TOKEN123");
48
- assert_eq!(remaining, "");
49
-
50
- let bad = "NoCredentialHere";
51
- assert!(parse_token_from_header(bad).is_err());
52
- }
53
-
54
-
55
- #[test]
56
- fn test_parse_valid_scope() {
57
- let header = "Credential=AKIAEXAMPLE/20250425/us-west-2/s3/aws4_request, SignedHeaders=host;x-amz-date";
58
- let (rem, (region, service)) = parse_credential_scope(header).expect("parse failed");
59
- assert_eq!(region, "us-west-2");
60
- assert_eq!(service, "s3");
61
- assert!(rem.starts_with(", SignedHeaders"));
62
- }
63
-
64
- #[test]
65
- fn test_parse_invalid_scope() {
66
- let header = "Credential=AKIAEXAMPLE/20250425/us-west-2/s3/some_request";
67
- assert!(matches!(parse_credential_scope(header), Err(Err::Error(_))));
68
- }
69
-
70
- #[test]
71
- fn test_parse_with_prefix() {
72
- let header = "Authorization: AWS4-HMAC-SHA256 Credential=XYZ/20250425/eu-central-1/dynamodb/aws4_request/extra";
73
- let idx = header.find("Credential=").unwrap();
74
- let substr = &header[idx..];
75
- let (rem, (region, service)) = parse_credential_scope(substr).expect("parse failed");
76
- assert_eq!(region, "eu-central-1");
77
- assert_eq!(service, "dynamodb");
78
- assert!(rem.starts_with("/extra"));
79
- }
80
- }
81
-
82
-