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