feat(cloudfront): forward viewer headers, cookies and query strings per origin request policy (#4230)

Custom origins only ever received the origin custom headers: the serving
controller built each origin request from them alone and dropped the
query string, whatever cache policy, origin request policy or legacy
ForwardedValues the matched cache behavior carried.

CloudFrontOriginRequestBuilder now decides what reaches the origin. With
a cache policy it forwards the union of the cache policy's
ParametersInCacheKeyAndForwardedToOrigin and the origin request policy
(every HeaderBehavior, CookieBehavior and QueryStringBehavior value).
Without one it applies legacy ForwardedValues, including the headers
CloudFront forwards by default. It also adds what CloudFront always
sends: Host for the origin unless the viewer Host is forwarded,
User-Agent "Amazon CloudFront", X-Forwarded-For, Via and X-Amz-Cf-Id.
Authorization is kept on the methods CloudFront does not cache, and
CloudFront-Forwarded-Proto, -Viewer-Address and -Viewer-Http-Version are
generated when a policy asks for them. Origin custom headers now replace
every same-named viewer header, and signing parameters are still never
forwarded.

The eight AWS managed origin request policies are served by their AWS
IDs and cannot be updated or deleted. With no origin request policy and
nothing selected by the cache policy, no cookies or query strings are
forwarded, as before.

Closes #4217

(cherry picked from commit f8f1b56ce9e9a1cbd63642ec1c4144fe8f6dd661)
This commit is contained in:
jwoa95
2026-09-23 18:59:53 +01:00
committed by Zakaria
parent aef858a47c
commit 40ee0e7822
13 changed files with 1585 additions and 75 deletions
@@ -4,16 +4,19 @@ import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import io.github.hectorvent.floci.services.cloudfront.model.CacheBehavior;
import io.github.hectorvent.floci.services.cloudfront.model.CachePolicy;
import io.github.hectorvent.floci.services.cloudfront.model.DefaultCacheBehavior;
import io.github.hectorvent.floci.services.cloudfront.model.Distribution;
import io.github.hectorvent.floci.services.cloudfront.model.DistributionConfig;
import io.github.hectorvent.floci.services.cloudfront.model.KeyGroup;
import io.github.hectorvent.floci.services.cloudfront.model.Origin;
import io.github.hectorvent.floci.services.cloudfront.model.OriginRequestPolicy;
import io.github.hectorvent.floci.services.cloudfront.model.PublicKey;
import io.github.hectorvent.floci.services.cloudfront.model.ResponseHeadersPolicy;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.QuarkusTestProfile;
import io.quarkus.test.junit.TestProfile;
import io.restassured.response.Response;
import jakarta.inject.Inject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
@@ -46,6 +49,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
@TestProfile(CloudFrontCustomOriginServingTest.PrivateOriginProfile.class)
class CloudFrontCustomOriginServingTest {
/** The AWS managed CachingDisabled cache policy, which forwards no viewer values. */
private static final String CACHING_DISABLED = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad";
@Inject
CloudFrontService cloudFrontService;
@@ -448,6 +454,9 @@ class CloudFrontCustomOriginServingTest {
DefaultCacheBehavior behavior = allMethodsBehavior();
behavior.setTrustedKeyGroups(List.of(keyGroup.getId()));
behavior.setCachePolicyId(CACHING_DISABLED);
behavior.setOriginRequestPolicyId(
CloudFrontService.MANAGED_ALL_VIEWER_ORIGIN_REQUEST_POLICY_ID);
Distribution created = cloudFrontService.createDistribution(
customOriginDistribution(behavior), Map.of());
@@ -470,6 +479,7 @@ class CloudFrontCustomOriginServingTest {
given()
.header("Host", created.getDomainName())
.queryParam("color", "blue")
.queryParam("Policy", cfBase64(policy.getBytes(StandardCharsets.UTF_8)))
.queryParam("Signature", cfBase64(signer.sign()))
.queryParam("Key-Pair-Id", publicKey.getId())
@@ -482,7 +492,193 @@ class CloudFrontCustomOriginServingTest {
assertEquals(1, hits.get());
assertEquals("{\"signed\":true}",
new String(received.get().body(), StandardCharsets.UTF_8));
assertNull(received.get().query());
// AllViewer forwards every query string, but never CloudFront's signing parameters.
assertEquals("color=blue", received.get().query());
assertEquals(created.getDomainName(), received.get().header("Host"));
}
@Test
void allViewerExceptHostHeaderForwardsViewerCookiesHeadersAndQuery() throws Exception {
AtomicReference<ReceivedRequest> received = new AtomicReference<>();
AtomicInteger hits = new AtomicInteger();
startRecordingOrigin(received, hits);
DefaultCacheBehavior behavior = policyBehavior(CACHING_DISABLED,
CloudFrontService.MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_ORIGIN_REQUEST_POLICY_ID);
Distribution created = cloudFrontService.createDistribution(
customOriginDistribution(behavior, originVerifyHeader()), Map.of());
Response response = given()
.header("Host", created.getDomainName())
.header("Cookie", "session=abc123; csrf=xyz")
.header("Authorization", "Bearer t")
.header("X-Custom", "custom")
.header("User-Agent", "viewer-agent/1.0")
.header("X-Origin-Verify", "forged")
.header("X-Origin-Verify", "forged-again")
.when()
.get("/api/me?x=1")
.then()
.statusCode(200)
.extract().response();
ReceivedRequest request = received.get();
assertEquals("/api/me", request.path());
assertEquals("x=1", request.query());
assertEquals("csrf=xyz; session=abc123", request.header("Cookie"));
assertEquals("Bearer t", request.header("Authorization"));
assertEquals("custom", request.header("X-Custom"));
assertEquals("viewer-agent/1.0", request.header("User-Agent"));
assertEquals(List.of("s3cr3t"), request.headers().get("X-Origin-Verify"));
assertEquals("127.0.0.1:" + originServer.getAddress().getPort(), request.header("Host"));
assertEquals("1.1 " + created.getDomainName() + " (CloudFront)", request.header("Via"));
assertFalse(request.header("X-Forwarded-For").isBlank());
assertEquals(56, request.header("X-Amz-Cf-Id").length());
assertEquals("http", request.header("CloudFront-Forwarded-Proto"));
assertEquals(List.of("session=renewed; Path=/; HttpOnly"),
response.getHeaders().getValues("Set-Cookie"));
}
@Test
void cachePolicyWithoutAnOriginRequestPolicyForwardsNoViewerValues() throws Exception {
AtomicReference<ReceivedRequest> received = new AtomicReference<>();
AtomicInteger hits = new AtomicInteger();
startRecordingOrigin(received, hits);
DefaultCacheBehavior behavior = policyBehavior(CACHING_DISABLED, null);
Distribution created = cloudFrontService.createDistribution(
customOriginDistribution(behavior, originVerifyHeader()), Map.of());
given()
.header("Host", created.getDomainName())
.header("Cookie", "session=abc123")
.header("Authorization", "Bearer t")
.header("X-Custom", "custom")
.header("User-Agent", "viewer-agent/1.0")
.when()
.get("/api/me?x=1")
.then()
.statusCode(200);
ReceivedRequest get = received.get();
assertNull(get.query());
assertNull(get.header("Cookie"));
assertNull(get.header("Authorization"));
assertNull(get.header("X-Custom"));
assertEquals("Amazon CloudFront", get.header("User-Agent"));
assertEquals("s3cr3t", get.header("X-Origin-Verify"));
assertEquals("127.0.0.1:" + originServer.getAddress().getPort(), get.header("Host"));
// CloudFront never strips Authorization from methods it does not cache.
given()
.header("Host", created.getDomainName())
.header("Cookie", "session=abc123")
.header("Authorization", "Bearer t")
.contentType("application/json")
.body("{}")
.when()
.post("/api/things")
.then()
.statusCode(201);
ReceivedRequest post = received.get();
assertEquals("Bearer t", post.header("Authorization"));
assertTrue(post.header("Content-Type").startsWith("application/json"));
assertNull(post.header("Cookie"));
}
@Test
void customPoliciesFilterCookiesHeadersAndQueryStrings() throws Exception {
AtomicReference<ReceivedRequest> received = new AtomicReference<>();
AtomicInteger hits = new AtomicInteger();
startRecordingOrigin(received, hits);
CachePolicy cachePolicy = new CachePolicy();
cachePolicy.setName("forwarding-cache-policy-" + System.nanoTime());
cachePolicy.setConfig(CloudFrontPolicyConfigCodec.parseCachePolicy("""
<CachePolicyConfig>
<MinTTL>0</MinTTL><DefaultTTL>0</DefaultTTL><MaxTTL>0</MaxTTL>
<ParametersInCacheKeyAndForwardedToOrigin>
<EnableAcceptEncodingGzip>false</EnableAcceptEncodingGzip>
<HeadersConfig><HeaderBehavior>whitelist</HeaderBehavior>
<Headers><Quantity>1</Quantity><Items><Name>Accept-Language</Name></Items></Headers>
</HeadersConfig>
<CookiesConfig><CookieBehavior>none</CookieBehavior></CookiesConfig>
<QueryStringsConfig><QueryStringBehavior>none</QueryStringBehavior></QueryStringsConfig>
</ParametersInCacheKeyAndForwardedToOrigin>
</CachePolicyConfig>
"""));
cachePolicy = cloudFrontService.createCachePolicy(cachePolicy);
OriginRequestPolicy originRequestPolicy = new OriginRequestPolicy();
originRequestPolicy.setName("forwarding-origin-policy-" + System.nanoTime());
originRequestPolicy.setConfig(CloudFrontPolicyConfigCodec.parseOriginRequestPolicy("""
<OriginRequestPolicyConfig>
<HeadersConfig><HeaderBehavior>whitelist</HeaderBehavior>
<Headers><Quantity>1</Quantity><Items><Name>X-Custom</Name></Items></Headers>
</HeadersConfig>
<CookiesConfig><CookieBehavior>whitelist</CookieBehavior>
<Cookies><Quantity>1</Quantity><Items><Name>session</Name></Items></Cookies>
</CookiesConfig>
<QueryStringsConfig><QueryStringBehavior>allExcept</QueryStringBehavior>
<QueryStrings><Quantity>1</Quantity><Items><Name>utm_source</Name></Items></QueryStrings>
</QueryStringsConfig>
</OriginRequestPolicyConfig>
"""));
originRequestPolicy = cloudFrontService.createOriginRequestPolicy(originRequestPolicy);
Distribution created = cloudFrontService.createDistribution(customOriginDistribution(
policyBehavior(cachePolicy.getId(), originRequestPolicy.getId())), Map.of());
given()
.urlEncodingEnabled(false)
.header("Host", created.getDomainName())
.header("Cookie", "tracking=1; session=abc; theme=dark")
.header("X-Custom", "custom")
.header("X-Other", "other")
.header("Accept-Language", "en-GB")
.when()
.get("/search?q=red%20shoes&utm_source=ad&page=2")
.then()
.statusCode(200);
ReceivedRequest request = received.get();
assertEquals("q=red%20shoes&page=2", request.query());
assertEquals("session=abc", request.header("Cookie"));
assertEquals("custom", request.header("X-Custom"));
assertEquals("en-GB", request.header("Accept-Language"));
assertNull(request.header("X-Other"));
}
@Test
void legacyForwardedValuesSelectQueryCookiesAndHeaders() throws Exception {
AtomicReference<ReceivedRequest> received = new AtomicReference<>();
AtomicInteger hits = new AtomicInteger();
startRecordingOrigin(received, hits);
Map<String, Object> forwardedValues = new LinkedHashMap<>();
forwardedValues.put("QueryString", true);
forwardedValues.put("CookiesForward", "whitelist");
forwardedValues.put("CookieNames", List.of("session"));
forwardedValues.put("Headers", List.of("Authorization"));
DefaultCacheBehavior behavior = defaultBehavior("custom-origin");
behavior.setForwardedValues(forwardedValues);
Distribution created = cloudFrontService.createDistribution(
customOriginDistribution(behavior), Map.of());
given()
.header("Host", created.getDomainName())
.header("Cookie", "session=abc; other=1")
.header("Authorization", "Bearer t")
.header("X-Custom", "custom")
.header("Accept-Language", "en-GB")
.header("User-Agent", "viewer-agent/1.0")
.when()
.get("/legacy?x=1&y=2")
.then()
.statusCode(200);
ReceivedRequest request = received.get();
assertEquals("x=1&y=2", request.query());
assertEquals("session=abc", request.header("Cookie"));
assertEquals("Bearer t", request.header("Authorization"));
assertEquals("custom", request.header("X-Custom"));
assertNull(request.header("Accept-Language"));
assertEquals("Amazon CloudFront", request.header("User-Agent"));
assertEquals("127.0.0.1:" + originServer.getAddress().getPort(), request.header("Host"));
}
private void startRecordingOrigin(AtomicReference<ReceivedRequest> received, AtomicInteger hits)
@@ -500,6 +696,7 @@ class CloudFrontCustomOriginServingTest {
.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "text/plain");
exchange.getResponseHeaders().add("X-Origin-Method", exchange.getRequestMethod());
exchange.getResponseHeaders().add("Set-Cookie", "session=renewed; Path=/; HttpOnly");
exchange.sendResponseHeaders(
"POST".equals(exchange.getRequestMethod()) ? 201 : 200, body.length);
exchange.getResponseBody().write(body);
@@ -509,10 +706,16 @@ class CloudFrontCustomOriginServingTest {
}
private Distribution customOriginDistribution(DefaultCacheBehavior behavior) {
return customOriginDistribution(behavior, null);
}
private Distribution customOriginDistribution(DefaultCacheBehavior behavior,
List<Map<String, String>> customHeaders) {
Origin customOrigin = new Origin();
customOrigin.setId("custom-origin");
customOrigin.setDomainName("127.0.0.1");
customOrigin.setCustomOriginConfig(customOriginConfig(originServer.getAddress().getPort()));
customOrigin.setCustomHeaders(customHeaders);
DistributionConfig config = new DistributionConfig();
config.setEnabled(true);
config.setOrigins(List.of(customOrigin));
@@ -522,6 +725,19 @@ class CloudFrontCustomOriginServingTest {
return distribution;
}
private static List<Map<String, String>> originVerifyHeader() {
return List.of(new LinkedHashMap<>(Map.of(
"HeaderName", "X-Origin-Verify", "HeaderValue", "s3cr3t")));
}
private static DefaultCacheBehavior policyBehavior(String cachePolicyId,
String originRequestPolicyId) {
DefaultCacheBehavior behavior = allMethodsBehavior();
behavior.setCachePolicyId(cachePolicyId);
behavior.setOriginRequestPolicyId(originRequestPolicyId);
return behavior;
}
private static DefaultCacheBehavior allMethodsBehavior() {
DefaultCacheBehavior behavior = defaultBehavior("custom-origin");
behavior.setAllowedMethods(List.of("GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"));
@@ -409,6 +409,42 @@ class CloudFrontManagementProtocolTest {
}
}
@Test
void managedOriginRequestPoliciesAreReadableListableAndImmutable() {
String exceptHost = "b689b0a8-53d0-40ab-baf2-68738e2966ac";
given()
.when()
.get(API + "origin-request-policy/" + exceptHost)
.then()
.statusCode(200)
.body(hasXPath("//*[local-name()='Name']/text()",
equalTo("Managed-AllViewerExceptHostHeader")))
.body(hasXPath("//*[local-name()='HeaderBehavior']/text()", equalTo("allExcept")))
.body(hasXPath("//*[local-name()='Headers']//*[local-name()='Name']/text()",
equalTo("Host")))
.body(hasXPath("//*[local-name()='CookieBehavior']/text()", equalTo("all")))
.body(hasXPath("//*[local-name()='QueryStringBehavior']/text()", equalTo("all")));
given()
.queryParam("Type", "managed")
.when()
.get(API + "origin-request-policy")
.then()
.statusCode(200)
.body(hasXPath("/*/*[local-name()='Quantity']/text()", equalTo("8")))
.body(hasXPath("//*[local-name()='OriginRequestPolicySummary']"
+ "[.//*[local-name()='Id' and text()='" + exceptHost + "']]"
+ "/*[local-name()='Type']/text()", equalTo("managed")));
given()
.header("If-Match", "E23ZP02F085DFQ")
.when()
.delete(API + "origin-request-policy/" + exceptHost)
.then()
.statusCode(400)
.body(hasXPath("//*[local-name()='Code']/text()", equalTo("IllegalDelete")));
}
private static String cachePolicyBody(String name, String comment, String defaultTtl, String cookie) {
return """
<CachePolicyConfig xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
@@ -201,7 +201,7 @@ class CloudFrontOriginHttpClientTest {
.build();
HttpResponse<byte[]> response = client.send(
request, Map.of(), payload, HttpResponse.BodyHandlers.ofByteArray());
request, List.of(), Map.of(), payload, HttpResponse.BodyHandlers.ofByteArray());
assertEquals(204, response.statusCode());
assertEquals("PUT", method.get());
@@ -0,0 +1,413 @@
package io.github.hectorvent.floci.services.cloudfront;
import io.github.hectorvent.floci.services.cloudfront.CloudFrontOriginRequestBuilder.Forwarding;
import io.github.hectorvent.floci.services.cloudfront.CloudFrontOriginRequestBuilder.Header;
import io.github.hectorvent.floci.services.cloudfront.CloudFrontOriginRequestBuilder.OriginRequest;
import io.github.hectorvent.floci.services.cloudfront.CloudFrontOriginRequestBuilder.ViewerRequest;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CloudFrontOriginRequestBuilderTest {
private static final String VIA_HOST = "d111111abcdef8.cloudfront.net";
@Test
void cachePolicyWithoutForwardingSendsOnlyTheHeadersCloudFrontAdds() {
OriginRequest request = build(viewer("GET", "x=1&y=2",
"Host", "d111111abcdef8.cloudfront.net",
"User-Agent", "curl/8.0",
"Cookie", "session=abc123",
"Authorization", "Bearer t",
"X-Custom", "custom",
"Origin", "https://viewer.example",
"X-Forwarded-For", "203.0.113.9"),
Forwarding.policies(parameters(none("Header"), none("Cookie"), none("QueryString")),
null, false));
assertNull(request.rawQuery());
assertEquals(List.of("User-Agent", "X-Forwarded-For", "Via", "X-Amz-Cf-Id"), names(request));
assertEquals("Amazon CloudFront", value(request, "User-Agent"));
assertEquals("203.0.113.9,127.0.0.1", value(request, "X-Forwarded-For"));
assertEquals("1.1 " + VIA_HOST + " (CloudFront)", value(request, "Via"));
assertEquals(56, value(request, "X-Amz-Cf-Id").length());
}
@Test
void allExceptForwardsEveryViewerValueButTheListedHeaders() {
Map<String, Object> allViewerExceptHost = orp(
selection("HeaderBehavior", "allExcept", "Headers", "host"),
selection("CookieBehavior", "all", "Cookies"),
selection("QueryStringBehavior", "all", "QueryStrings"));
OriginRequest request = build(viewer("GET", "x=1&encoded=a%2Fb",
"Host", "d111111abcdef8.cloudfront.net",
"User-Agent", "curl/8.0",
"Cookie", "session=abc123; csrf=xyz",
"Authorization", "Bearer t",
"X-Custom", "custom"),
Forwarding.policies(parameters(none("Header"), none("Cookie"), none("QueryString")),
allViewerExceptHost, false));
assertEquals("x=1&encoded=a%2Fb", request.rawQuery());
assertNull(value(request, "Host"));
assertEquals("curl/8.0", value(request, "User-Agent"));
assertEquals("Bearer t", value(request, "Authorization"));
assertEquals("custom", value(request, "X-Custom"));
assertEquals("csrf=xyz; session=abc123", value(request, "Cookie"));
// AWS documents AllViewerExceptHostHeader as adding CloudFront's protocol, version and
// location headers; Floci generates the ones it can derive locally.
assertEquals("http", value(request, "CloudFront-Forwarded-Proto"));
assertEquals("1.1", value(request, "CloudFront-Viewer-Http-Version"));
assertEquals("127.0.0.1:54321", value(request, "CloudFront-Viewer-Address"));
}
@Test
void allViewerForwardsTheViewerHostAndAddsNoCloudFrontHeaders() {
OriginRequest request = build(viewer("GET", null,
"Host", "d111111abcdef8.cloudfront.net",
"Accept-Language", "en-GB"),
Forwarding.policies(null, orp(
selection("HeaderBehavior", "allViewer", "Headers"),
selection("CookieBehavior", "none", "Cookies"),
selection("QueryStringBehavior", "none", "QueryStrings")), false));
assertEquals("d111111abcdef8.cloudfront.net", value(request, "Host"));
assertEquals("en-GB", value(request, "Accept-Language"));
assertTrue(names(request).stream().noneMatch(name -> name.startsWith("CloudFront-")),
names(request).toString());
}
@Test
void allViewerAndWhitelistCloudFrontGeneratesOnlyListedSupportedHeaders() {
OriginRequest request = build(viewerOverHttp2("https",
"X-Custom", "custom",
"CloudFront-Viewer-Country", "XX",
"CloudFront-Viewer-Address", "198.51.100.1:1"),
Forwarding.policies(null, orp(
selection("HeaderBehavior", "allViewerAndWhitelistCloudFront", "Headers",
"CloudFront-Viewer-Address", "CloudFront-Viewer-Country",
"CloudFront-Forwarded-Proto"),
selection("CookieBehavior", "none", "Cookies"),
selection("QueryStringBehavior", "none", "QueryStrings")), false));
assertEquals("custom", value(request, "X-Custom"));
assertEquals("127.0.0.1:54321", value(request, "CloudFront-Viewer-Address"));
assertEquals("https", value(request, "CloudFront-Forwarded-Proto"));
assertNull(value(request, "CloudFront-Viewer-Country"), "viewer-supplied values are dropped");
assertNull(value(request, "CloudFront-Viewer-Http-Version"), "not listed");
assertEquals("2.0 " + VIA_HOST + " (CloudFront)", value(request, "Via"));
}
@Test
void whitelistedHeadersMatchCaseInsensitivelyAndUnionWithTheCachePolicy() {
OriginRequest request = build(viewer("GET", null,
"authorization", "Bearer t",
"x-custom", "custom",
"X-Other", "other",
"Referer", "https://viewer.example/"),
Forwarding.policies(
parameters(selection("HeaderBehavior", "whitelist", "Headers", "Authorization"),
none("Cookie"), none("QueryString")),
orp(selection("HeaderBehavior", "whitelist", "Headers", "X-CUSTOM"),
selection("CookieBehavior", "none", "Cookies"),
selection("QueryStringBehavior", "none", "QueryStrings")),
false));
assertEquals("Bearer t", value(request, "Authorization"));
assertEquals("custom", value(request, "X-Custom"));
assertNull(value(request, "X-Other"));
assertNull(value(request, "Referer"));
}
@Test
void cookieBehaviorsSelectSortAndDropMalformedCookies() {
ViewerRequest viewer = viewer("GET", null,
"Cookie", "session=abc; Session=upper; $Version=1; theme=dark; broken",
"Cookie", "csrf=xyz");
assertNull(value(build(viewer, cookies("none")), "Cookie"));
assertEquals("session=abc",
value(build(viewer, cookies("whitelist", "session", "missing")), "Cookie"));
assertEquals("Session=upper; csrf=xyz; theme=dark",
value(build(viewer, cookies("allExcept", "session")), "Cookie"));
assertEquals("Session=upper; csrf=xyz; session=abc; theme=dark",
value(build(viewer, cookies("all")), "Cookie"));
}
@Test
void queryStringBehaviorsKeepViewerOrderAndEncoding() {
ViewerRequest viewer = viewer("GET", "b=2&a=1&c=x%20y&flag&a=3", "Host", "d.example");
assertNull(build(viewer, queryStrings("none")).rawQuery());
assertEquals("b=2&a=1&c=x%20y&flag&a=3", build(viewer, queryStrings("all")).rawQuery());
assertEquals("a=1&flag&a=3", build(viewer, queryStrings("whitelist", "a", "flag")).rawQuery());
assertEquals("b=2&c=x%20y&flag", build(viewer, queryStrings("allExcept", "a")).rawQuery());
assertNull(build(viewer, queryStrings("whitelist", "A")).rawQuery(),
"query string names are case-sensitive");
OriginRequest union = build(viewer, Forwarding.policies(
parameters(none("Header"), none("Cookie"),
selection("QueryStringBehavior", "whitelist", "QueryStrings", "b")),
orp(selection("HeaderBehavior", "none", "Headers"),
selection("CookieBehavior", "none", "Cookies"),
selection("QueryStringBehavior", "whitelist", "QueryStrings", "c")),
false));
assertEquals("b=2&c=x%20y", union.rawQuery());
}
@Test
void legacySettingsForwardDefaultHeadersAndRemoveTheDocumentedOnes() {
OriginRequest request = build(viewer("GET", "x=1",
"Host", "d111111abcdef8.cloudfront.net",
"User-Agent", "curl/8.0",
"Accept", "text/html",
"Accept-Language", "en-GB",
"Referer", "https://viewer.example/",
"Authorization", "Bearer t",
"Cookie", "session=abc",
"X-HTTP-Method-Override", "DELETE",
"X-Custom", "custom",
"Origin", "https://viewer.example",
"If-None-Match", "\"etag\"",
"Via", "1.1 proxy.example",
"Accept-Encoding", "gzip, deflate"),
Forwarding.legacy(null, false));
assertNull(request.rawQuery());
assertEquals("custom", value(request, "X-Custom"));
assertEquals("https://viewer.example", value(request, "Origin"));
assertEquals("\"etag\"", value(request, "If-None-Match"));
assertEquals("gzip", value(request, "Accept-Encoding"));
assertEquals("Amazon CloudFront", value(request, "User-Agent"));
assertEquals("1.1 proxy.example, 1.1 " + VIA_HOST + " (CloudFront)", value(request, "Via"));
for (String removed : List.of("Host", "Accept", "Accept-Language", "Referer", "Authorization",
"Cookie", "X-HTTP-Method-Override")) {
assertNull(value(request, removed), removed);
}
}
@Test
void legacyHeaderListsAddRemovedHeadersAndAWildcardForwardsAll() {
ViewerRequest viewer = viewer("GET", null,
"Host", "d111111abcdef8.cloudfront.net",
"User-Agent", "curl/8.0",
"Authorization", "Bearer t",
"Accept-Language", "en-GB",
"Accept-Encoding", "gzip, deflate",
"X-Custom", "custom");
OriginRequest listed = build(viewer, Forwarding.legacy(
forwardedValues(false, "none", List.of(), List.of("Authorization", "accept-language")),
false));
assertEquals("Bearer t", value(listed, "Authorization"));
assertEquals("en-GB", value(listed, "Accept-Language"));
assertEquals("custom", value(listed, "X-Custom"));
assertNull(value(listed, "Host"));
OriginRequest all = build(viewer, Forwarding.legacy(
forwardedValues(false, "none", List.of(), List.of("*")), false));
assertEquals("d111111abcdef8.cloudfront.net", value(all, "Host"));
assertEquals("curl/8.0", value(all, "User-Agent"));
assertEquals("Bearer t", value(all, "Authorization"));
assertEquals("gzip, deflate", value(all, "Accept-Encoding"));
}
@Test
void legacyCookiesAndQueryStringFollowForwardedValues() {
ViewerRequest viewer = viewer("GET", "a=1&b=2",
"Cookie", "userid_42=me; userid_=empty; uid9=x; theme=dark");
OriginRequest wildcard = build(viewer, Forwarding.legacy(
forwardedValues(true, "whitelist", List.of("userid_*", "uid?"), List.of()), false));
assertEquals("uid9=x; userid_=empty; userid_42=me", value(wildcard, "Cookie"));
// QueryString=true forwards every parameter; QueryStringCacheKeys only narrows the cache key.
Map<String, Object> keyed = forwardedValues(true, "all", List.of(), List.of());
keyed.put("QueryStringCacheKeys", List.of("a"));
OriginRequest all = build(viewer, Forwarding.legacy(keyed, false));
assertEquals("a=1&b=2", all.rawQuery());
assertEquals("theme=dark; uid9=x; userid_=empty; userid_42=me", value(all, "Cookie"));
OriginRequest none = build(viewer, Forwarding.legacy(
forwardedValues(false, "none", List.of(), List.of()), false));
assertNull(none.rawQuery());
assertNull(value(none, "Cookie"));
}
@Test
void authorizationIsAlwaysForwardedForMethodsCloudFrontDoesNotCache() {
Forwarding nothing = Forwarding.policies(null, null, false);
for (String method : List.of("POST", "PUT", "PATCH", "DELETE", "OPTIONS")) {
assertEquals("Bearer t", value(build(viewer(method, null, "Authorization", "Bearer t"),
nothing), "Authorization"), method);
}
assertNull(value(build(viewer("GET", null, "Authorization", "Bearer t"), nothing),
"Authorization"));
assertNull(value(build(viewer("HEAD", null, "Authorization", "Bearer t"), nothing),
"Authorization"));
assertNull(value(build(viewer("OPTIONS", null, "Authorization", "Bearer t"),
Forwarding.policies(null, null, true)), "Authorization"),
"a cached OPTIONS response drops Authorization");
}
@Test
void contentTypeTravelsWithARequestBodyWhateverThePolicy() {
Forwarding nothing = Forwarding.policies(null, null, false);
assertEquals("application/json", value(build(
viewer("POST", null, "Content-Type", "application/json"), nothing), "Content-Type"));
assertNull(value(build(
viewer("GET", null, "Content-Type", "application/json"), nothing), "Content-Type"));
}
@Test
void hopByHopAndCloudFrontOwnedHeadersAreNeverForwarded() {
OriginRequest request = build(viewer("POST", null,
"Connection", "keep-alive, X-Hop",
"X-Hop", "hop",
"Keep-Alive", "timeout=5",
"Proxy-Authorization", "Basic x",
"TE", "trailers",
"Upgrade", "websocket",
"Transfer-Encoding", "chunked",
"Content-Length", "10",
"Expect", "100-continue",
"X-Amz-Cf-Id", "forged",
"X-Edge-Location", "forged",
"X-Real-IP", "10.0.0.1",
"X-Forwarded-Proto", "https",
"X-Kept", "kept"),
Forwarding.policies(null, orp(
selection("HeaderBehavior", "allViewer", "Headers"),
selection("CookieBehavior", "none", "Cookies"),
selection("QueryStringBehavior", "none", "QueryStrings")), false));
assertEquals("kept", value(request, "X-Kept"));
for (String dropped : List.of("Connection", "X-Hop", "Keep-Alive", "Proxy-Authorization", "TE",
"Upgrade", "Transfer-Encoding", "Content-Length", "Expect", "X-Edge-Location",
"X-Real-IP", "X-Forwarded-Proto")) {
assertNull(value(request, dropped), dropped);
}
assertNotEquals("forged", value(request, "X-Amz-Cf-Id"));
assertNotEquals(value(request, "X-Amz-Cf-Id"),
value(build(viewer("GET", null), Forwarding.policies(null, null, false)), "X-Amz-Cf-Id"));
}
@Test
void cachePolicyCompressionSettingsNormalizeAcceptEncoding() {
Map<String, Object> both = parameters(none("Header"), none("Cookie"), none("QueryString"));
both.put("EnableAcceptEncodingGzip", "true");
both.put("EnableAcceptEncodingBrotli", "true");
Map<String, Object> gzipOnly = parameters(none("Header"), none("Cookie"), none("QueryString"));
gzipOnly.put("EnableAcceptEncodingGzip", "true");
assertEquals("br,gzip", value(build(viewer("GET", null, "Accept-Encoding", "gzip, deflate, br;q=0.9"),
Forwarding.policies(both, null, false)), "Accept-Encoding"));
assertEquals("gzip", value(build(viewer("GET", null, "Accept-Encoding", "gzip, br"),
Forwarding.policies(gzipOnly, null, false)), "Accept-Encoding"));
assertEquals("identity", value(build(viewer("GET", null, "Accept-Encoding", "deflate"),
Forwarding.policies(both, null, false)), "Accept-Encoding"));
assertNull(value(build(viewer("GET", null), Forwarding.policies(both, null, false)),
"Accept-Encoding"));
assertEquals("gzip, deflate", value(build(viewer("GET", null, "Accept-Encoding", "gzip, deflate"),
Forwarding.policies(null, orp(
selection("HeaderBehavior", "allViewer", "Headers"),
selection("CookieBehavior", "none", "Cookies"),
selection("QueryStringBehavior", "none", "QueryStrings")), false)),
"Accept-Encoding"));
}
private static OriginRequest build(ViewerRequest viewer, Forwarding forwarding) {
return CloudFrontOriginRequestBuilder.build(viewer, forwarding, VIA_HOST);
}
private static ViewerRequest viewer(String method, String rawQuery, String... headerPairs) {
return new ViewerRequest(method, headers(headerPairs), rawQuery, "127.0.0.1", 54321,
"http", "1.1");
}
private static ViewerRequest viewerOverHttp2(String scheme, String... headerPairs) {
return new ViewerRequest("GET", headers(headerPairs), null, "127.0.0.1", 54321, scheme, "2.0");
}
private static List<Header> headers(String... headerPairs) {
List<Header> headers = new ArrayList<>();
for (int i = 0; i < headerPairs.length; i += 2) {
headers.add(new Header(headerPairs[i], headerPairs[i + 1]));
}
return headers;
}
private static Forwarding cookies(String behavior, String... names) {
return Forwarding.policies(null, orp(
selection("HeaderBehavior", "none", "Headers"),
selection("CookieBehavior", behavior, "Cookies", names),
selection("QueryStringBehavior", "none", "QueryStrings")), false);
}
private static Forwarding queryStrings(String behavior, String... names) {
return Forwarding.policies(null, orp(
selection("HeaderBehavior", "none", "Headers"),
selection("CookieBehavior", "none", "Cookies"),
selection("QueryStringBehavior", behavior, "QueryStrings", names)), false);
}
private static Map<String, Object> orp(Map<String, Object> headers, Map<String, Object> cookies,
Map<String, Object> queryStrings) {
Map<String, Object> config = new LinkedHashMap<>();
config.put("HeadersConfig", headers);
config.put("CookiesConfig", cookies);
config.put("QueryStringsConfig", queryStrings);
return config;
}
private static Map<String, Object> parameters(Map<String, Object> headers,
Map<String, Object> cookies,
Map<String, Object> queryStrings) {
return orp(headers, cookies, queryStrings);
}
private static Map<String, Object> none(String kind) {
return selection(kind + "Behavior", "none", kind + "s");
}
private static Map<String, Object> selection(String behaviorKey, String behavior, String listKey,
String... names) {
Map<String, Object> block = new LinkedHashMap<>();
block.put(behaviorKey, behavior);
if (names.length > 0) {
block.put(listKey, List.of(names));
}
return block;
}
private static Map<String, Object> forwardedValues(boolean queryString, String cookiesForward,
List<String> cookieNames, List<String> headers) {
Map<String, Object> values = new LinkedHashMap<>();
values.put("QueryString", queryString);
values.put("CookiesForward", cookiesForward);
values.put("CookieNames", cookieNames);
values.put("Headers", headers);
return values;
}
private static List<String> names(OriginRequest request) {
return request.headers().stream().map(Header::name).toList();
}
private static String value(OriginRequest request, String name) {
List<String> values = request.headers().stream()
.filter(header -> header.name().equalsIgnoreCase(name))
.map(Header::value)
.toList();
assertTrue(values.size() <= 1, name + " appears more than once: " + values);
return values.isEmpty() ? null : values.getFirst();
}
}
@@ -7,6 +7,7 @@ import io.github.hectorvent.floci.services.cloudfront.model.Origin;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -121,6 +122,32 @@ class CloudFrontRequestRouterTest {
assertNull(CloudFrontRequestRouter.matchResponseHeadersPolicyId(cfg, "/index.html"));
}
@Test
void matchForwardingReturnsTheSelectedBehaviorsPoliciesAndForwardedValues() {
CacheBehavior api = behavior("api/*", "api-origin");
api.setCachePolicyId("api-cache-policy");
api.setOriginRequestPolicyId("api-origin-request-policy");
api.setCachedMethods(List.of("GET", "HEAD", "OPTIONS"));
DefaultCacheBehavior dflt = defaultBehavior("default-origin");
dflt.setForwardedValues(Map.of("QueryString", true));
DistributionConfig cfg = new DistributionConfig();
cfg.setCacheBehaviors(List.of(api));
cfg.setDefaultCacheBehavior(dflt);
CloudFrontRequestRouter.BehaviorForwarding matched =
CloudFrontRequestRouter.matchForwarding(cfg, "/api/data");
assertEquals("api-cache-policy", matched.cachePolicyId());
assertEquals("api-origin-request-policy", matched.originRequestPolicyId());
assertEquals(List.of("GET", "HEAD", "OPTIONS"), matched.cachedMethods());
assertNull(matched.forwardedValues());
CloudFrontRequestRouter.BehaviorForwarding fallback =
CloudFrontRequestRouter.matchForwarding(cfg, "/index.html");
assertNull(fallback.cachePolicyId());
assertEquals(Map.of("QueryString", true), fallback.forwardedValues());
}
@Test
void matchAllowedMethodsUsesTheSelectedBehaviorAndDefaultsToGetHead() {
CacheBehavior api = behavior("api/*", "api-origin");
@@ -175,7 +202,7 @@ class CloudFrontRequestRouterTest {
Origin s3 = new Origin();
s3.setId("s3-origin");
s3.setDomainName("my-bucket.s3.us-east-1.amazonaws.com");
s3.setS3OriginConfig(java.util.Map.of("originAccessIdentity", ""));
s3.setS3OriginConfig(Map.of("originAccessIdentity", ""));
DistributionConfig cfg = new DistributionConfig();
cfg.setOrigins(List.of(s3));
@@ -982,6 +982,40 @@ class CloudFrontServiceTest {
assertAws("NoSuchOriginRequestPolicy", () -> service.getOriginRequestPolicy(policy.getId()));
}
@Test
void exposesTheAwsManagedOriginRequestPoliciesAsImmutable() {
CloudFrontService service = serviceWithDomainSuffix("cloudfront.net");
OriginRequestPolicy exceptHost = service.getOriginRequestPolicy(
CloudFrontService.MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_ORIGIN_REQUEST_POLICY_ID);
assertEquals("Managed-AllViewerExceptHostHeader", exceptHost.getName());
assertEquals(Map.of("HeaderBehavior", "allExcept", "Headers", List.of("Host")),
exceptHost.getConfig().get("HeadersConfig"));
assertEquals(Map.of("CookieBehavior", "all"), exceptHost.getConfig().get("CookiesConfig"));
assertEquals(Map.of("QueryStringBehavior", "all"),
exceptHost.getConfig().get("QueryStringsConfig"));
assertEquals(Map.of("HeaderBehavior", "allViewer"), service.getOriginRequestPolicy(
CloudFrontService.MANAGED_ALL_VIEWER_ORIGIN_REQUEST_POLICY_ID)
.getConfig().get("HeadersConfig"));
assertEquals(Map.of("HeaderBehavior", "whitelist", "Headers", List.of("Host")),
service.getOriginRequestPolicy(
CloudFrontService.MANAGED_HOST_HEADER_ONLY_ORIGIN_REQUEST_POLICY_ID)
.getConfig().get("HeadersConfig"));
OriginRequestPolicy custom =
service.createOriginRequestPolicy(namedOriginRequestPolicy("custom-orp"));
assertEquals(8, service.listOriginRequestPolicies(null, 100, "managed").size());
assertEquals(List.of(custom.getId()), service.listOriginRequestPolicies(null, 100, "custom")
.stream().map(OriginRequestPolicy::getId).toList());
assertEquals(9, service.listOriginRequestPolicies(null, 100, null).size());
assertAws("InvalidArgument", () -> service.listOriginRequestPolicies(null, 100, "MANAGED"));
assertAws("IllegalUpdate", () -> service.updateOriginRequestPolicy(exceptHost.getId(),
exceptHost.getEtag(), namedOriginRequestPolicy("replacement")));
assertAws("IllegalDelete",
() -> service.deleteOriginRequestPolicy(exceptHost.getId(), exceptHost.getEtag()));
}
@Test
void deletesAnUnattachedCachePolicy() {
CloudFrontService service = serviceWithDomainSuffix("cloudfront.net");