1 /*
2  * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License").
5  * You may not use this file except in compliance with the License.
6  * A copy of the License is located at
7  *
8  *  http://aws.amazon.com/apache2.0
9  *
10  * or in the "license" file accompanying this file. This file is distributed
11  * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12  * express or implied. See the License for the specific language governing
13  * permissions and limitations under the License.
14  */
15 
16 package software.amazon.awssdk.http.auth.aws.internal.signer;
17 
18 import static software.amazon.awssdk.utils.StringUtils.lowerCase;
19 
20 import java.util.ArrayList;
21 import java.util.Arrays;
22 import java.util.Collections;
23 import java.util.Comparator;
24 import java.util.List;
25 import java.util.Map;
26 import java.util.SortedMap;
27 import java.util.TreeMap;
28 import software.amazon.awssdk.annotations.Immutable;
29 import software.amazon.awssdk.annotations.SdkInternalApi;
30 import software.amazon.awssdk.http.SdkHttpRequest;
31 import software.amazon.awssdk.http.auth.aws.internal.signer.util.SignerConstant;
32 import software.amazon.awssdk.utils.Pair;
33 import software.amazon.awssdk.utils.StringUtils;
34 import software.amazon.awssdk.utils.http.SdkHttpUtils;
35 
36 /**
37  * A class that represents a canonical request in AWS, as documented:
38  * <p>
39  * https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html#create-canonical-request
40  * </p>
41  */
42 @SdkInternalApi
43 @Immutable
44 public final class V4CanonicalRequest {
45     private static final List<String> HEADERS_TO_IGNORE_IN_LOWER_CASE =
46         Arrays.asList("connection", "x-amzn-trace-id", "user-agent", "expect");
47 
48     private final SdkHttpRequest request;
49     private final String contentHash;
50     private final Options options;
51 
52     // Compute these fields lazily when, and, if needed.
53     private String canonicalUri;
54     private SortedMap<String, List<String>> canonicalParams;
55     private List<Pair<String, List<String>>> canonicalHeaders;
56     private String canonicalQueryString;
57     private String canonicalHeadersString;
58     private String signedHeadersString;
59     private String canonicalRequestString;
60 
61     /**
62      * Create a canonical request.
63      * <p>
64      * Each parameter of a canonical request is set upon creation of this object.
65      * <p>
66      * To get such a parameter (i.e. the canonical request string), simply call the getter for that parameter (i.e.
67      * getCanonicalRequestString())
68      */
V4CanonicalRequest(SdkHttpRequest request, String contentHash, Options options)69     public V4CanonicalRequest(SdkHttpRequest request, String contentHash, Options options) {
70         this.request = request;
71         this.contentHash = contentHash;
72         this.options = options;
73     }
74 
75     /**
76      * Get the string representing which headers are part of the signing process. Header names are separated by a semicolon.
77      */
getSignedHeadersString()78     public String getSignedHeadersString() {
79         if (signedHeadersString == null) {
80             signedHeadersString = getSignedHeadersString(canonicalHeaders());
81         }
82         return signedHeadersString;
83     }
84 
85     /**
86      * Get the canonical request string.
87      */
getCanonicalRequestString()88     public String getCanonicalRequestString() {
89         if (canonicalRequestString == null) {
90             canonicalRequestString = getCanonicalRequestString(request.method().toString(), canonicalUri(),
91                                                                canonicalQueryString(), canonicalHeadersString(),
92                                                                getSignedHeadersString(), contentHash);
93         }
94         return canonicalRequestString;
95     }
96 
canonicalQueryParams()97     private SortedMap<String, List<String>> canonicalQueryParams() {
98         if (canonicalParams == null) {
99             canonicalParams = getCanonicalQueryParams(request);
100         }
101         return canonicalParams;
102     }
103 
canonicalHeaders()104     private List<Pair<String, List<String>>> canonicalHeaders() {
105         if (canonicalHeaders == null) {
106             canonicalHeaders = getCanonicalHeaders(request);
107         }
108         return canonicalHeaders;
109     }
110 
canonicalUri()111     private String canonicalUri() {
112         if (canonicalUri == null) {
113             canonicalUri = getCanonicalUri(request, options);
114         }
115         return canonicalUri;
116     }
117 
canonicalQueryString()118     private String canonicalQueryString() {
119         if (canonicalQueryString == null) {
120             canonicalQueryString = getCanonicalQueryString(canonicalQueryParams());
121         }
122         return canonicalQueryString;
123     }
124 
canonicalHeadersString()125     private String canonicalHeadersString() {
126         if (canonicalHeadersString == null) {
127             canonicalHeadersString = getCanonicalHeadersString(canonicalHeaders());
128         }
129         return canonicalHeadersString;
130     }
131 
132     /**
133      * Get the list of headers that are to be signed.
134      * <p>
135      * If calling from a site with the request object handy, this method should be used instead of passing the headers themselves,
136      * as doing so creates a redundant copy.
137      */
getCanonicalHeaders(SdkHttpRequest request)138     public static List<Pair<String, List<String>>> getCanonicalHeaders(SdkHttpRequest request) {
139         List<Pair<String, List<String>>> result = new ArrayList<>(request.numHeaders());
140 
141         // headers retrieved from the request are already sorted case-insensitively
142         request.forEachHeader((key, value) -> {
143             String lowerCaseHeader = lowerCase(key);
144             if (!HEADERS_TO_IGNORE_IN_LOWER_CASE.contains(lowerCaseHeader)) {
145                 result.add(Pair.of(lowerCaseHeader, value));
146             }
147         });
148 
149         result.sort(Comparator.comparing(Pair::left));
150 
151         return result;
152     }
153 
154     /**
155      * Get the list of headers that are to be signed. The supplied map of headers is expected to be sorted case-insensitively.
156      */
getCanonicalHeaders(Map<String, List<String>> headers)157     public static List<Pair<String, List<String>>> getCanonicalHeaders(Map<String, List<String>> headers) {
158         List<Pair<String, List<String>>> result = new ArrayList<>(headers.size());
159 
160         headers.forEach((key, value) -> {
161             String lowerCaseHeader = lowerCase(key);
162             if (!HEADERS_TO_IGNORE_IN_LOWER_CASE.contains(lowerCaseHeader)) {
163                 result.add(Pair.of(lowerCaseHeader, value));
164             }
165         });
166 
167         result.sort(Comparator.comparing(Pair::left));
168 
169         return result;
170     }
171 
172     /**
173      * Get the string representing the headers that will be signed and their values. The input list is expected to be sorted
174      * case-insensitively.
175      * <p>
176      * The output string will have header names as lower-case, sorted in alphabetical order, and followed by a colon.
177      * <p>
178      * Values are trimmed of any leading/trailing spaces, sequential spaces are converted to single space, and multiple values are
179      * comma separated.
180      * <p>
181      * Each header-value pair is separated by a newline.
182      */
getCanonicalHeadersString(List<Pair<String, List<String>>> canonicalHeaders)183     public static String getCanonicalHeadersString(List<Pair<String, List<String>>> canonicalHeaders) {
184         // 2048 chosen experimentally to avoid always needing to resize the string builder's internal byte array.
185         // The minimal DynamoDB get-item request at the time of testing used ~1100 bytes. 2048 was chosen as the
186         // next-highest power-of-two.
187         StringBuilder result = new StringBuilder(2048);
188         canonicalHeaders.forEach(header -> {
189             result.append(header.left());
190             result.append(":");
191             for (String headerValue : header.right()) {
192                 addAndTrim(result, headerValue);
193                 result.append(",");
194             }
195             result.setLength(result.length() - 1);
196             result.append("\n");
197         });
198         return result.toString();
199     }
200 
201     /**
202      * Get the string representing which headers are part of the signing process. Header names are separated by a semicolon.
203      */
getSignedHeadersString(List<Pair<String, List<String>>> canonicalHeaders)204     public static String getSignedHeadersString(List<Pair<String, List<String>>> canonicalHeaders) {
205         String signedHeadersString;
206         StringBuilder headersString = new StringBuilder(512);
207         for (Pair<String, List<String>> header : canonicalHeaders) {
208             headersString.append(header.left()).append(";");
209         }
210         // get rid of trailing semicolon
211         signedHeadersString = headersString.toString();
212 
213         boolean trimTrailingSemicolon = signedHeadersString.length() > 1 &&
214                                         signedHeadersString.endsWith(";");
215 
216         if (trimTrailingSemicolon) {
217             signedHeadersString = signedHeadersString.substring(0, signedHeadersString.length() - 1);
218         }
219         return signedHeadersString;
220     }
221 
222     /**
223      * Get the canonical request string.
224      * <p>
225      * Each {@link String} parameter is separated by a newline character.
226      */
getCanonicalRequestString(String httpMethod, String canonicalUri, String canonicalParamsString, String canonicalHeadersString, String signedHeadersString, String contentHash)227     private static String getCanonicalRequestString(String httpMethod, String canonicalUri, String canonicalParamsString,
228                                                     String canonicalHeadersString, String signedHeadersString,
229                                                     String contentHash) {
230         return httpMethod + SignerConstant.LINE_SEPARATOR +
231                canonicalUri + SignerConstant.LINE_SEPARATOR +
232                canonicalParamsString + SignerConstant.LINE_SEPARATOR +
233                canonicalHeadersString + SignerConstant.LINE_SEPARATOR +
234                signedHeadersString + SignerConstant.LINE_SEPARATOR +
235                contentHash;
236     }
237 
238     /**
239      * "The addAndTrim function removes excess white space before and after values, and converts sequential spaces to a single
240      * space."
241      * <p>
242      * https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
243      * <p>
244      * The collapse-whitespace logic is equivalent to:
245      * <pre>
246      *     value.replaceAll("\\s+", " ")
247      * </pre>
248      * but does not create a Pattern object that needs to compile the match string; it also prevents us from having to make a
249      * Matcher object as well.
250      */
addAndTrim(StringBuilder result, String value)251     private static void addAndTrim(StringBuilder result, String value) {
252         int valueLength = value.length();
253         if (valueLength == 0) {
254             return;
255         }
256 
257         int start = 0;
258         // Find first non-whitespace
259         while (isWhiteSpace(value.charAt(start))) {
260             ++start;
261             if (start >= valueLength) {
262                 return;
263             }
264         }
265 
266         // Add things word-by-word
267         int lastWordStart = start;
268         boolean lastWasWhitespace = false;
269         for (int i = start; i < valueLength; i++) {
270             char c = value.charAt(i);
271 
272             if (isWhiteSpace(c)) {
273                 if (!lastWasWhitespace) {
274                     // End of word, add word
275                     result.append(value, lastWordStart, i);
276                     lastWasWhitespace = true;
277                 }
278             } else {
279                 if (lastWasWhitespace) {
280                     // Start of new word, add space
281                     result.append(' ');
282                     lastWordStart = i;
283                     lastWasWhitespace = false;
284                 }
285             }
286         }
287 
288         if (!lastWasWhitespace) {
289             result.append(value, lastWordStart, valueLength);
290         }
291     }
292 
293     /**
294      * Get the uri-encoded version of the absolute path component URL.
295      * <p>
296      * If the path is empty, a single-forward slash ('/') is returned.
297      */
getCanonicalUri(SdkHttpRequest request, Options options)298     private static String getCanonicalUri(SdkHttpRequest request, Options options) {
299         String path = options.normalizePath ? request.getUri().normalize().getRawPath()
300                                             : request.encodedPath();
301 
302         if (StringUtils.isEmpty(path)) {
303             return "/";
304         }
305 
306         if (options.doubleUrlEncode) {
307             path = SdkHttpUtils.urlEncodeIgnoreSlashes(path);
308         }
309 
310         if (!path.startsWith("/")) {
311             path += "/";
312         }
313 
314         // Normalization can leave a trailing slash at the end of the resource path,
315         // even if the input path doesn't end with one. Example input: /foo/bar/.
316         // Remove the trailing slash if the input path doesn't end with one.
317         boolean trimTrailingSlash = options.normalizePath &&
318                                     path.length() > 1 &&
319                                     !request.getUri().getPath().endsWith("/") &&
320                                     path.charAt(path.length() - 1) == '/';
321 
322         if (trimTrailingSlash) {
323             path = path.substring(0, path.length() - 1);
324         }
325         return path;
326     }
327 
328     /**
329      * Get the sorted map of query parameters that are to be signed.
330      */
getCanonicalQueryParams(SdkHttpRequest request)331     private static SortedMap<String, List<String>> getCanonicalQueryParams(SdkHttpRequest request) {
332         SortedMap<String, List<String>> sorted = new TreeMap<>();
333 
334         // Signing protocol expects the param values also to be sorted after url
335         // encoding in addition to sorted parameter names.
336         request.forEachRawQueryParameter((key, values) -> {
337             if (StringUtils.isEmpty(key)) {
338                 // Do not sign empty keys.
339                 return;
340             }
341 
342             String encodedParamName = SdkHttpUtils.urlEncode(key);
343 
344             List<String> encodedValues = new ArrayList<>(values.size());
345             for (String value : values) {
346                 String encodedValue = SdkHttpUtils.urlEncode(value);
347 
348                 // Null values should be treated as empty for the purposes of signing, not missing.
349                 // For example "?foo=" instead of "?foo".
350                 String signatureFormattedEncodedValue = encodedValue == null ? "" : encodedValue;
351 
352                 encodedValues.add(signatureFormattedEncodedValue);
353             }
354             Collections.sort(encodedValues);
355             sorted.put(encodedParamName, encodedValues);
356 
357         });
358         return sorted;
359     }
360 
361     /**
362      * Get the string representing query string parameters. Parameters are URL-encoded and separated by an ampersand.
363      * <p>
364      * Reserved characters are percent-encoded, names and values are encoded separately and empty parameters have an equals-sign
365      * appended before encoding.
366      * <p>
367      * After encoding, parameters are sorted alphabetically by key name.
368      * <p>
369      * If no query string is given, an empty string ("") is returned.
370      */
getCanonicalQueryString(SortedMap<String, List<String>> canonicalParams)371     private static String getCanonicalQueryString(SortedMap<String, List<String>> canonicalParams) {
372         if (canonicalParams.isEmpty()) {
373             return "";
374         }
375         StringBuilder stringBuilder = new StringBuilder(512);
376         SdkHttpUtils.flattenQueryParameters(stringBuilder, canonicalParams);
377         return stringBuilder.toString();
378     }
379 
isWhiteSpace(char ch)380     private static boolean isWhiteSpace(char ch) {
381         switch (ch) {
382             case ' ':
383             case '\t':
384             case '\n':
385             case '\u000b':
386             case '\r':
387             case '\f':
388                 return true;
389             default:
390                 return false;
391         }
392     }
393 
394     /**
395      * A class for representing options used when creating a {@link V4CanonicalRequest}
396      */
397     public static class Options {
398         final boolean doubleUrlEncode;
399         final boolean normalizePath;
400 
Options(boolean doubleUrlEncode, boolean normalizePath)401         public Options(boolean doubleUrlEncode, boolean normalizePath) {
402             this.doubleUrlEncode = doubleUrlEncode;
403             this.normalizePath = normalizePath;
404         }
405     }
406 }
407