Skip to main content

object_storage_proxy/parsers/
credentials.rs

1use std::collections::HashMap;
2
3use nom::{
4    IResult, Parser,
5    bytes::complete::{tag, take_until, take_while1},
6    multi::separated_list1,
7    sequence::{preceded, separated_pair},
8};
9
10use nom::character::complete::char as nomchar;
11use nom::error::{Error, ErrorKind, make_error};
12use percent_encoding::percent_decode_str;
13
14fn miss(i: &str) -> nom::Err<Error<&str>> {
15    nom::Err::Error(make_error(i, ErrorKind::Tag))
16}
17
18/// Extract the AWS access key ID from an `Authorization` header value.
19///
20/// Expects the standard `AWS4-HMAC-SHA256 Credential=<ACCESS_KEY>/…` format.
21/// Returns the access key as a borrowed slice of the input.
22///
23/// # Errors
24///
25/// Returns a nom parse error if the header does not start with the expected prefix.
26pub fn parse_token_from_header(header: &str) -> IResult<&str, &str> {
27    let (_, token) =
28        (preceded(tag("AWS4-HMAC-SHA256 Credential="), take_until("/"))).parse(header)?;
29
30    Ok(("", token))
31}
32
33/// Extract the `(region, service)` pair from a `Credential=…` scope string.
34///
35/// The credential scope has the form:
36/// `<ACCESS_KEY>/<DATE>/<REGION>/<SERVICE>/aws4_request`
37///
38/// Works whether the input starts directly at `Credential=` or has other text
39/// before it (e.g. a full `Authorization:` header value).
40///
41/// # Errors
42///
43/// Returns a nom parse error if `Credential=` is absent or the scope does not
44/// contain the mandatory `/aws4_request` trailer.
45pub fn parse_credential_scope(input: &str) -> IResult<&str, (&str, &str)> {
46    let (input, _) = take_until("Credential=")(input)?;
47    let (remaining, (_, _, _, _, _, region, _, service, _)) = (
48        tag("Credential="), // prefix
49        take_until("/"),    // access key
50        tag("/"),
51        take_until("/"), // date
52        tag("/"),
53        take_until("/"), // region
54        tag("/"),
55        take_until("/aws4_request"), // service
56        tag("/aws4_request"),        // trailing
57    )
58        .parse(input)?;
59    Ok((remaining, (region, service)))
60}
61
62/// Structured representation of the query parameters in an AWS presigned URL.
63///
64/// Produced by [`parse_presigned_params`].
65#[derive(Debug, PartialEq)]
66pub struct PresignedParams {
67    /// Signing algorithm, e.g. `"AWS4-HMAC-SHA256"`.
68    pub algorithm: String,
69    /// AWS access key ID extracted from `X-Amz-Credential`.
70    pub access_key: String,
71    /// Short date (`YYYYMMDD`) extracted from `X-Amz-Credential`.
72    pub credential_date: String,
73    /// AWS region extracted from `X-Amz-Credential`.
74    pub region: String,
75    /// AWS service code extracted from `X-Amz-Credential`.
76    pub service: String,
77    /// Full ISO 8601 timestamp from `X-Amz-Date`.
78    pub amz_date: String,
79    /// Validity window in seconds from `X-Amz-Expires`.
80    pub expires: String,
81    /// Semicolon-separated list of signed headers.
82    pub signed_headers: String,
83    /// Hex-encoded request signature.
84    pub signature: String,
85}
86
87/// key chars: letters, digits, dash, dot
88fn is_key_char(c: char) -> bool {
89    c.is_alphanumeric() || c == '-' || c == '.'
90}
91/// val chars: anything except `&`
92fn is_val_char(c: char) -> bool {
93    c != '&'
94}
95
96/// Parse the `?` and then a list of `key=val` pairs separated by `&`
97fn query_pairs(input: &str) -> IResult<&str, Vec<(&str, &str)>> {
98    // skip everything up to the '?'
99    let (input, _) = if let Some(i) = input.find('?') {
100        // consume everything up to – and including – the first '?'
101        nom::bytes::complete::take::<_, _, nom::error::Error<_>>(i + 1usize)(input)?
102    } else {
103        // the input *is* the query string, start parsing immediately
104        ("", input)
105    }; // then parse key=val (& key=val)* until the end
106    separated_list1(
107        nomchar('&'),
108        separated_pair(
109            take_while1(is_key_char),
110            nomchar('='),
111            take_while1(is_val_char),
112        ),
113    )
114    .parse(input)
115}
116
117/// Parse all AWS presigned URL query parameters into a [`PresignedParams`] struct.
118///
119/// `input` must start with a `?` (or be a bare query string without a leading
120/// `?` — in that case the parser expects a full URL-like input and will attempt
121/// to locate the `?` separator).
122///
123/// All `X-Amz-*` values are percent-decoded before being stored.
124///
125/// # Errors
126///
127/// Returns a nom error if any mandatory field (`X-Amz-Algorithm`,
128/// `X-Amz-Credential`, `X-Amz-Date`, `X-Amz-Expires`, `X-Amz-SignedHeaders`,
129/// `X-Amz-Signature`) is absent.
130pub fn parse_presigned_params(input: &str) -> IResult<&str, PresignedParams> {
131    let (rest, pairs) = query_pairs(input)?;
132    // build a little map
133    let mut m = HashMap::new();
134    for (k, v) in pairs {
135        // percent-decode the value
136        let val = percent_decode_str(v).decode_utf8_lossy().into_owned();
137        m.insert(k, val);
138    }
139
140    // pull out each required field (error if missing)
141    let algorithm = m.remove("X-Amz-Algorithm").ok_or_else(|| miss(rest))?;
142    let credential_raw = m.remove("X-Amz-Credential").ok_or_else(|| miss(rest))?;
143    let amz_date = m.remove("X-Amz-Date").ok_or_else(|| miss(rest))?;
144    let expires = m.remove("X-Amz-Expires").ok_or_else(|| miss(rest))?;
145    let signed_headers = m.remove("X-Amz-SignedHeaders").ok_or_else(|| miss(rest))?;
146    let signature = m.remove("X-Amz-Signature").ok_or_else(|| miss(rest))?;
147
148    // split the credential into its components
149    // format is: ACCESSKEY/DATE/REGION/SERVICE/aws4_request
150    let mut parts = credential_raw.split('/');
151    let access_key = parts.next().unwrap_or("").to_string();
152    let credential_date = parts.next().unwrap_or("").to_string();
153    let region = parts.next().unwrap_or("").to_string();
154    let service = parts.next().unwrap_or("").to_string();
155    // ignore the final “aws4_request”
156
157    Ok((
158        rest,
159        PresignedParams {
160            algorithm,
161            access_key,
162            credential_date,
163            region,
164            service,
165            amz_date,
166            expires,
167            signed_headers,
168            signature,
169        },
170    ))
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use nom::Err;
177
178    #[test]
179    fn test_parse_token_from_header() {
180        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";
181        let result = parse_token_from_header(input);
182        assert_eq!(result, Ok(("", ("MYLOCAL123"))));
183    }
184
185    #[test]
186    fn parse_token_from_header_success_and_error() {
187        let input = "AWS4-HMAC-SHA256 Credential=TOKEN123/20250417/eu-west-1/s3/aws4_request, SignedHeaders=host,Signature=abc";
188        let result = parse_token_from_header(input);
189        assert!(result.is_ok());
190        let (remaining, token) = result.unwrap();
191        assert_eq!(token, "TOKEN123");
192        assert_eq!(remaining, "");
193
194        let bad = "NoCredentialHere";
195        assert!(parse_token_from_header(bad).is_err());
196    }
197
198    #[test]
199    fn parse_credential_scope_missing_prefix_fails() {
200        assert!(parse_credential_scope("no-credential-here").is_err());
201    }
202
203    #[test]
204    fn parse_token_from_header_empty_fails() {
205        assert!(parse_token_from_header("").is_err());
206    }
207
208    #[test]
209    fn parse_token_from_header_no_credential_fails() {
210        assert!(parse_token_from_header("AWS4-HMAC-SHA256 SignedHeaders=host").is_err());
211    }
212
213    #[test]
214    fn test_parse_valid_scope() {
215        let header = "Credential=AKIAEXAMPLE/20250425/us-west-2/s3/aws4_request, SignedHeaders=host;x-amz-date";
216        let (rem, (region, service)) = parse_credential_scope(header).expect("parse failed");
217        assert_eq!(region, "us-west-2");
218        assert_eq!(service, "s3");
219        assert!(rem.starts_with(", SignedHeaders"));
220    }
221
222    #[test]
223    fn test_parse_invalid_scope() {
224        let header = "Credential=AKIAEXAMPLE/20250425/us-west-2/s3/some_request";
225        assert!(matches!(parse_credential_scope(header), Err(Err::Error(_))));
226    }
227
228    #[test]
229    fn test_parse_with_prefix() {
230        let header = "Authorization: AWS4-HMAC-SHA256 Credential=XYZ/20250425/eu-central-1/dynamodb/aws4_request/extra";
231        let idx = header.find("Credential=").unwrap();
232        let substr = &header[idx..];
233        let (rem, (region, service)) = parse_credential_scope(substr).expect("parse failed");
234        assert_eq!(region, "eu-central-1");
235        assert_eq!(service, "dynamodb");
236        assert!(rem.starts_with("/extra"));
237    }
238
239    #[test]
240    fn parses_all_fields() {
241        let url = "http://localhost:6190/proxy-aws-bucket01/mandelbrot/?\
242            X-Amz-Algorithm=AWS4-HMAC-SHA256&\
243            X-Amz-Credential=MYLOCAL123%2F20250426%2Feu-west-3%2Fs3%2Faws4_request&\
244            X-Amz-Date=20250426T143249Z&\
245            X-Amz-Expires=3600&\
246            X-Amz-SignedHeaders=host&\
247            X-Amz-Signature=53cb3d8a12c8c1078fba3fcd55ced9c93fcdc8e2f98184e9ffea50245f4ebea5";
248
249        let (_, p) = parse_presigned_params(url).unwrap();
250        assert_eq!(p.algorithm, "AWS4-HMAC-SHA256");
251        assert_eq!(p.access_key, "MYLOCAL123");
252        assert_eq!(p.credential_date, "20250426");
253        assert_eq!(p.region, "eu-west-3");
254        assert_eq!(p.service, "s3");
255        assert_eq!(p.amz_date, "20250426T143249Z");
256        assert_eq!(p.expires, "3600");
257        assert_eq!(p.signed_headers, "host");
258        assert_eq!(
259            p.signature,
260            "53cb3d8a12c8c1078fba3fcd55ced9c93fcdc8e2f98184e9ffea50245f4ebea5"
261        );
262    }
263
264    #[test]
265    fn fails_if_missing_signature() {
266        let url = "https://example.com/?X-Amz-Credential=AK/20250426/us-west-2/s3/aws4_request";
267        assert!(parse_presigned_params(url).is_err());
268    }
269
270    #[test]
271    fn parses_presigned_params_from_raw_query() {
272        // the very same query string that shows up in the log
273        let q = "X-Amz-Algorithm=AWS4-HMAC-SHA256&\
274                 X-Amz-Credential=MYLOCAL123%2F20250426%2Feu-west-3%2Fs3%2Faws4_request&\
275                 X-Amz-Date=20250426T143249Z&\
276                 X-Amz-Expires=3600&\
277                 X-Amz-SignedHeaders=host&\
278                 X-Amz-Signature=53cb3d8a12c8c1078fba3fcd55ced9c93fcdc8e2f98184e9ffea50245f4ebea5";
279
280        // ❌ This is what the proxy does today — and it should FAIL until we fix the parser.
281        assert!(
282            parse_presigned_params(q).is_err(),
283            "the parser should reject a query that has no leading '?'"
284        );
285
286        // ✅ This is what the proxy *should* do (or what the parser should accept):
287        let wrapped = format!("?{q}");
288        let (_, p) = parse_presigned_params(&wrapped)
289            .expect("parser must succeed when a leading '?' is present");
290
291        assert_eq!(p.algorithm, "AWS4-HMAC-SHA256");
292        assert_eq!(p.access_key, "MYLOCAL123");
293        assert_eq!(p.credential_date, "20250426");
294        assert_eq!(p.region, "eu-west-3");
295        assert_eq!(p.service, "s3");
296        assert_eq!(p.amz_date, "20250426T143249Z");
297        assert_eq!(p.expires, "3600");
298        assert_eq!(p.signed_headers, "host");
299        assert_eq!(
300            p.signature,
301            "53cb3d8a12c8c1078fba3fcd55ced9c93fcdc8e2f98184e9ffea50245f4ebea5"
302        );
303    }
304}