Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions services/cloudfront/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@
<artifactId>aws-xml-protocol</artifactId>
<version>${awsjavasdk.version}</version>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>json-utils</artifactId>
<version>${awsjavasdk.version}</version>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>protocol-core</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import java.util.Base64;
import software.amazon.awssdk.annotations.SdkInternalApi;
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.protocols.jsoncore.JsonWriter;
import software.amazon.awssdk.services.cloudfront.internal.auth.Pem;
import software.amazon.awssdk.utils.IoUtils;
import software.amazon.awssdk.utils.StringUtils;
Expand All @@ -57,11 +58,29 @@ private SigningUtils() {
* >Setting signed cookies using a canned policy</a>.
*/
public static String buildCannedPolicy(String resourceUrl, Instant expirationDate) {
return "{\"Statement\":[{\"Resource\":\""
+ resourceUrl
+ "\",\"Condition\":{\"DateLessThan\":{\"AWS:EpochTime\":"
+ expirationDate.getEpochSecond()
+ "}}}]}";
Validate.notNull(resourceUrl, "resourceUrl must not be null");
Validate.notNull(expirationDate, "expirationDate must not be null");
validateInput(resourceUrl, "resourceUrl");

JsonWriter writer = JsonWriter.create();
writer.writeStartObject()
.writeFieldName("Statement")
.writeStartArray()
.writeStartObject()
.writeFieldName("Resource")
.writeValue(resourceUrl)
.writeFieldName("Condition")
.writeStartObject()
.writeFieldName("DateLessThan")
.writeStartObject()
.writeFieldName("AWS:EpochTime")
.writeValue(expirationDate.getEpochSecond())
.writeEndObject()
.writeEndObject()
.writeEndObject()
.writeEndArray()
.writeEndObject();
return new String(writer.getBytes(), UTF_8);
}

/**
Expand All @@ -75,24 +94,72 @@ public static String buildCannedPolicy(String resourceUrl, Instant expirationDat
* >Setting signed cookies using a custom policy</a>.
*/
public static String buildCustomPolicy(String resourceUrl, Instant activeDate, Instant expirationDate,
String ipAddress) {
return "{\"Statement\": [{"
+ "\"Resource\":\""
+ resourceUrl
+ "\""
+ ",\"Condition\":{"
+ "\"DateLessThan\":{\"AWS:EpochTime\":"
+ expirationDate.getEpochSecond()
+ "}"
+ (ipAddress == null
? ""
: ",\"IpAddress\":{\"AWS:SourceIp\":\"" + ipAddress + "\"}"
)
+ (activeDate == null
? ""
: ",\"DateGreaterThan\":{\"AWS:EpochTime\":" + activeDate.getEpochSecond() + "}"
)
+ "}}]}";
String ipAddress) {
Validate.notNull(resourceUrl, "resourceUrl must not be null");
Validate.notNull(expirationDate, "expirationDate must not be null");
validateInput(resourceUrl, "resourceUrl");
if (ipAddress != null) {
validateInput(ipAddress, "ipAddress");
}

JsonWriter writer = JsonWriter.create();
writer.writeStartObject()
.writeFieldName("Statement")
.writeStartArray()
.writeStartObject()
.writeFieldName("Resource")
.writeValue(resourceUrl)
.writeFieldName("Condition")
.writeStartObject()
.writeFieldName("DateLessThan")
.writeStartObject()
.writeFieldName("AWS:EpochTime")
.writeValue(expirationDate.getEpochSecond())
.writeEndObject();

if (ipAddress != null) {
writer.writeFieldName("IpAddress")
.writeStartObject()
.writeFieldName("AWS:SourceIp")
.writeValue(ipAddress)
.writeEndObject();
}

if (activeDate != null) {
writer.writeFieldName("DateGreaterThan")
.writeStartObject()
.writeFieldName("AWS:EpochTime")
.writeValue(activeDate.getEpochSecond())
.writeEndObject();
}

writer.writeEndObject()
.writeEndObject()
.writeEndArray()
.writeEndObject();

return new String(writer.getBytes(), UTF_8);
}

/**
* Validates that the input does not contain characters that could be used for JSON injection attacks.
* Double quotes, backslashes, and control characters should never appear in valid CloudFront resource URLs
* or IP addresses.
*
* @param input the input string to validate
* @param paramName the parameter name for error messages
* @throws IllegalArgumentException if the input contains invalid characters
*/
private static void validateInput(String input, String paramName) {
for (int i = 0; i < input.length(); i++) {
char c = input.charAt(i);
if (c == '"' || c == '\\' || Character.isISOControl(c)) {
throw new IllegalArgumentException(
paramName + " contains invalid characters. The character '" + c + "' at position " + i +
" is not allowed. URLs and IP addresses should be properly encoded and must not contain " +
"double quotes, backslashes, or control characters.");
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ void getSignedURLWithCustomPolicy_policyResourceUrlShouldHandleVariousPatterns(
StringJoiner conditions = new StringJoiner(",", "{", "}");
conditions.add("\"DateLessThan\":{\"AWS:EpochTime\":" + expiration.getEpochSecond() + "}");

expectedPolicy.append("{\"Statement\": [{")
expectedPolicy.append("{\"Statement\":[{")
.append("\"Resource\":\"").append(expectedResource).append("\",")
.append("\"Condition\":").append(conditions)
.append("}]}");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

package software.amazon.awssdk.services.cloudfront.internal.utils;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.time.Instant;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

class SigningUtilsTest {

private static final Instant EXPIRATION = Instant.ofEpochSecond(1704067200);
private static final Instant ACTIVE_DATE = Instant.ofEpochSecond(1640995200);
private static final String VALID_URL = "https://d111111abcdef8.cloudfront.net/s3ObjectKey";
private static final String VALID_IP = "192.168.1.0/24";

@Test
void buildCannedPolicy_withValidUrl_producesValidJson() {
String policy = SigningUtils.buildCannedPolicy(VALID_URL, EXPIRATION);

assertThat(policy).contains("\"Resource\":\"" + VALID_URL + "\"");
assertThat(policy).contains("\"AWS:EpochTime\":" + EXPIRATION.getEpochSecond());
// Verify it's valid JSON structure
assertThat(policy).startsWith("{");
assertThat(policy).endsWith("}");
}

@Test
void buildCustomPolicy_withAllParameters_producesValidJson() {
String policy = SigningUtils.buildCustomPolicy(VALID_URL, ACTIVE_DATE, EXPIRATION, VALID_IP);

assertThat(policy).contains("\"Resource\":\"" + VALID_URL + "\"");
assertThat(policy).contains("\"DateLessThan\"");
assertThat(policy).contains("\"DateGreaterThan\"");
assertThat(policy).contains("\"IpAddress\"");
assertThat(policy).contains("\"AWS:SourceIp\":\"" + VALID_IP + "\"");
}


@Test
void buildCannedPolicy_withDoubleQuoteInUrl_shouldRejectInput() {
String maliciousUrl = "https://example.com/file\",\"Resource\":\"*\",\"x\":\"";

assertThatThrownBy(() -> SigningUtils.buildCannedPolicy(maliciousUrl, EXPIRATION))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("contains invalid characters")
.hasMessageContaining("resourceUrl");
}

@Test
void buildCannedPolicy_withBackslashInUrl_shouldRejectInput() {
String maliciousUrl = "https://example.com/file\\";

assertThatThrownBy(() -> SigningUtils.buildCannedPolicy(maliciousUrl, EXPIRATION))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("contains invalid characters");
}

@ParameterizedTest
@ValueSource(strings = {
"https://example.com/file\u0000", // null character
"https://example.com/file\n", // newline
"https://example.com/file\r", // carriage return
"https://example.com/file\t" // tab
})
void buildCannedPolicy_withControlCharactersInUrl_shouldRejectInput(String maliciousUrl) {
assertThatThrownBy(() -> SigningUtils.buildCannedPolicy(maliciousUrl, EXPIRATION))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("contains invalid characters");
}

@Test
void buildCustomPolicy_withDoubleQuoteInUrl_shouldRejectInput() {
String maliciousUrl = "https://example.com/file\"";

assertThatThrownBy(() -> SigningUtils.buildCustomPolicy(maliciousUrl, ACTIVE_DATE, EXPIRATION, VALID_IP))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("resourceUrl");
}

@Test
void buildCustomPolicy_withDoubleQuoteInIpAddress_shouldRejectInput() {
String maliciousIp = "192.168.1.0\",\"Resource\":\"*";

assertThatThrownBy(() -> SigningUtils.buildCustomPolicy(VALID_URL, ACTIVE_DATE, EXPIRATION, maliciousIp))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("ipAddress");
}

@Test
void buildCustomPolicyForSignedUrl_withDoubleQuoteInUrl_shouldRejectInput() {
String maliciousUrl = "https://example.com/file\"";

assertThatThrownBy(() -> SigningUtils.buildCustomPolicyForSignedUrl(maliciousUrl, ACTIVE_DATE, EXPIRATION, VALID_IP))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("contains invalid characters");
}

@Test
void buildCustomPolicyForSignedUrl_withDoubleQuoteInIpRange_shouldRejectInput() {
String maliciousIp = "192.168.1.0\"";

assertThatThrownBy(() -> SigningUtils.buildCustomPolicyForSignedUrl(VALID_URL, ACTIVE_DATE, EXPIRATION, maliciousIp))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("contains invalid characters");
}

// Null parameter validation tests

@Test
void buildCannedPolicy_withNullUrl_shouldThrowNullPointerException() {
assertThatThrownBy(() -> SigningUtils.buildCannedPolicy(null, EXPIRATION))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("resourceUrl");
}

@Test
void buildCannedPolicy_withNullExpiration_shouldThrowNullPointerException() {
assertThatThrownBy(() -> SigningUtils.buildCannedPolicy(VALID_URL, null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("expirationDate");
}

@Test
void buildCustomPolicy_withNullUrl_shouldThrowNullPointerException() {
assertThatThrownBy(() -> SigningUtils.buildCustomPolicy(null, ACTIVE_DATE, EXPIRATION, VALID_IP))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("resourceUrl");
}

@Test
void buildCustomPolicy_withNullExpiration_shouldThrowNullPointerException() {
assertThatThrownBy(() -> SigningUtils.buildCustomPolicy(VALID_URL, ACTIVE_DATE, null, VALID_IP))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("expirationDate");
}

// Valid edge cases that should still work

@Test
void buildCannedPolicy_withWildcard_shouldSucceed() {
String policy = SigningUtils.buildCannedPolicy("*", EXPIRATION);
assertThat(policy).contains("\"Resource\":\"*\"");
}

@Test
void buildCannedPolicy_withWildcardInPath_shouldSucceed() {
String url = "https://d111111abcdef8.cloudfront.net/*";
String policy = SigningUtils.buildCannedPolicy(url, EXPIRATION);
assertThat(policy).contains("\"Resource\":\"" + url + "\"");
}

@Test
void buildCannedPolicy_withQueryParameters_shouldSucceed() {
String url = "https://d111111abcdef8.cloudfront.net/file?param=value&other=123";
String policy = SigningUtils.buildCannedPolicy(url, EXPIRATION);
assertThat(policy).contains("\"Resource\":\"" + url + "\"");
}

}
Loading