aws_sigv4/http_request/
canonical_request.rs

1/*
2 * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6use crate::date_time::{format_date, format_date_time};
7use crate::http_request::error::CanonicalRequestError;
8use crate::http_request::settings::SessionTokenMode;
9use crate::http_request::settings::UriPathNormalizationMode;
10use crate::http_request::sign::SignableRequest;
11use crate::http_request::uri_path_normalization::normalize_uri_path;
12use crate::http_request::url_escape::percent_encode_path;
13use crate::http_request::{PayloadChecksumKind, SignableBody, SignatureLocation, SigningParams};
14use crate::http_request::{PercentEncodingMode, SigningSettings};
15use crate::sign::v4::sha256_hex_string;
16use crate::SignatureVersion;
17use aws_smithy_http::query_writer::QueryWriter;
18use http0::header::{AsHeaderName, HeaderName, HOST};
19use http0::uri::{Port, Scheme};
20use http0::{HeaderMap, HeaderValue, Uri};
21use std::borrow::Cow;
22use std::cmp::Ordering;
23use std::fmt;
24use std::str::FromStr;
25use std::time::SystemTime;
26
27#[cfg(feature = "sigv4a")]
28pub(crate) mod sigv4a;
29
30pub(crate) mod header {
31    pub(crate) const X_AMZ_CONTENT_SHA_256: &str = "x-amz-content-sha256";
32    pub(crate) const X_AMZ_DATE: &str = "x-amz-date";
33    pub(crate) const X_AMZ_SECURITY_TOKEN: &str = "x-amz-security-token";
34    pub(crate) const X_AMZ_USER_AGENT: &str = "x-amz-user-agent";
35    pub(crate) const X_AMZ_CHECKSUM_MODE: &str = "x-amz-checksum-mode";
36}
37
38pub(crate) mod param {
39    pub(crate) const X_AMZ_ALGORITHM: &str = "X-Amz-Algorithm";
40    pub(crate) const X_AMZ_CREDENTIAL: &str = "X-Amz-Credential";
41    pub(crate) const X_AMZ_DATE: &str = "X-Amz-Date";
42    pub(crate) const X_AMZ_EXPIRES: &str = "X-Amz-Expires";
43    pub(crate) const X_AMZ_SECURITY_TOKEN: &str = "X-Amz-Security-Token";
44    pub(crate) const X_AMZ_SIGNED_HEADERS: &str = "X-Amz-SignedHeaders";
45    pub(crate) const X_AMZ_SIGNATURE: &str = "X-Amz-Signature";
46}
47
48pub(crate) const HMAC_256: &str = "AWS4-HMAC-SHA256";
49
50const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
51const STREAMING_UNSIGNED_PAYLOAD_TRAILER: &str = "STREAMING-UNSIGNED-PAYLOAD-TRAILER";
52
53#[derive(Debug, PartialEq)]
54pub(crate) struct HeaderValues<'a> {
55    pub(crate) content_sha256: Cow<'a, str>,
56    pub(crate) date_time: String,
57    pub(crate) security_token: Option<&'a str>,
58    pub(crate) signed_headers: SignedHeaders,
59    #[cfg(feature = "sigv4a")]
60    pub(crate) region_set: Option<&'a str>,
61}
62
63#[derive(Debug, PartialEq)]
64pub(crate) struct QueryParamValues<'a> {
65    pub(crate) algorithm: &'static str,
66    pub(crate) content_sha256: Cow<'a, str>,
67    pub(crate) credential: String,
68    pub(crate) date_time: String,
69    pub(crate) expires: String,
70    pub(crate) security_token: Option<&'a str>,
71    pub(crate) signed_headers: SignedHeaders,
72    #[cfg(feature = "sigv4a")]
73    pub(crate) region_set: Option<&'a str>,
74}
75
76#[derive(Debug, PartialEq)]
77pub(crate) enum SignatureValues<'a> {
78    Headers(HeaderValues<'a>),
79    QueryParams(QueryParamValues<'a>),
80}
81
82impl<'a> SignatureValues<'a> {
83    pub(crate) fn signed_headers(&self) -> &SignedHeaders {
84        match self {
85            SignatureValues::Headers(values) => &values.signed_headers,
86            SignatureValues::QueryParams(values) => &values.signed_headers,
87        }
88    }
89
90    fn content_sha256(&self) -> &str {
91        match self {
92            SignatureValues::Headers(values) => &values.content_sha256,
93            SignatureValues::QueryParams(values) => &values.content_sha256,
94        }
95    }
96
97    pub(crate) fn as_headers(&self) -> Option<&HeaderValues<'_>> {
98        match self {
99            SignatureValues::Headers(values) => Some(values),
100            _ => None,
101        }
102    }
103
104    #[allow(clippy::result_large_err)]
105    /*
106       QueryParams(QueryParamValues<'a>),
107       --------------------------------- the largest variant contains at least 192 bytes
108
109       Suppressing the Clippy warning, as the variant above is always returned wrapped in `Ok`.
110    */
111    pub(crate) fn into_query_params(self) -> Result<QueryParamValues<'a>, Self> {
112        match self {
113            SignatureValues::QueryParams(values) => Ok(values),
114            _ => Err(self),
115        }
116    }
117}
118
119#[derive(Debug, PartialEq)]
120pub(crate) struct CanonicalRequest<'a> {
121    pub(crate) method: &'a str,
122    pub(crate) path: Cow<'a, str>,
123    pub(crate) params: Option<String>,
124    pub(crate) headers: HeaderMap,
125    pub(crate) values: SignatureValues<'a>,
126}
127
128impl CanonicalRequest<'_> {
129    /// Construct a CanonicalRequest from a [`SignableRequest`] and [`SigningParams`].
130    ///
131    /// The returned canonical request includes information required for signing as well
132    /// as query parameters or header values that go along with the signature in a request.
133    ///
134    /// ## Behavior
135    ///
136    /// There are several settings which alter signing behavior:
137    /// - If a `security_token` is provided as part of the credentials it will be included in the signed headers
138    /// - If `settings.percent_encoding_mode` specifies double encoding, `%` in the URL will be re-encoded as `%25`
139    /// - If `settings.payload_checksum_kind` is XAmzSha256, add a x-amz-content-sha256 with the body
140    ///   checksum. This is the same checksum used as the "payload_hash" in the canonical request
141    /// - If `settings.session_token_mode` specifies X-Amz-Security-Token to be
142    ///   included before calculating the signature, add it, otherwise omit it.
143    /// - `settings.signature_location` determines where the signature will be placed in a request,
144    ///   and also alters the kinds of signing values that go along with it in the request.
145    pub(crate) fn from<'b>(
146        req: &'b SignableRequest<'b>,
147        params: &'b SigningParams<'b>,
148    ) -> Result<CanonicalRequest<'b>, CanonicalRequestError> {
149        let creds = params
150            .credentials()
151            .map_err(|_| CanonicalRequestError::unsupported_identity_type())?;
152        // Path encoding: if specified, re-encode % as %25
153        // Set method and path into CanonicalRequest
154        let path = req.uri().path();
155        let path = match params.settings().uri_path_normalization_mode {
156            UriPathNormalizationMode::Enabled => normalize_uri_path(path),
157            UriPathNormalizationMode::Disabled => Cow::Borrowed(path),
158        };
159        let path = match params.settings().percent_encoding_mode {
160            // The string is already URI encoded, we don't need to encode everything again, just `%`
161            PercentEncodingMode::Double => Cow::Owned(percent_encode_path(&path)),
162            PercentEncodingMode::Single => path,
163        };
164        let payload_hash = Self::payload_hash(req.body());
165
166        let date_time = format_date_time(*params.time());
167        let (signed_headers, canonical_headers) =
168            Self::headers(req, params, &payload_hash, &date_time)?;
169        let signed_headers = SignedHeaders::new(signed_headers);
170
171        let security_token = match params.settings().session_token_mode {
172            SessionTokenMode::Include => creds.session_token(),
173            SessionTokenMode::Exclude => None,
174        };
175
176        let values = match params.settings().signature_location {
177            SignatureLocation::Headers => SignatureValues::Headers(HeaderValues {
178                content_sha256: payload_hash,
179                date_time,
180                security_token,
181                signed_headers,
182                #[cfg(feature = "sigv4a")]
183                region_set: params.region_set(),
184            }),
185            SignatureLocation::QueryParams => {
186                let credential = match params {
187                    SigningParams::V4(params) => {
188                        format!(
189                            "{}/{}/{}/{}/aws4_request",
190                            creds.access_key_id(),
191                            format_date(params.time),
192                            params.region,
193                            params.name,
194                        )
195                    }
196                    #[cfg(feature = "sigv4a")]
197                    SigningParams::V4a(params) => {
198                        format!(
199                            "{}/{}/{}/aws4_request",
200                            creds.access_key_id(),
201                            format_date(params.time),
202                            params.name,
203                        )
204                    }
205                };
206
207                SignatureValues::QueryParams(QueryParamValues {
208                    algorithm: params.algorithm(),
209                    content_sha256: payload_hash,
210                    credential,
211                    date_time,
212                    expires: params
213                        .settings()
214                        .expires_in
215                        .expect("presigning requires expires_in")
216                        .as_secs()
217                        .to_string(),
218                    security_token,
219                    signed_headers,
220                    #[cfg(feature = "sigv4a")]
221                    region_set: params.region_set(),
222                })
223            }
224        };
225
226        let creq = CanonicalRequest {
227            method: req.method(),
228            path,
229            params: Self::params(req.uri(), &values, params.settings()),
230            headers: canonical_headers,
231            values,
232        };
233        Ok(creq)
234    }
235
236    fn headers(
237        req: &SignableRequest<'_>,
238        params: &SigningParams<'_>,
239        payload_hash: &str,
240        date_time: &str,
241    ) -> Result<(Vec<CanonicalHeaderName>, HeaderMap), CanonicalRequestError> {
242        // Header computation:
243        // The canonical request will include headers not present in the input. We need to clone and
244        // normalize the headers from the original request and add:
245        // - host
246        // - x-amz-date
247        // - x-amz-security-token (if provided)
248        // - x-amz-content-sha256 (if requested by signing settings)
249        let mut canonical_headers = HeaderMap::with_capacity(req.headers().len());
250        for (name, value) in req.headers().iter() {
251            // Header names and values need to be normalized according to Step 4 of https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
252            // Using append instead of insert means this will not clobber headers that have the same lowercased name
253            canonical_headers.append(
254                HeaderName::from_str(&name.to_lowercase())?,
255                normalize_header_value(value)?,
256            );
257        }
258
259        Self::insert_host_header(&mut canonical_headers, req.uri());
260
261        let token_header_name = params
262            .settings()
263            .session_token_name_override
264            .unwrap_or(header::X_AMZ_SECURITY_TOKEN);
265
266        if params.settings().signature_location == SignatureLocation::Headers {
267            let creds = params
268                .credentials()
269                .map_err(|_| CanonicalRequestError::unsupported_identity_type())?;
270            Self::insert_date_header(&mut canonical_headers, date_time);
271
272            if let Some(security_token) = creds.session_token() {
273                let mut sec_header = HeaderValue::from_str(security_token)?;
274                sec_header.set_sensitive(true);
275                canonical_headers.insert(token_header_name, sec_header);
276            }
277
278            if params.settings().payload_checksum_kind == PayloadChecksumKind::XAmzSha256 {
279                let header = HeaderValue::from_str(payload_hash)?;
280                canonical_headers.insert(header::X_AMZ_CONTENT_SHA_256, header);
281            }
282
283            #[cfg(feature = "sigv4a")]
284            if let Some(region_set) = params.region_set() {
285                let header = HeaderValue::from_str(region_set)?;
286                canonical_headers.insert(sigv4a::header::X_AMZ_REGION_SET, header);
287            }
288        }
289
290        let mut signed_headers = Vec::with_capacity(canonical_headers.len());
291        for name in canonical_headers.keys() {
292            if let Some(excluded_headers) = params.settings().excluded_headers.as_ref() {
293                if excluded_headers.iter().any(|it| name.as_str() == it) {
294                    continue;
295                }
296            }
297
298            if params.settings().session_token_mode == SessionTokenMode::Exclude
299                && name == HeaderName::from_static(token_header_name)
300            {
301                continue;
302            }
303
304            if params.settings().signature_location == SignatureLocation::QueryParams {
305                // The X-Amz-User-Agent and x-amz-checksum-mode headers should not be signed if this is for a presigned URL
306                if name == HeaderName::from_static(header::X_AMZ_USER_AGENT)
307                    || name == HeaderName::from_static(header::X_AMZ_CHECKSUM_MODE)
308                {
309                    continue;
310                }
311            }
312            signed_headers.push(CanonicalHeaderName(name.clone()));
313        }
314
315        Ok((signed_headers, canonical_headers))
316    }
317
318    fn payload_hash<'b>(body: &'b SignableBody<'b>) -> Cow<'b, str> {
319        // Payload hash computation
320        //
321        // Based on the input body, set the payload_hash of the canonical request:
322        // Either:
323        // - compute a hash
324        // - use the precomputed hash
325        // - use `UnsignedPayload`
326        // - use `UnsignedPayload` for streaming requests
327        // - use `StreamingUnsignedPayloadTrailer` for streaming requests with trailers
328        match body {
329            SignableBody::Bytes(data) => Cow::Owned(sha256_hex_string(data)),
330            SignableBody::Precomputed(digest) => Cow::Borrowed(digest.as_str()),
331            SignableBody::UnsignedPayload => Cow::Borrowed(UNSIGNED_PAYLOAD),
332            SignableBody::StreamingUnsignedPayloadTrailer => {
333                Cow::Borrowed(STREAMING_UNSIGNED_PAYLOAD_TRAILER)
334            }
335        }
336    }
337
338    fn params(
339        uri: &Uri,
340        values: &SignatureValues<'_>,
341        settings: &SigningSettings,
342    ) -> Option<String> {
343        let mut params: Vec<(Cow<'_, str>, Cow<'_, str>)> =
344            form_urlencoded::parse(uri.query().unwrap_or_default().as_bytes()).collect();
345        fn add_param<'a>(params: &mut Vec<(Cow<'a, str>, Cow<'a, str>)>, k: &'a str, v: &'a str) {
346            params.push((Cow::Borrowed(k), Cow::Borrowed(v)));
347        }
348
349        if let SignatureValues::QueryParams(values) = values {
350            add_param(&mut params, param::X_AMZ_DATE, &values.date_time);
351            add_param(&mut params, param::X_AMZ_EXPIRES, &values.expires);
352
353            #[cfg(feature = "sigv4a")]
354            if let Some(regions) = values.region_set {
355                add_param(&mut params, sigv4a::param::X_AMZ_REGION_SET, regions);
356            }
357
358            add_param(&mut params, param::X_AMZ_ALGORITHM, values.algorithm);
359            add_param(&mut params, param::X_AMZ_CREDENTIAL, &values.credential);
360            add_param(
361                &mut params,
362                param::X_AMZ_SIGNED_HEADERS,
363                values.signed_headers.as_str(),
364            );
365
366            if let Some(security_token) = values.security_token {
367                add_param(
368                    &mut params,
369                    settings
370                        .session_token_name_override
371                        .unwrap_or(param::X_AMZ_SECURITY_TOKEN),
372                    security_token,
373                );
374            }
375        }
376
377        // Sort on the _encoded_ key/value pairs
378        let mut params: Vec<(String, String)> = params
379            .into_iter()
380            .map(|x| {
381                use aws_smithy_http::query::fmt_string;
382                let enc_k = fmt_string(&x.0);
383                let enc_v = fmt_string(&x.1);
384                (enc_k, enc_v)
385            })
386            .collect();
387
388        params.sort();
389
390        let mut query = QueryWriter::new(uri);
391        query.clear_params();
392        for (key, value) in params {
393            query.insert_encoded(&key, &value);
394        }
395
396        let query = query.build_query();
397        if query.is_empty() {
398            None
399        } else {
400            Some(query)
401        }
402    }
403
404    fn insert_host_header(
405        canonical_headers: &mut HeaderMap<HeaderValue>,
406        uri: &Uri,
407    ) -> HeaderValue {
408        match canonical_headers.get(&HOST) {
409            Some(header) => header.clone(),
410            None => {
411                let port = uri.port();
412                let scheme = uri.scheme();
413                let authority = uri
414                    .authority()
415                    .expect("request uri authority must be set for signing")
416                    .as_str();
417                let host = uri
418                    .host()
419                    .expect("request uri host must be set for signing");
420
421                // Check if port is default (80 for HTTP, 443 for HTTPS) and if so exclude it from the
422                // Host header when signing since RFC 2616 indicates that the default port should not be
423                // sent in the Host header (and Hyper strips default ports if they are present)
424                // https://datatracker.ietf.org/doc/html/rfc2616#section-14.23
425                // https://github.com/awslabs/aws-sdk-rust/issues/1244
426                let header_value = if is_port_scheme_default(scheme, port) {
427                    host
428                } else {
429                    authority
430                };
431
432                let header = HeaderValue::try_from(header_value)
433                    .expect("endpoint must contain valid header characters");
434                canonical_headers.insert(HOST, header.clone());
435                header
436            }
437        }
438    }
439
440    fn insert_date_header(
441        canonical_headers: &mut HeaderMap<HeaderValue>,
442        date_time: &str,
443    ) -> HeaderValue {
444        let x_amz_date = HeaderName::from_static(header::X_AMZ_DATE);
445        let date_header = HeaderValue::try_from(date_time).expect("date is valid header value");
446        canonical_headers.insert(x_amz_date, date_header.clone());
447        date_header
448    }
449
450    fn header_values_for(&self, key: impl AsHeaderName) -> String {
451        let values: Vec<&str> = self
452            .headers
453            .get_all(key)
454            .into_iter()
455            .map(|value| {
456                std::str::from_utf8(value.as_bytes())
457                    .expect("SDK request header values are valid UTF-8")
458            })
459            .collect();
460        values.join(",")
461    }
462}
463
464impl fmt::Display for CanonicalRequest<'_> {
465    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
466        writeln!(f, "{}", self.method)?;
467        writeln!(f, "{}", self.path)?;
468        writeln!(f, "{}", self.params.as_deref().unwrap_or(""))?;
469        // write out _all_ the headers
470        for header in &self.values.signed_headers().headers {
471            write!(f, "{}:", header.0.as_str())?;
472            writeln!(f, "{}", self.header_values_for(&header.0))?;
473        }
474        writeln!(f)?;
475        // write out the signed headers
476        writeln!(f, "{}", self.values.signed_headers().as_str())?;
477        write!(f, "{}", self.values.content_sha256())?;
478        Ok(())
479    }
480}
481
482/// Removes excess spaces before and after a given byte string, and converts multiple sequential
483/// spaces to a single space e.g. "  Some  example   text  " -> "Some example text".
484///
485/// This function ONLY affects spaces and not other kinds of whitespace.
486fn trim_all(text: &str) -> Cow<'_, str> {
487    let text = text.trim_matches(' ');
488    let requires_filter = text
489        .chars()
490        .zip(text.chars().skip(1))
491        .any(|(a, b)| a == ' ' && b == ' ');
492    if !requires_filter {
493        Cow::Borrowed(text)
494    } else {
495        // The normal trim function will trim non-breaking spaces and other various whitespace chars.
496        // S3 ONLY trims spaces so we use trim_matches to trim spaces only
497        Cow::Owned(
498            text.chars()
499                // Filter out consecutive spaces
500                .zip(text.chars().skip(1).chain(std::iter::once('!')))
501                .filter(|(a, b)| *a != ' ' || *b != ' ')
502                .map(|(a, _)| a)
503                .collect(),
504        )
505    }
506}
507
508/// Works just like [trim_all] but acts on HeaderValues instead of bytes.
509/// Will ensure that the underlying bytes are valid UTF-8.
510fn normalize_header_value(header_value: &str) -> Result<HeaderValue, CanonicalRequestError> {
511    let trimmed_value = trim_all(header_value);
512    HeaderValue::from_str(&trimmed_value).map_err(CanonicalRequestError::from)
513}
514
515#[inline]
516fn is_port_scheme_default(scheme: Option<&Scheme>, port: Option<Port<&str>>) -> bool {
517    if let (Some(scheme), Some(port)) = (scheme, port) {
518        return [("http", "80"), ("https", "443")].contains(&(scheme.as_str(), port.as_str()));
519    }
520
521    false
522}
523
524#[derive(Debug, PartialEq, Default)]
525pub(crate) struct SignedHeaders {
526    headers: Vec<CanonicalHeaderName>,
527    formatted: String,
528}
529
530impl SignedHeaders {
531    fn new(mut headers: Vec<CanonicalHeaderName>) -> Self {
532        headers.sort();
533        let formatted = Self::fmt(&headers);
534        SignedHeaders { headers, formatted }
535    }
536
537    fn fmt(headers: &[CanonicalHeaderName]) -> String {
538        let mut value = String::new();
539        let mut iter = headers.iter().peekable();
540        while let Some(next) = iter.next() {
541            value += next.0.as_str();
542            if iter.peek().is_some() {
543                value.push(';');
544            }
545        }
546        value
547    }
548
549    pub(crate) fn as_str(&self) -> &str {
550        &self.formatted
551    }
552}
553
554impl fmt::Display for SignedHeaders {
555    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
556        write!(f, "{}", self.formatted)
557    }
558}
559
560#[derive(Debug, PartialEq, Eq, Clone)]
561struct CanonicalHeaderName(HeaderName);
562
563impl PartialOrd for CanonicalHeaderName {
564    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
565        Some(self.cmp(other))
566    }
567}
568
569impl Ord for CanonicalHeaderName {
570    fn cmp(&self, other: &Self) -> Ordering {
571        self.0.as_str().cmp(other.0.as_str())
572    }
573}
574
575#[derive(PartialEq, Debug, Clone)]
576pub(crate) struct SigningScope<'a> {
577    pub(crate) time: SystemTime,
578    pub(crate) region: &'a str,
579    pub(crate) service: &'a str,
580}
581
582impl SigningScope<'_> {
583    pub(crate) fn v4a_display(&self) -> String {
584        format!("{}/{}/aws4_request", format_date(self.time), self.service)
585    }
586}
587
588impl fmt::Display for SigningScope<'_> {
589    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
590        write!(
591            f,
592            "{}/{}/{}/aws4_request",
593            format_date(self.time),
594            self.region,
595            self.service
596        )
597    }
598}
599
600#[derive(PartialEq, Debug, Clone)]
601pub(crate) struct StringToSign<'a> {
602    pub(crate) algorithm: &'static str,
603    pub(crate) scope: SigningScope<'a>,
604    pub(crate) time: SystemTime,
605    pub(crate) region: &'a str,
606    pub(crate) service: &'a str,
607    pub(crate) hashed_creq: &'a str,
608    signature_version: SignatureVersion,
609}
610
611impl<'a> StringToSign<'a> {
612    pub(crate) fn new_v4(
613        time: SystemTime,
614        region: &'a str,
615        service: &'a str,
616        hashed_creq: &'a str,
617    ) -> Self {
618        let scope = SigningScope {
619            time,
620            region,
621            service,
622        };
623        Self {
624            algorithm: HMAC_256,
625            scope,
626            time,
627            region,
628            service,
629            hashed_creq,
630            signature_version: SignatureVersion::V4,
631        }
632    }
633
634    #[cfg(feature = "sigv4a")]
635    pub(crate) fn new_v4a(
636        time: SystemTime,
637        region_set: &'a str,
638        service: &'a str,
639        hashed_creq: &'a str,
640    ) -> Self {
641        use crate::sign::v4a::ECDSA_256;
642
643        let scope = SigningScope {
644            time,
645            region: region_set,
646            service,
647        };
648        Self {
649            algorithm: ECDSA_256,
650            scope,
651            time,
652            region: region_set,
653            service,
654            hashed_creq,
655            signature_version: SignatureVersion::V4a,
656        }
657    }
658}
659
660impl fmt::Display for StringToSign<'_> {
661    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
662        write!(
663            f,
664            "{}\n{}\n{}\n{}",
665            self.algorithm,
666            format_date_time(self.time),
667            match self.signature_version {
668                SignatureVersion::V4 => self.scope.to_string(),
669                SignatureVersion::V4a => self.scope.v4a_display(),
670            },
671            self.hashed_creq
672        )
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use crate::date_time::test_parsers::parse_date_time;
679    use crate::http_request::canonical_request::{
680        normalize_header_value, trim_all, CanonicalRequest, SigningScope, StringToSign,
681    };
682    use crate::http_request::test;
683    use crate::http_request::test::SigningSuiteTest;
684    use crate::http_request::{
685        PayloadChecksumKind, SessionTokenMode, SignableBody, SignableRequest, SignatureLocation,
686        SigningParams, SigningSettings,
687    };
688    use crate::sign::v4;
689    use crate::sign::v4::sha256_hex_string;
690    use aws_credential_types::Credentials;
691    use aws_smithy_http::query_writer::QueryWriter;
692    use aws_smithy_runtime_api::client::identity::Identity;
693    use http0::{HeaderValue, Uri};
694    use pretty_assertions::assert_eq;
695    use proptest::{prelude::*, proptest};
696    use std::borrow::Cow;
697    use std::time::Duration;
698
699    fn signing_params(identity: &Identity, settings: SigningSettings) -> SigningParams<'_> {
700        v4::signing_params::Builder::default()
701            .identity(identity)
702            .region("test-region")
703            .name("testservicename")
704            .time(parse_date_time("20210511T154045Z").unwrap())
705            .settings(settings)
706            .build()
707            .unwrap()
708            .into()
709    }
710
711    #[test]
712    fn test_repeated_header() {
713        let test = test::SigningSuiteTest::v4("get-vanilla-query-order-key-case");
714        let mut req = test.request();
715        req.headers.push((
716            "x-amz-object-attributes".to_string(),
717            "Checksum".to_string(),
718        ));
719        req.headers.push((
720            "x-amz-object-attributes".to_string(),
721            "ObjectSize".to_string(),
722        ));
723        let req = SignableRequest::from(&req);
724        let settings = SigningSettings {
725            payload_checksum_kind: PayloadChecksumKind::XAmzSha256,
726            session_token_mode: SessionTokenMode::Exclude,
727            ..Default::default()
728        };
729        let identity = Credentials::for_tests().into();
730        let signing_params = signing_params(&identity, settings);
731        let creq = CanonicalRequest::from(&req, &signing_params).unwrap();
732
733        assert_eq!(
734            creq.values.signed_headers().to_string(),
735            "host;x-amz-content-sha256;x-amz-date;x-amz-object-attributes"
736        );
737        assert_eq!(
738            creq.header_values_for("x-amz-object-attributes"),
739            "Checksum,ObjectSize",
740        );
741    }
742
743    #[test]
744    fn test_host_header_properly_handles_ports() {
745        fn host_header_test_setup(endpoint: String) -> String {
746            let test = SigningSuiteTest::v4("get-vanilla");
747            let mut req = test.request();
748            req.uri = endpoint;
749            let req = SignableRequest::from(&req);
750            let settings = SigningSettings {
751                payload_checksum_kind: PayloadChecksumKind::XAmzSha256,
752                session_token_mode: SessionTokenMode::Exclude,
753                ..Default::default()
754            };
755            let identity = Credentials::for_tests().into();
756            let signing_params = signing_params(&identity, settings);
757            let creq = CanonicalRequest::from(&req, &signing_params).unwrap();
758            creq.header_values_for("host")
759        }
760
761        // HTTP request with 80 port should not be signed with that port
762        let http_80_host_header = host_header_test_setup("http://localhost:80".into());
763        assert_eq!(http_80_host_header, "localhost",);
764
765        // HTTP request with non-80 port should be signed with that port
766        let http_1234_host_header = host_header_test_setup("http://localhost:1234".into());
767        assert_eq!(http_1234_host_header, "localhost:1234",);
768
769        // HTTPS request with 443 port should not be signed with that port
770        let https_443_host_header = host_header_test_setup("https://localhost:443".into());
771        assert_eq!(https_443_host_header, "localhost",);
772
773        // HTTPS request with non-443 port should be signed with that port
774        let https_1234_host_header = host_header_test_setup("https://localhost:1234".into());
775        assert_eq!(https_1234_host_header, "localhost:1234",);
776    }
777
778    #[test]
779    fn test_set_xamz_sha_256() {
780        let test = SigningSuiteTest::v4("get-vanilla-query-order-key-case");
781        let req = test.request();
782        let req = SignableRequest::from(&req);
783        let settings = SigningSettings {
784            payload_checksum_kind: PayloadChecksumKind::XAmzSha256,
785            session_token_mode: SessionTokenMode::Exclude,
786            ..Default::default()
787        };
788        let identity = Credentials::for_tests().into();
789        let mut signing_params = signing_params(&identity, settings);
790        let creq = CanonicalRequest::from(&req, &signing_params).unwrap();
791        assert_eq!(
792            creq.values.content_sha256(),
793            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
794        );
795        // assert that the sha256 header was added
796        assert_eq!(
797            creq.values.signed_headers().as_str(),
798            "host;x-amz-content-sha256;x-amz-date"
799        );
800
801        signing_params.set_payload_checksum_kind(PayloadChecksumKind::NoHeader);
802        let creq = CanonicalRequest::from(&req, &signing_params).unwrap();
803        assert_eq!(creq.values.signed_headers().as_str(), "host;x-amz-date");
804    }
805
806    #[test]
807    fn test_unsigned_payload() {
808        let test = SigningSuiteTest::v4("get-vanilla-query-order-key-case");
809        let mut req = test.request();
810        req.set_body(SignableBody::UnsignedPayload);
811        let req: SignableRequest<'_> = SignableRequest::from(&req);
812
813        let settings = SigningSettings {
814            payload_checksum_kind: PayloadChecksumKind::XAmzSha256,
815            ..Default::default()
816        };
817        let identity = Credentials::for_tests().into();
818        let signing_params = signing_params(&identity, settings);
819        let creq = CanonicalRequest::from(&req, &signing_params).unwrap();
820        assert_eq!(creq.values.content_sha256(), "UNSIGNED-PAYLOAD");
821        assert!(creq.to_string().ends_with("UNSIGNED-PAYLOAD"));
822    }
823
824    #[test]
825    fn test_precomputed_payload() {
826        let payload_hash = "44ce7dd67c959e0d3524ffac1771dfbba87d2b6b4b4e99e42034a8b803f8b072";
827        let test = SigningSuiteTest::v4("get-vanilla-query-order-key-case");
828        let mut req = test.request();
829        req.set_body(SignableBody::Precomputed(String::from(payload_hash)));
830        let req = SignableRequest::from(&req);
831        let settings = SigningSettings {
832            payload_checksum_kind: PayloadChecksumKind::XAmzSha256,
833            ..Default::default()
834        };
835        let identity = Credentials::for_tests().into();
836        let signing_params = signing_params(&identity, settings);
837        let creq = CanonicalRequest::from(&req, &signing_params).unwrap();
838        assert_eq!(creq.values.content_sha256(), payload_hash);
839        assert!(creq.to_string().ends_with(payload_hash));
840    }
841
842    #[test]
843    fn test_generate_scope() {
844        let expected = "20150830/us-east-1/iam/aws4_request\n";
845        let scope = SigningScope {
846            time: parse_date_time("20150830T123600Z").unwrap(),
847            region: "us-east-1",
848            service: "iam",
849        };
850        assert_eq!(format!("{}\n", scope), expected);
851    }
852
853    #[test]
854    fn test_string_to_sign() {
855        let time = parse_date_time("20150830T123600Z").unwrap();
856        let test = SigningSuiteTest::v4("get-vanilla-query-order-key-case");
857        let creq = test.canonical_request(SignatureLocation::Headers);
858        let expected_sts = test.string_to_sign(SignatureLocation::Headers);
859        let encoded = sha256_hex_string(creq.as_bytes());
860
861        let actual = StringToSign::new_v4(time, "us-east-1", "service", &encoded);
862        assert_eq!(expected_sts, actual.to_string());
863    }
864
865    #[test]
866    fn test_digest_of_canonical_request() {
867        let test = SigningSuiteTest::v4("get-vanilla-query-order-key-case");
868        let creq = test.canonical_request(SignatureLocation::Headers);
869        let expected = "816cd5b414d056048ba4f7c5386d6e0533120fb1fcfa93762cf0fc39e2cf19e0";
870        let actual = sha256_hex_string(creq.as_bytes());
871        assert_eq!(expected, actual);
872    }
873
874    #[test]
875    fn test_double_url_encode_path() {
876        let test = SigningSuiteTest::v4("double-encode-path");
877        let req = test.request();
878        let req = SignableRequest::from(&req);
879        let identity = Credentials::for_tests().into();
880        let signing_params = signing_params(&identity, SigningSettings::default());
881        let creq = CanonicalRequest::from(&req, &signing_params).unwrap();
882
883        let expected = test.canonical_request(SignatureLocation::Headers);
884        let actual = format!("{}", creq);
885        assert_eq!(actual, expected);
886    }
887
888    #[test]
889    fn test_double_url_encode() {
890        let test = SigningSuiteTest::v4("double-url-encode");
891        let req = test.request();
892        let req = SignableRequest::from(&req);
893        let identity = Credentials::for_tests().into();
894        let signing_params = signing_params(&identity, SigningSettings::default());
895        let creq = CanonicalRequest::from(&req, &signing_params).unwrap();
896        let expected = test.canonical_request(SignatureLocation::Headers);
897        let actual = format!("{}", creq);
898        assert_eq!(actual, expected);
899    }
900
901    #[test]
902    fn test_tilde_in_uri() {
903        let req = http0::Request::builder()
904            .uri("https://s3.us-east-1.amazonaws.com/my-bucket?list-type=2&prefix=~objprefix&single&k=&unreserved=-_.~").body("").unwrap().into();
905        let req = SignableRequest::from(&req);
906        let identity = Credentials::for_tests().into();
907        let signing_params = signing_params(&identity, SigningSettings::default());
908        let creq = CanonicalRequest::from(&req, &signing_params).unwrap();
909        assert_eq!(
910            Some("k=&list-type=2&prefix=~objprefix&single=&unreserved=-_.~"),
911            creq.params.as_deref(),
912        );
913    }
914
915    #[test]
916    fn test_signing_urls_with_percent_encoded_query_strings() {
917        let all_printable_ascii_chars: String = (32u8..127).map(char::from).collect();
918        let uri = Uri::from_static("https://s3.us-east-1.amazonaws.com/my-bucket");
919
920        let mut query_writer = QueryWriter::new(&uri);
921        query_writer.insert("list-type", "2");
922        query_writer.insert("prefix", &all_printable_ascii_chars);
923
924        let req = http0::Request::builder()
925            .uri(query_writer.build_uri())
926            .body("")
927            .unwrap()
928            .into();
929        let req = SignableRequest::from(&req);
930        let identity = Credentials::for_tests().into();
931        let signing_params = signing_params(&identity, SigningSettings::default());
932        let creq = CanonicalRequest::from(&req, &signing_params).unwrap();
933
934        let expected = "list-type=2&prefix=%20%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F0123456789%3A%3B%3C%3D%3E%3F%40ABCDEFGHIJKLMNOPQRSTUVWXYZ%5B%5C%5D%5E_%60abcdefghijklmnopqrstuvwxyz%7B%7C%7D~";
935        let actual = creq.params.unwrap();
936        assert_eq!(expected, actual);
937    }
938
939    #[test]
940    fn test_omit_session_token() {
941        let test = SigningSuiteTest::v4("get-vanilla-query-order-key-case");
942        let req = test.request();
943        let req = SignableRequest::from(&req);
944        let settings = SigningSettings {
945            session_token_mode: SessionTokenMode::Include,
946            ..Default::default()
947        };
948        let identity = Credentials::for_tests_with_session_token().into();
949        let mut signing_params = signing_params(&identity, settings);
950
951        let creq = CanonicalRequest::from(&req, &signing_params).unwrap();
952        assert_eq!(
953            creq.values.signed_headers().as_str(),
954            "host;x-amz-date;x-amz-security-token"
955        );
956        assert_eq!(
957            creq.headers.get("x-amz-security-token").unwrap(),
958            "notarealsessiontoken"
959        );
960
961        signing_params.set_session_token_mode(SessionTokenMode::Exclude);
962        let creq = CanonicalRequest::from(&req, &signing_params).unwrap();
963        assert_eq!(
964            creq.headers.get("x-amz-security-token").unwrap(),
965            "notarealsessiontoken"
966        );
967        assert_eq!(creq.values.signed_headers().as_str(), "host;x-amz-date");
968    }
969
970    // It should exclude authorization, user-agent, x-amzn-trace-id, and transfer-encoding headers from presigning
971    #[test]
972    fn non_presigning_header_exclusion() {
973        let request = http0::Request::builder()
974            .uri("https://some-endpoint.some-region.amazonaws.com")
975            .header("authorization", "test-authorization")
976            .header("content-type", "application/xml")
977            .header("content-length", "0")
978            .header("user-agent", "test-user-agent")
979            .header("x-amzn-trace-id", "test-trace-id")
980            .header("x-amz-user-agent", "test-user-agent")
981            .header("transfer-encoding", "chunked")
982            .body("")
983            .unwrap()
984            .into();
985        let request = SignableRequest::from(&request);
986
987        let settings = SigningSettings {
988            signature_location: SignatureLocation::Headers,
989            ..Default::default()
990        };
991
992        let identity = Credentials::for_tests().into();
993        let signing_params = signing_params(&identity, settings);
994        let canonical = CanonicalRequest::from(&request, &signing_params).unwrap();
995
996        let values = canonical.values.as_headers().unwrap();
997        assert_eq!(
998            "content-length;content-type;host;x-amz-date;x-amz-user-agent",
999            values.signed_headers.as_str()
1000        );
1001    }
1002
1003    // It should exclude authorization, user-agent, x-amz-user-agent, x-amzn-trace-id, and transfer-encoding headers from presigning
1004    #[test]
1005    fn presigning_header_exclusion() {
1006        let request = http0::Request::builder()
1007            .uri("https://some-endpoint.some-region.amazonaws.com")
1008            .header("authorization", "test-authorization")
1009            .header("content-type", "application/xml")
1010            .header("content-length", "0")
1011            .header("user-agent", "test-user-agent")
1012            .header("x-amzn-trace-id", "test-trace-id")
1013            .header("x-amz-user-agent", "test-user-agent")
1014            .header("transfer-encoding", "chunked")
1015            .body("")
1016            .unwrap()
1017            .into();
1018        let request = SignableRequest::from(&request);
1019
1020        let settings = SigningSettings {
1021            signature_location: SignatureLocation::QueryParams,
1022            expires_in: Some(Duration::from_secs(30)),
1023            ..Default::default()
1024        };
1025
1026        let identity = Credentials::for_tests().into();
1027        let signing_params = signing_params(&identity, settings);
1028        let canonical = CanonicalRequest::from(&request, &signing_params).unwrap();
1029
1030        let values = canonical.values.into_query_params().unwrap();
1031        assert_eq!(
1032            "content-length;content-type;host",
1033            values.signed_headers.as_str()
1034        );
1035    }
1036
1037    #[allow(clippy::ptr_arg)] // The proptest macro requires this arg to be a Vec instead of a slice.
1038    fn valid_input(input: &Vec<String>) -> bool {
1039        [
1040            "content-length".to_owned(),
1041            "content-type".to_owned(),
1042            "host".to_owned(),
1043        ]
1044        .iter()
1045        .all(|element| !input.contains(element))
1046    }
1047
1048    proptest! {
1049        #[test]
1050        fn presigning_header_exclusion_with_explicit_exclusion_list_specified(
1051            excluded_headers in prop::collection::vec("[a-z]{1,20}", 1..10).prop_filter(
1052                "`excluded_headers` should pass the `valid_input` check",
1053                valid_input,
1054            )
1055        ) {
1056            let mut request_builder = http0::Request::builder()
1057                .uri("https://some-endpoint.some-region.amazonaws.com")
1058                .header("content-type", "application/xml")
1059                .header("content-length", "0");
1060            for key in &excluded_headers {
1061                request_builder = request_builder.header(key, "value");
1062            }
1063            let request = request_builder.body("").unwrap().into();
1064
1065            let request = SignableRequest::from(&request);
1066
1067            let settings = SigningSettings {
1068                signature_location: SignatureLocation::QueryParams,
1069                expires_in: Some(Duration::from_secs(30)),
1070                excluded_headers: Some(
1071                    excluded_headers
1072                        .into_iter()
1073                        .map(std::borrow::Cow::Owned)
1074                        .collect(),
1075                ),
1076                ..Default::default()
1077            };
1078
1079        let identity = Credentials::for_tests().into();
1080        let signing_params = signing_params(&identity, settings);
1081            let canonical = CanonicalRequest::from(&request, &signing_params).unwrap();
1082
1083            let values = canonical.values.into_query_params().unwrap();
1084            assert_eq!(
1085                "content-length;content-type;host",
1086                values.signed_headers.as_str()
1087            );
1088        }
1089    }
1090
1091    #[test]
1092    fn test_trim_all_handles_spaces_correctly() {
1093        assert_eq!(Cow::Borrowed("don't touch me"), trim_all("don't touch me"));
1094        assert_eq!("trim left", trim_all("   trim left"));
1095        assert_eq!("trim right", trim_all("trim right "));
1096        assert_eq!("trim both", trim_all("   trim both  "));
1097        assert_eq!("", trim_all(" "));
1098        assert_eq!("", trim_all("  "));
1099        assert_eq!("a b", trim_all(" a   b "));
1100        assert_eq!("Some example text", trim_all("  Some  example   text  "));
1101    }
1102
1103    #[test]
1104    fn test_trim_all_ignores_other_forms_of_whitespace() {
1105        // \xA0 is a non-breaking space character
1106        assert_eq!(
1107            "\t\u{A0}Some\u{A0} example \u{A0}text\u{A0}\n",
1108            trim_all("\t\u{A0}Some\u{A0}     example   \u{A0}text\u{A0}\n")
1109        );
1110    }
1111
1112    #[test]
1113    fn trim_spaces_works_on_single_characters() {
1114        assert_eq!(trim_all("2").as_ref(), "2");
1115    }
1116
1117    proptest! {
1118        #[test]
1119        fn test_trim_all_doesnt_elongate_strings(s in ".*") {
1120            assert!(trim_all(&s).len() <= s.len())
1121        }
1122
1123        #[test]
1124        fn test_normalize_header_value_works_on_valid_header_value(v in (".*")) {
1125            assert_eq!(normalize_header_value(&v).is_ok(), HeaderValue::from_str(&v).is_ok());
1126        }
1127
1128        #[test]
1129        fn test_trim_all_does_nothing_when_there_are_no_spaces(s in "[^ ]*") {
1130            assert_eq!(trim_all(&s).as_ref(), s);
1131        }
1132    }
1133}