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:
@@ -159,9 +159,6 @@ PUT, PATCH and DELETE).
|
||||
`Content-Type`; `Content-Length` is set from the body. Their responses are never cached, as on AWS.
|
||||
Signed URL and signed cookie enforcement applies to them like any other method. Request bodies are
|
||||
bounded by the emulator-wide `floci.protocols.max-request-size` limit.
|
||||
- The viewer's `Authorization` header is forwarded to custom origins on POST, PUT, PATCH and DELETE
|
||||
and removed from GET and HEAD requests, as on AWS. Floci also removes it from OPTIONS requests;
|
||||
AWS removes it there only when OPTIONS is a cached method.
|
||||
- Every distribution is also served as `{id}.cloudfront.{host}` for each endpoint host Floci
|
||||
resolves: `localhost`, `localhost.floci.io`, `localhost.localstack.cloud`, `FLOCI_HOSTNAME` and
|
||||
every `FLOCI_DNS_EXTRA_SUFFIXES` entry. `{id}.cloudfront.localhost.floci.io` and
|
||||
@@ -169,7 +166,7 @@ PUT, PATCH and DELETE).
|
||||
HTTPS certificate, so a signed URL for either can be downloaded over `https://`. See
|
||||
[Downloading over HTTPS](#downloading-over-https).
|
||||
- Origin custom headers are persisted through the CloudFront API and CloudFormation. They replace
|
||||
same-named viewer headers on every custom-origin request. For in-process S3 origins, a
|
||||
every same-named forwarded viewer header on custom-origin requests. For in-process S3 origins, a
|
||||
configured `Origin` header is used for S3 CORS evaluation. AWS-prohibited names, malformed
|
||||
values, inconsistent quantities, duplicates, and quota violations are rejected with modeled
|
||||
CloudFront errors when the distribution is created or updated.
|
||||
@@ -190,9 +187,44 @@ PUT, PATCH and DELETE).
|
||||
service-principal bucket-policy grants (including the distribution `AWS:SourceArn`) when strict S3
|
||||
authentication is enabled. OAC `always`, `never`, and unsigned `no-override` requests follow their
|
||||
documented signing behavior; signed `no-override` viewer requests retain their authorization.
|
||||
- Cache-policy, origin-request-policy, and legacy `ForwardedValues` data-plane evaluation is not
|
||||
implemented yet. Viewer query strings therefore follow CloudFront's default behavior and are not
|
||||
forwarded to origins.
|
||||
- Requests to custom origins carry what the matched cache behavior forwards. With a cache policy,
|
||||
the viewer headers, cookies and query strings selected by the cache policy's
|
||||
`ParametersInCacheKeyAndForwardedToOrigin` and by the origin request policy are forwarded (the
|
||||
union of both). `HeaderBehavior` `none`, `whitelist`, `allViewer`,
|
||||
`allViewerAndWhitelistCloudFront` and `allExcept`, `CookieBehavior` and `QueryStringBehavior`
|
||||
`none`, `whitelist`, `allExcept` and `all` are evaluated. A cache policy that selects nothing and no
|
||||
origin request policy forwards no viewer headers, cookies or query strings, as on AWS.
|
||||
- A cache behavior without a cache policy uses its legacy `ForwardedValues`: `QueryString=true`
|
||||
forwards every query string (`QueryStringCacheKeys` only narrows the cache key), cookies follow
|
||||
`Forward` `none`, `whitelist` (with `*` and `?` wildcards) or `all`, and viewer headers are
|
||||
forwarded by default except `Accept`, `Accept-Charset`, `Accept-Language`, `Authorization`,
|
||||
`Host`, `Referer`, `User-Agent` and `X-HTTP-Method-Override`, which `Headers` can add back (`*`
|
||||
forwards all of them). An origin request policy needs a cache policy on AWS, so a behavior without
|
||||
one ignores it.
|
||||
- Whatever the policies, CloudFront sets `Host` to the origin's domain name unless the viewer's
|
||||
`Host` is forwarded, sends `User-Agent: Amazon CloudFront` unless the viewer's `User-Agent` is
|
||||
forwarded, appends the viewer address to `X-Forwarded-For`, adds a
|
||||
`Via: <viewer HTTP version> <distribution domain> (CloudFront)` hop and an `X-Amz-Cf-Id`.
|
||||
`Authorization` is always forwarded on POST, PUT, PATCH and DELETE, and on OPTIONS unless OPTIONS
|
||||
is a cached method; `Content-Type` travels with a request body. Cache policy compression settings
|
||||
normalize `Accept-Encoding` to `br,gzip`, `gzip`, `br` or `identity`. Forwarded cookies are sorted
|
||||
by name, and cookies that are not `name=value` or whose name starts with `$` are dropped.
|
||||
Hop-by-hop headers, `Expect`, `X-Edge-*`, `X-Real-IP`, `X-Forwarded-Proto` and viewer-supplied
|
||||
`CloudFront-*` headers are never forwarded.
|
||||
- Of the CloudFront request headers, `CloudFront-Forwarded-Proto`, `CloudFront-Viewer-Address` and
|
||||
`CloudFront-Viewer-Http-Version` are generated when a policy names them, or when an `allExcept`
|
||||
header behavior does not exclude them (AWS documents Managed-AllViewerExceptHostHeader as adding
|
||||
them). Device-type, geolocation, TLS, JA3/JA4 and header-order headers are not generated.
|
||||
- The eight AWS managed origin request policies are available by their AWS IDs through
|
||||
`GetOriginRequestPolicy` and `ListOriginRequestPolicies` (`Type` is `managed` or `custom`), and
|
||||
cannot be updated or deleted. AWS managed cache policies are not modeled: a behavior that
|
||||
references one forwards nothing on its account, which matches `CachingDisabled`,
|
||||
`CachingOptimized` and `CachingOptimizedForUncompressedObjects` but not `Amplify`,
|
||||
`Elemental-MediaPackage` or the `UseOriginCacheControlHeaders` policies.
|
||||
- In-process S3 origins do not apply forwarding settings: viewer query strings, cookies and headers
|
||||
do not change the S3 read.
|
||||
- Origin `Set-Cookie` headers always reach the viewer. With legacy `Forward=none`, AWS strips them
|
||||
from the response; Floci does not.
|
||||
- Custom origins that resolve to loopback, private, link-local, carrier-grade NAT, or other non-routable addresses are rejected by default. Development-only private origins must be explicitly allowlisted by exact hostname.
|
||||
- Response headers policies validate the AWS configuration shape and are applied after the origin
|
||||
response, including CORS preflight fields, origin override behavior, custom headers, security
|
||||
|
||||
+3
-2
@@ -468,7 +468,7 @@ public class CloudFrontController {
|
||||
@QueryParam("Type") String type) {
|
||||
try {
|
||||
Page<OriginRequestPolicy> page = page(
|
||||
service.listOriginRequestPolicies(marker, paginationFetchLimit(maxItems)),
|
||||
service.listOriginRequestPolicies(marker, paginationFetchLimit(maxItems), type),
|
||||
maxItems, OriginRequestPolicy::getId);
|
||||
|
||||
XmlBuilder xml = new XmlBuilder()
|
||||
@@ -479,7 +479,8 @@ public class CloudFrontController {
|
||||
.start("Items");
|
||||
for (OriginRequestPolicy p : page.items()) {
|
||||
xml.start("OriginRequestPolicySummary")
|
||||
.elem("Type", "custom")
|
||||
.elem("Type", CloudFrontService.isManagedOriginRequestPolicy(p.getId())
|
||||
? "managed" : "custom")
|
||||
.raw(xmlOriginRequestPolicyResponse(p))
|
||||
.end("OriginRequestPolicySummary");
|
||||
}
|
||||
|
||||
+24
-9
@@ -41,6 +41,7 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -120,16 +121,20 @@ final class CloudFrontOriginHttpClient implements AutoCloseable {
|
||||
HttpResponse<byte[]> send(HttpRequest request, Map<String, String> originHeaders,
|
||||
HttpResponse.BodyHandler<byte[]> bodyHandler)
|
||||
throws IOException, InterruptedException {
|
||||
return send(request, originHeaders, null, bodyHandler);
|
||||
return send(request, List.of(), originHeaders, null, bodyHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends {@code request} with {@code requestBody} as its entity, or with no entity when it is
|
||||
* {@code null}. The request's own body publisher must be empty: the body travels as bytes so its
|
||||
* {@code Content-Length} is exact.
|
||||
* Sends {@code request} with {@code forwardedHeaders} added and {@code requestBody} as its
|
||||
* entity, or with no entity when it is {@code null}. Each origin header replaces every
|
||||
* same-named request or forwarded header, so a viewer cannot repeat a header to smuggle its own
|
||||
* value past an origin custom header. The request's own body publisher must be empty: the body
|
||||
* travels as bytes so its {@code Content-Length} is exact.
|
||||
*/
|
||||
HttpResponse<byte[]> send(HttpRequest request, Map<String, String> originHeaders,
|
||||
byte[] requestBody, HttpResponse.BodyHandler<byte[]> bodyHandler)
|
||||
HttpResponse<byte[]> send(HttpRequest request,
|
||||
List<CloudFrontOriginRequestBuilder.Header> forwardedHeaders,
|
||||
Map<String, String> originHeaders, byte[] requestBody,
|
||||
HttpResponse.BodyHandler<byte[]> bodyHandler)
|
||||
throws IOException, InterruptedException {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
throw new InterruptedException("CloudFront origin request interrupted");
|
||||
@@ -141,9 +146,19 @@ final class CloudFrontOriginHttpClient implements AutoCloseable {
|
||||
ClassicRequestBuilder builder = ClassicRequestBuilder.create(request.method())
|
||||
.setUri(request.uri())
|
||||
.setVersion(HttpVersion.HTTP_1_1);
|
||||
request.headers().map().forEach((name, values) ->
|
||||
values.forEach(value -> builder.addHeader(name, value)));
|
||||
originHeaders.forEach(builder::setHeader);
|
||||
Set<String> replaced = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
|
||||
replaced.addAll(originHeaders.keySet());
|
||||
request.headers().map().forEach((name, values) -> {
|
||||
if (!replaced.contains(name)) {
|
||||
values.forEach(value -> builder.addHeader(name, value));
|
||||
}
|
||||
});
|
||||
for (CloudFrontOriginRequestBuilder.Header header : forwardedHeaders) {
|
||||
if (!replaced.contains(header.name())) {
|
||||
builder.addHeader(header.name(), header.value());
|
||||
}
|
||||
}
|
||||
originHeaders.forEach(builder::addHeader);
|
||||
if (requestBody != null) {
|
||||
// No entity content type: the caller's Content-Type request header is sent as is.
|
||||
builder.setEntity(new ByteArrayEntity(requestBody, null));
|
||||
|
||||
+497
@@ -0,0 +1,497 @@
|
||||
package io.github.hectorvent.floci.services.cloudfront;
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
/**
|
||||
* Decides what CloudFront sends to a custom origin for one viewer request: the viewer headers,
|
||||
* cookies and query strings the matched cache behavior forwards, and the headers CloudFront adds on
|
||||
* its own. Implemented to the CloudFront Developer Guide:
|
||||
*
|
||||
* <ul>
|
||||
* <li>With a cache policy, only what the cache policy's
|
||||
* {@code ParametersInCacheKeyAndForwardedToOrigin} or the origin request policy selects is
|
||||
* forwarded; the two selections are unioned.</li>
|
||||
* <li>Without a cache policy (legacy cache settings), {@code ForwardedValues} applies, and viewer
|
||||
* headers are forwarded by default except the ones the custom-origin request table says
|
||||
* CloudFront removes.</li>
|
||||
* <li>Every origin request carries {@code User-Agent: Amazon CloudFront} unless the viewer's
|
||||
* {@code User-Agent} is forwarded, an {@code X-Forwarded-For} ending in the viewer address,
|
||||
* CloudFront's {@code Via} hop and an {@code X-Amz-Cf-Id}. {@code Host} is the origin's own
|
||||
* name unless the viewer's {@code Host} is forwarded.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>This class holds no state and does no I/O. Origin custom headers are applied afterwards by the
|
||||
* transport, which lets them replace same-named viewer headers.
|
||||
*/
|
||||
final class CloudFrontOriginRequestBuilder {
|
||||
|
||||
static final String CLOUDFRONT_USER_AGENT = "Amazon CloudFront";
|
||||
|
||||
private static final Set<String> BODY_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");
|
||||
/**
|
||||
* Never copied from a viewer request: hop-by-hop and framing fields the transport owns, and
|
||||
* fields CloudFront removes or sets itself whatever the policy says.
|
||||
*/
|
||||
private static final Set<String> NEVER_FORWARDED = Set.of(
|
||||
"connection", "keep-alive", "proxy-connection", "proxy-authenticate",
|
||||
"proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade",
|
||||
"content-length", "expect", "x-amz-cf-id", "x-real-ip", "x-forwarded-proto");
|
||||
/** Removed by legacy cache settings unless the behavior's {@code Headers} names them. */
|
||||
private static final Set<String> LEGACY_REMOVED = Set.of(
|
||||
"accept", "accept-charset", "accept-language", "authorization", "host", "referer",
|
||||
"user-agent", "x-http-method-override");
|
||||
private static final String FORWARDED_PROTO = "CloudFront-Forwarded-Proto";
|
||||
private static final String VIEWER_ADDRESS = "CloudFront-Viewer-Address";
|
||||
private static final String VIEWER_HTTP_VERSION = "CloudFront-Viewer-Http-Version";
|
||||
/** The CloudFront request headers Floci can derive from a local viewer connection. */
|
||||
private static final List<String> GENERATED_HEADERS =
|
||||
List.of(FORWARDED_PROTO, VIEWER_ADDRESS, VIEWER_HTTP_VERSION);
|
||||
|
||||
private CloudFrontOriginRequestBuilder() {
|
||||
}
|
||||
|
||||
/** One request header field, in the order it was received or will be sent. */
|
||||
record Header(String name, String value) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewer request as CloudFront received it. {@code rawQuery} has CloudFront's signing
|
||||
* parameters removed already; {@code httpVersion} is the viewer's protocol version, such as
|
||||
* {@code 1.1} or {@code 2.0}.
|
||||
*/
|
||||
record ViewerRequest(String method, List<Header> headers, String rawQuery,
|
||||
String clientAddress, int clientPort, String scheme, String httpVersion) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The forwarding configuration of the cache behavior that matched the request. A behavior
|
||||
* without a cache policy uses legacy cache settings, where only {@code forwardedValues} applies.
|
||||
* The policy maps use the shapes {@link CloudFrontPolicyConfigCodec} stores.
|
||||
*/
|
||||
record Forwarding(boolean legacy, Map<?, ?> cachePolicyParameters,
|
||||
Map<?, ?> originRequestPolicy, Map<?, ?> forwardedValues,
|
||||
boolean optionsCached) {
|
||||
|
||||
static Forwarding legacy(Map<?, ?> forwardedValues, boolean optionsCached) {
|
||||
return new Forwarding(true, null, null, forwardedValues, optionsCached);
|
||||
}
|
||||
|
||||
static Forwarding policies(Map<?, ?> cachePolicyParameters, Map<?, ?> originRequestPolicy,
|
||||
boolean optionsCached) {
|
||||
return new Forwarding(false, cachePolicyParameters, originRequestPolicy, null,
|
||||
optionsCached);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What CloudFront sends to the origin before origin custom headers are applied. {@code rawQuery}
|
||||
* is {@code null} when no query string is forwarded. A {@code Host} header is present only when
|
||||
* the viewer's {@code Host} is forwarded.
|
||||
*/
|
||||
record OriginRequest(List<Header> headers, String rawQuery) {
|
||||
|
||||
static OriginRequest empty() {
|
||||
return new OriginRequest(List.of(), null);
|
||||
}
|
||||
}
|
||||
|
||||
static OriginRequest build(ViewerRequest viewer, Forwarding forwarding, String viaHost) {
|
||||
Selections selections = forwarding.legacy()
|
||||
? legacySelections(forwarding.forwardedValues())
|
||||
: policySelections(forwarding.cachePolicyParameters(),
|
||||
forwarding.originRequestPolicy());
|
||||
Set<String> connectionTokens = connectionTokens(viewer.headers());
|
||||
boolean forwardsAuthorization = BODY_METHODS.contains(viewer.method())
|
||||
|| ("OPTIONS".equals(viewer.method()) && !forwarding.optionsCached());
|
||||
|
||||
List<Header> headers = new ArrayList<>();
|
||||
List<String> cookieHeaders = new ArrayList<>();
|
||||
List<String> forwardedFor = new ArrayList<>();
|
||||
List<String> via = new ArrayList<>();
|
||||
List<String> acceptEncoding = new ArrayList<>();
|
||||
boolean userAgentForwarded = false;
|
||||
for (Header header : viewer.headers()) {
|
||||
String name = normalize(header.name());
|
||||
if (name.isEmpty() || NEVER_FORWARDED.contains(name) || connectionTokens.contains(name)
|
||||
|| name.startsWith("x-edge-") || name.startsWith("cloudfront-")) {
|
||||
continue;
|
||||
}
|
||||
switch (name) {
|
||||
case "cookie" -> cookieHeaders.add(header.value());
|
||||
case "x-forwarded-for" -> forwardedFor.add(header.value());
|
||||
case "via" -> {
|
||||
if (selections.forwardsHeader(name)) {
|
||||
via.add(header.value());
|
||||
}
|
||||
}
|
||||
case "accept-encoding" -> {
|
||||
if (selections.normalizesAcceptEncoding()) {
|
||||
acceptEncoding.add(header.value());
|
||||
} else if (selections.forwardsHeader(name)) {
|
||||
headers.add(header);
|
||||
}
|
||||
}
|
||||
case "authorization" -> {
|
||||
if (forwardsAuthorization || selections.forwardsHeader(name)) {
|
||||
headers.add(header);
|
||||
}
|
||||
}
|
||||
case "content-type" -> {
|
||||
if (BODY_METHODS.contains(viewer.method()) || selections.forwardsHeader(name)) {
|
||||
headers.add(header);
|
||||
}
|
||||
}
|
||||
default -> {
|
||||
if (selections.forwardsHeader(name)) {
|
||||
headers.add(header);
|
||||
userAgentForwarded |= "user-agent".equals(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!userAgentForwarded) {
|
||||
headers.add(new Header("User-Agent", CLOUDFRONT_USER_AGENT));
|
||||
}
|
||||
if (!acceptEncoding.isEmpty()) {
|
||||
String normalized = normalizedAcceptEncoding(acceptEncoding, selections);
|
||||
if (normalized != null) {
|
||||
headers.add(new Header("Accept-Encoding", normalized));
|
||||
}
|
||||
}
|
||||
String cookies = forwardedCookies(cookieHeaders, selections.cookies());
|
||||
if (cookies != null) {
|
||||
headers.add(new Header("Cookie", cookies));
|
||||
}
|
||||
for (String generated : GENERATED_HEADERS) {
|
||||
String value = selections.generatesHeader(normalize(generated))
|
||||
? generatedValue(generated, viewer) : null;
|
||||
if (value != null) {
|
||||
headers.add(new Header(generated, value));
|
||||
}
|
||||
}
|
||||
String clientForwardedFor = forwardedFor(forwardedFor, viewer.clientAddress());
|
||||
if (clientForwardedFor != null) {
|
||||
headers.add(new Header("X-Forwarded-For", clientForwardedFor));
|
||||
}
|
||||
via.add(viewerProtocolVersion(viewer.httpVersion()) + " " + viaHost + " (CloudFront)");
|
||||
headers.add(new Header("Via", String.join(", ", via)));
|
||||
headers.add(new Header("X-Amz-Cf-Id", requestId()));
|
||||
|
||||
return new OriginRequest(List.copyOf(headers),
|
||||
forwardedQuery(viewer.rawQuery(), selections.queryStrings()));
|
||||
}
|
||||
|
||||
private static Selections policySelections(Map<?, ?> cachePolicyParameters,
|
||||
Map<?, ?> originRequestPolicy) {
|
||||
List<Selection> headers = new ArrayList<>();
|
||||
List<Selection> cookies = new ArrayList<>();
|
||||
List<Selection> queryStrings = new ArrayList<>();
|
||||
for (Map<?, ?> source : new Map<?, ?>[] {cachePolicyParameters, originRequestPolicy}) {
|
||||
if (source == null) {
|
||||
continue;
|
||||
}
|
||||
headers.add(Selection.of(source.get("HeadersConfig"), "HeaderBehavior", "Headers", true));
|
||||
cookies.add(Selection.of(source.get("CookiesConfig"), "CookieBehavior", "Cookies", false));
|
||||
queryStrings.add(Selection.of(
|
||||
source.get("QueryStringsConfig"), "QueryStringBehavior", "QueryStrings", false));
|
||||
}
|
||||
boolean gzip = cachePolicyParameters != null
|
||||
&& isTrue(cachePolicyParameters.get("EnableAcceptEncodingGzip"));
|
||||
boolean brotli = cachePolicyParameters != null
|
||||
&& isTrue(cachePolicyParameters.get("EnableAcceptEncodingBrotli"));
|
||||
return new Selections(false, headers, false, cookies, queryStrings, gzip, brotli,
|
||||
gzip || brotli);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy {@code ForwardedValues}: headers are forwarded by default apart from the ones CloudFront
|
||||
* removes, {@code Headers} adds names back (or everything with {@code *}), cookie names may use
|
||||
* {@code *} and {@code ?} wildcards, and {@code QueryString=true} forwards every query string
|
||||
* ({@code QueryStringCacheKeys} only narrows the cache key).
|
||||
*/
|
||||
private static Selections legacySelections(Map<?, ?> forwardedValues) {
|
||||
List<String> headerNames = stringList(forwardedValues, "Headers");
|
||||
boolean allHeaders = headerNames.contains("*");
|
||||
Selection headers = allHeaders
|
||||
? new Selection("all", List.of(), true, false)
|
||||
: new Selection("whitelist", headerNames, true, false);
|
||||
|
||||
String cookiesForward = forwardedValues != null && forwardedValues.get("CookiesForward") != null
|
||||
? forwardedValues.get("CookiesForward").toString() : "none";
|
||||
Selection cookies = new Selection(cookiesForward, stringList(forwardedValues, "CookieNames"),
|
||||
false, true);
|
||||
|
||||
boolean queryString = forwardedValues != null && isTrue(forwardedValues.get("QueryString"));
|
||||
Selection queryStrings = new Selection(queryString ? "all" : "none", List.of(), false, false);
|
||||
|
||||
return new Selections(true, List.of(headers), !allHeaders, List.of(cookies),
|
||||
List.of(queryStrings), true, true, !headers.includes("accept-encoding"));
|
||||
}
|
||||
|
||||
private static Set<String> connectionTokens(List<Header> headers) {
|
||||
Set<String> tokens = new HashSet<>();
|
||||
for (Header header : headers) {
|
||||
if ("connection".equals(normalize(header.name())) && header.value() != null) {
|
||||
for (String token : header.value().split(",")) {
|
||||
String normalized = normalize(token);
|
||||
if (!normalized.isEmpty()) {
|
||||
tokens.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept-Encoding normalization: the formats caching is enabled for that the viewer accepts, as
|
||||
* {@code br,gzip}, {@code gzip} or {@code br}. A cache policy sends {@code identity} when the
|
||||
* viewer accepts none of them; legacy settings drop the header instead.
|
||||
*/
|
||||
private static String normalizedAcceptEncoding(List<String> values, Selections selections) {
|
||||
Set<String> accepted = new HashSet<>();
|
||||
for (String value : values) {
|
||||
for (String coding : value.split(",")) {
|
||||
int parameters = coding.indexOf(';');
|
||||
String name = normalize(parameters >= 0 ? coding.substring(0, parameters) : coding);
|
||||
if (!name.isEmpty()) {
|
||||
accepted.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean brotli = selections.brotli() && accepted.contains("br");
|
||||
boolean gzip = selections.gzip() && accepted.contains("gzip");
|
||||
if (brotli && gzip) {
|
||||
return "br,gzip";
|
||||
}
|
||||
if (brotli) {
|
||||
return "br";
|
||||
}
|
||||
if (gzip) {
|
||||
return "gzip";
|
||||
}
|
||||
return selections.legacy() ? null : "identity";
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewer cookies the behavior forwards, sorted by name as CloudFront sorts them. Pairs that are
|
||||
* not {@code name=value}, and names starting with {@code $}, are dropped.
|
||||
*/
|
||||
private static String forwardedCookies(List<String> cookieHeaders, List<Selection> selections) {
|
||||
List<String[]> cookies = new ArrayList<>();
|
||||
for (String cookieHeader : cookieHeaders) {
|
||||
if (cookieHeader == null) {
|
||||
continue;
|
||||
}
|
||||
for (String pair : cookieHeader.split(";")) {
|
||||
int eq = pair.indexOf('=');
|
||||
String name = eq > 0 ? pair.substring(0, eq).trim() : "";
|
||||
if (name.isEmpty() || name.startsWith("$")) {
|
||||
continue;
|
||||
}
|
||||
if (selections.stream().anyMatch(selection -> selection.includes(name))) {
|
||||
cookies.add(new String[] {name, pair.substring(eq + 1).trim()});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cookies.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
cookies.sort(Comparator.comparing(cookie -> cookie[0]));
|
||||
List<String> pairs = new ArrayList<>(cookies.size());
|
||||
for (String[] cookie : cookies) {
|
||||
pairs.add(cookie[0] + "=" + cookie[1]);
|
||||
}
|
||||
return String.join("; ", pairs);
|
||||
}
|
||||
|
||||
/** The raw query pairs the behavior forwards, in viewer order and with their original encoding. */
|
||||
private static String forwardedQuery(String rawQuery, List<Selection> selections) {
|
||||
if (rawQuery == null || rawQuery.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (selections.stream().anyMatch(selection -> "all".equals(selection.behavior()))) {
|
||||
return rawQuery;
|
||||
}
|
||||
List<String> kept = new ArrayList<>();
|
||||
for (String pair : rawQuery.split("&")) {
|
||||
if (pair.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
int eq = pair.indexOf('=');
|
||||
String name = decodeQueryName(eq >= 0 ? pair.substring(0, eq) : pair);
|
||||
if (selections.stream().anyMatch(selection -> selection.includes(name))) {
|
||||
kept.add(pair);
|
||||
}
|
||||
}
|
||||
return kept.isEmpty() ? null : String.join("&", kept);
|
||||
}
|
||||
|
||||
private static String decodeQueryName(String rawName) {
|
||||
try {
|
||||
return URLDecoder.decode(rawName, StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException malformed) {
|
||||
// A name that is not valid percent-encoding can only match a policy entry literally.
|
||||
return rawName;
|
||||
}
|
||||
}
|
||||
|
||||
private static String generatedValue(String header, ViewerRequest viewer) {
|
||||
return switch (header) {
|
||||
case FORWARDED_PROTO -> viewer.scheme() != null
|
||||
? viewer.scheme().toLowerCase(Locale.ROOT) : null;
|
||||
case VIEWER_ADDRESS -> viewer.clientAddress() != null && viewer.clientPort() > 0
|
||||
? viewer.clientAddress() + ":" + viewer.clientPort() : null;
|
||||
case VIEWER_HTTP_VERSION -> viewer.httpVersion();
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/** CloudFront appends the viewer's address to any {@code X-Forwarded-For} the viewer sent. */
|
||||
private static String forwardedFor(List<String> viewerValues, String clientAddress) {
|
||||
List<String> values = new ArrayList<>(viewerValues);
|
||||
if (clientAddress != null && !clientAddress.isBlank()) {
|
||||
values.add(clientAddress);
|
||||
}
|
||||
return values.isEmpty() ? null : String.join(",", values);
|
||||
}
|
||||
|
||||
private static String viewerProtocolVersion(String httpVersion) {
|
||||
return httpVersion == null || httpVersion.isBlank() ? "1.1" : httpVersion;
|
||||
}
|
||||
|
||||
/** A request identifier shaped like CloudFront's: 40 random bytes, URL-safe Base64. */
|
||||
private static String requestId() {
|
||||
byte[] bytes = new byte[40];
|
||||
ThreadLocalRandom.current().nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().encodeToString(bytes);
|
||||
}
|
||||
|
||||
private static boolean isTrue(Object value) {
|
||||
return value instanceof Boolean bool ? bool : value != null && "true".equalsIgnoreCase(value.toString());
|
||||
}
|
||||
|
||||
private static List<String> stringList(Map<?, ?> map, String key) {
|
||||
Object value = map != null ? map.get(key) : null;
|
||||
if (!(value instanceof List<?> list)) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> strings = new ArrayList<>(list.size());
|
||||
for (Object item : list) {
|
||||
if (item != null) {
|
||||
strings.add(item.toString());
|
||||
}
|
||||
}
|
||||
return strings;
|
||||
}
|
||||
|
||||
private static String normalize(String name) {
|
||||
return name == null ? "" : name.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/** The resolved selections of one request, unioned across the policies that apply. */
|
||||
private record Selections(boolean legacy, List<Selection> headers, boolean legacyDefaults,
|
||||
List<Selection> cookies, List<Selection> queryStrings,
|
||||
boolean gzip, boolean brotli, boolean normalizesAcceptEncoding) {
|
||||
|
||||
/**
|
||||
* Whether a viewer header is forwarded: a policy selects it, or legacy settings forward it
|
||||
* by default.
|
||||
*/
|
||||
boolean forwardsHeader(String normalizedName) {
|
||||
if (headers.stream().anyMatch(selection -> selection.includes(normalizedName))) {
|
||||
return true;
|
||||
}
|
||||
return legacyDefaults && !LEGACY_REMOVED.contains(normalizedName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether CloudFront adds one of its own request headers: a whitelist or
|
||||
* {@code allViewerAndWhitelistCloudFront} list names it, or {@code allExcept} does not
|
||||
* exclude it (the managed AllViewerExceptHostHeader policy is documented to add them).
|
||||
*/
|
||||
boolean generatesHeader(String normalizedName) {
|
||||
for (Selection selection : headers) {
|
||||
boolean generated = switch (selection.behavior()) {
|
||||
case "whitelist", "allViewerAndWhitelistCloudFront" -> selection.lists(normalizedName);
|
||||
case "allExcept" -> !selection.lists(normalizedName);
|
||||
default -> false;
|
||||
};
|
||||
if (generated) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One policy's choice of names of one kind: headers, cookies or query strings. Header names match
|
||||
* case-insensitively, cookie and query string names case-sensitively. Legacy cookie allowlists may
|
||||
* use {@code *} and {@code ?} wildcards.
|
||||
*/
|
||||
private record Selection(String behavior, List<String> names, boolean ignoreCase,
|
||||
boolean wildcards) {
|
||||
|
||||
static Selection of(Object block, String behaviorKey, String listKey, boolean ignoreCase) {
|
||||
if (!(block instanceof Map<?, ?> map) || map.get(behaviorKey) == null) {
|
||||
return new Selection("none", List.of(), ignoreCase, false);
|
||||
}
|
||||
return new Selection(map.get(behaviorKey).toString(), stringList(map, listKey),
|
||||
ignoreCase, false);
|
||||
}
|
||||
|
||||
boolean includes(String name) {
|
||||
return switch (behavior) {
|
||||
case "whitelist" -> lists(name);
|
||||
case "all", "allViewer", "allViewerAndWhitelistCloudFront" -> true;
|
||||
case "allExcept" -> !lists(name);
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
boolean lists(String name) {
|
||||
for (String candidate : names) {
|
||||
boolean matches = wildcards
|
||||
? wildcardMatches(candidate, name)
|
||||
: ignoreCase ? candidate.equalsIgnoreCase(name) : candidate.equals(name);
|
||||
if (matches) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean wildcardMatches(String pattern, String name) {
|
||||
return wildcardMatches(pattern, 0, name, 0);
|
||||
}
|
||||
|
||||
private static boolean wildcardMatches(String pattern, int p, String name, int n) {
|
||||
if (p == pattern.length()) {
|
||||
return n == name.length();
|
||||
}
|
||||
char c = pattern.charAt(p);
|
||||
if (c == '*') {
|
||||
for (int i = n; i <= name.length(); i++) {
|
||||
if (wildcardMatches(pattern, p + 1, name, i)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return n < name.length() && (c == '?' || c == name.charAt(n))
|
||||
&& wildcardMatches(pattern, p + 1, name, n + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -166,6 +166,35 @@ public final class CloudFrontRequestRouter {
|
||||
return allowedMethodsOrDefault(dflt != null ? dflt.getAllowedMethods() : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings that decide what the cache behavior serving a normalized path forwards to its
|
||||
* origin: cache policy, origin request policy, legacy {@code ForwardedValues} and cached methods.
|
||||
*/
|
||||
public record BehaviorForwarding(String cachePolicyId, String originRequestPolicyId,
|
||||
Map<String, Object> forwardedValues,
|
||||
List<String> cachedMethods) {
|
||||
}
|
||||
|
||||
/** Returns the forwarding settings of the cache behavior that serves a normalized path. */
|
||||
public static BehaviorForwarding matchForwarding(DistributionConfig config, String normalizedPath) {
|
||||
List<CacheBehavior> behaviors = config.getCacheBehaviors();
|
||||
if (behaviors != null) {
|
||||
for (CacheBehavior behavior : behaviors) {
|
||||
if (pathPatternMatches(behavior.getPathPattern(), normalizedPath)) {
|
||||
return new BehaviorForwarding(behavior.getCachePolicyId(),
|
||||
behavior.getOriginRequestPolicyId(), behavior.getForwardedValues(),
|
||||
behavior.getCachedMethods());
|
||||
}
|
||||
}
|
||||
}
|
||||
DefaultCacheBehavior dflt = config.getDefaultCacheBehavior();
|
||||
if (dflt == null) {
|
||||
return new BehaviorForwarding(null, null, null, null);
|
||||
}
|
||||
return new BehaviorForwarding(dflt.getCachePolicyId(), dflt.getOriginRequestPolicyId(),
|
||||
dflt.getForwardedValues(), dflt.getCachedMethods());
|
||||
}
|
||||
|
||||
private static List<String> allowedMethodsOrDefault(List<String> methods) {
|
||||
return methods == null || methods.isEmpty() ? List.of("GET", "HEAD") : methods;
|
||||
}
|
||||
|
||||
+158
-3
@@ -83,6 +83,24 @@ public class CloudFrontService {
|
||||
"60669652-455b-4ae9-85a4-c4c02393f86c";
|
||||
private static final Map<String, ResponseHeadersPolicy> MANAGED_RESPONSE_HEADERS_POLICIES =
|
||||
managedResponseHeadersPolicies();
|
||||
static final String MANAGED_ALL_VIEWER_ORIGIN_REQUEST_POLICY_ID =
|
||||
"216adef6-5c7f-47e4-b989-5492eafa07d3";
|
||||
static final String MANAGED_ALL_VIEWER_AND_CLOUDFRONT_HEADERS_ORIGIN_REQUEST_POLICY_ID =
|
||||
"33f36d7e-f396-46d9-90e0-52428a34d9dc";
|
||||
static final String MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_ORIGIN_REQUEST_POLICY_ID =
|
||||
"b689b0a8-53d0-40ab-baf2-68738e2966ac";
|
||||
static final String MANAGED_CORS_CUSTOM_ORIGIN_ORIGIN_REQUEST_POLICY_ID =
|
||||
"59781a5b-3903-41f3-afcb-af62929ccde1";
|
||||
static final String MANAGED_CORS_S3_ORIGIN_ORIGIN_REQUEST_POLICY_ID =
|
||||
"88a5eaf4-2fd4-4709-b370-b4c650ea3fcf";
|
||||
static final String MANAGED_MEDIATAILOR_ORIGIN_REQUEST_POLICY_ID =
|
||||
"775133bc-15f2-49f9-abea-afb2e0bf67d2";
|
||||
static final String MANAGED_HOST_HEADER_ONLY_ORIGIN_REQUEST_POLICY_ID =
|
||||
"bf0718e1-ba1e-49d1-88b1-f726733018ae";
|
||||
static final String MANAGED_USER_AGENT_REFERER_ORIGIN_REQUEST_POLICY_ID =
|
||||
"acba4595-bd28-49b8-b9fe-13317c0390fa";
|
||||
private static final Map<String, OriginRequestPolicy> MANAGED_ORIGIN_REQUEST_POLICIES =
|
||||
managedOriginRequestPolicies();
|
||||
|
||||
private final StorageBackend<String, Distribution> distStore;
|
||||
private final StorageBackend<String, List<Invalidation>> invalidationStore;
|
||||
@@ -543,14 +561,19 @@ public class CloudFrontService {
|
||||
}
|
||||
|
||||
public OriginRequestPolicy getOriginRequestPolicy(String id) {
|
||||
return orpStore.get(id).orElseThrow(() ->
|
||||
new AwsException("NoSuchOriginRequestPolicy",
|
||||
return orpStore.get(id)
|
||||
.or(() -> Optional.ofNullable(MANAGED_ORIGIN_REQUEST_POLICIES.get(id)))
|
||||
.orElseThrow(() -> new AwsException("NoSuchOriginRequestPolicy",
|
||||
"The specified origin request policy does not exist.", 404));
|
||||
}
|
||||
|
||||
public synchronized OriginRequestPolicy updateOriginRequestPolicy(String id, String ifMatch,
|
||||
OriginRequestPolicy updated) {
|
||||
OriginRequestPolicy existing = getOriginRequestPolicy(id);
|
||||
if (isManagedOriginRequestPolicy(id)) {
|
||||
throw new AwsException("IllegalUpdate",
|
||||
"AWS managed origin request policies cannot be updated.", 400);
|
||||
}
|
||||
if (!existing.getEtag().equals(ifMatch)) {
|
||||
throw new AwsException("InvalidIfMatchVersion",
|
||||
"The If-Match version is missing or not valid for the resource.", 400);
|
||||
@@ -564,6 +587,10 @@ public class CloudFrontService {
|
||||
|
||||
public synchronized void deleteOriginRequestPolicy(String id, String ifMatch) {
|
||||
OriginRequestPolicy existing = getOriginRequestPolicy(id);
|
||||
if (isManagedOriginRequestPolicy(id)) {
|
||||
throw new AwsException("IllegalDelete",
|
||||
"AWS managed origin request policies cannot be deleted.", 400);
|
||||
}
|
||||
if (!existing.getEtag().equals(ifMatch)) {
|
||||
throw new AwsException("InvalidIfMatchVersion",
|
||||
"The If-Match version is missing or not valid for the resource.", 400);
|
||||
@@ -577,12 +604,34 @@ public class CloudFrontService {
|
||||
}
|
||||
|
||||
public List<OriginRequestPolicy> listOriginRequestPolicies(String marker, int maxItems) {
|
||||
List<OriginRequestPolicy> all = new ArrayList<>(orpStore.scan(k -> true));
|
||||
return listOriginRequestPolicies(marker, maxItems, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists custom and AWS managed origin request policies; {@code type} narrows the list to the
|
||||
* AWS lowercase {@code managed} or {@code custom} value.
|
||||
*/
|
||||
public List<OriginRequestPolicy> listOriginRequestPolicies(String marker, int maxItems, String type) {
|
||||
if (type != null && !"custom".equals(type) && !"managed".equals(type)) {
|
||||
throw new AwsException("InvalidArgument",
|
||||
"Origin request policy Type must be managed or custom.", 400);
|
||||
}
|
||||
List<OriginRequestPolicy> all = new ArrayList<>();
|
||||
if (!"managed".equals(type)) {
|
||||
all.addAll(orpStore.scan(k -> true));
|
||||
}
|
||||
if (!"custom".equals(type)) {
|
||||
all.addAll(MANAGED_ORIGIN_REQUEST_POLICIES.values());
|
||||
}
|
||||
all.sort((a, b) -> a.getName() != null && b.getName() != null
|
||||
? a.getName().compareTo(b.getName()) : a.getId().compareTo(b.getId()));
|
||||
return paginate(all, marker, maxItems, OriginRequestPolicy::getId);
|
||||
}
|
||||
|
||||
static boolean isManagedOriginRequestPolicy(String id) {
|
||||
return MANAGED_ORIGIN_REQUEST_POLICIES.containsKey(id);
|
||||
}
|
||||
|
||||
// ── Response Headers Policies ─────────────────────────────────────────────
|
||||
|
||||
public synchronized ResponseHeadersPolicy createResponseHeadersPolicy(ResponseHeadersPolicy policy) {
|
||||
@@ -840,6 +889,112 @@ public class CloudFrontService {
|
||||
return Map.copyOf(policies);
|
||||
}
|
||||
|
||||
/**
|
||||
* The AWS managed origin request policies, with the IDs and settings the CloudFront Developer
|
||||
* Guide lists under "Use managed origin request policies".
|
||||
*/
|
||||
private static Map<String, OriginRequestPolicy> managedOriginRequestPolicies() {
|
||||
Map<String, OriginRequestPolicy> policies = new LinkedHashMap<>();
|
||||
policies.put(MANAGED_ALL_VIEWER_ORIGIN_REQUEST_POLICY_ID, managedOriginRequestPolicy(
|
||||
MANAGED_ALL_VIEWER_ORIGIN_REQUEST_POLICY_ID, "Managed-AllViewer",
|
||||
"Includes all values (headers, cookies, and query strings) from the viewer request",
|
||||
selection("HeaderBehavior", "allViewer", "Headers", List.of()),
|
||||
selection("CookieBehavior", "all", "Cookies", List.of()),
|
||||
selection("QueryStringBehavior", "all", "QueryStrings", List.of())));
|
||||
policies.put(MANAGED_ALL_VIEWER_AND_CLOUDFRONT_HEADERS_ORIGIN_REQUEST_POLICY_ID,
|
||||
managedOriginRequestPolicy(
|
||||
MANAGED_ALL_VIEWER_AND_CLOUDFRONT_HEADERS_ORIGIN_REQUEST_POLICY_ID,
|
||||
"Managed-AllViewerAndCloudFrontHeaders-2022-06",
|
||||
"Includes all values from the viewer request and all CloudFront headers "
|
||||
+ "released through June 2022",
|
||||
selection("HeaderBehavior", "allViewerAndWhitelistCloudFront", "Headers",
|
||||
List.of("CloudFront-Forwarded-Proto", "CloudFront-Is-Android-Viewer",
|
||||
"CloudFront-Is-Desktop-Viewer", "CloudFront-Is-IOS-Viewer",
|
||||
"CloudFront-Is-Mobile-Viewer", "CloudFront-Is-SmartTV-Viewer",
|
||||
"CloudFront-Is-Tablet-Viewer", "CloudFront-Viewer-Address",
|
||||
"CloudFront-Viewer-ASN", "CloudFront-Viewer-City",
|
||||
"CloudFront-Viewer-Country", "CloudFront-Viewer-Country-Name",
|
||||
"CloudFront-Viewer-Country-Region",
|
||||
"CloudFront-Viewer-Country-Region-Name",
|
||||
"CloudFront-Viewer-Http-Version", "CloudFront-Viewer-Latitude",
|
||||
"CloudFront-Viewer-Longitude", "CloudFront-Viewer-Metro-Code",
|
||||
"CloudFront-Viewer-Postal-Code", "CloudFront-Viewer-Time-Zone",
|
||||
"CloudFront-Viewer-TLS")),
|
||||
selection("CookieBehavior", "all", "Cookies", List.of()),
|
||||
selection("QueryStringBehavior", "all", "QueryStrings", List.of())));
|
||||
policies.put(MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_ORIGIN_REQUEST_POLICY_ID,
|
||||
managedOriginRequestPolicy(
|
||||
MANAGED_ALL_VIEWER_EXCEPT_HOST_HEADER_ORIGIN_REQUEST_POLICY_ID,
|
||||
"Managed-AllViewerExceptHostHeader",
|
||||
"Includes all values from the viewer request except the Host header",
|
||||
selection("HeaderBehavior", "allExcept", "Headers", List.of("Host")),
|
||||
selection("CookieBehavior", "all", "Cookies", List.of()),
|
||||
selection("QueryStringBehavior", "all", "QueryStrings", List.of())));
|
||||
policies.put(MANAGED_CORS_CUSTOM_ORIGIN_ORIGIN_REQUEST_POLICY_ID, managedOriginRequestPolicy(
|
||||
MANAGED_CORS_CUSTOM_ORIGIN_ORIGIN_REQUEST_POLICY_ID, "Managed-CORS-CustomOrigin",
|
||||
"Includes the header that enables CORS requests when the origin is a custom origin",
|
||||
selection("HeaderBehavior", "whitelist", "Headers", List.of("Origin")),
|
||||
selection("CookieBehavior", "none", "Cookies", List.of()),
|
||||
selection("QueryStringBehavior", "none", "QueryStrings", List.of())));
|
||||
policies.put(MANAGED_CORS_S3_ORIGIN_ORIGIN_REQUEST_POLICY_ID, managedOriginRequestPolicy(
|
||||
MANAGED_CORS_S3_ORIGIN_ORIGIN_REQUEST_POLICY_ID, "Managed-CORS-S3Origin",
|
||||
"Includes the headers that enable CORS requests when the origin is an Amazon S3 bucket",
|
||||
selection("HeaderBehavior", "whitelist", "Headers", List.of(
|
||||
"Origin", "Access-Control-Request-Headers", "Access-Control-Request-Method")),
|
||||
selection("CookieBehavior", "none", "Cookies", List.of()),
|
||||
selection("QueryStringBehavior", "none", "QueryStrings", List.of())));
|
||||
policies.put(MANAGED_MEDIATAILOR_ORIGIN_REQUEST_POLICY_ID, managedOriginRequestPolicy(
|
||||
MANAGED_MEDIATAILOR_ORIGIN_REQUEST_POLICY_ID,
|
||||
"Managed-Elemental-MediaTailor-PersonalizedManifests",
|
||||
"For use with an origin that is an AWS Elemental MediaTailor endpoint",
|
||||
selection("HeaderBehavior", "whitelist", "Headers", List.of(
|
||||
"Origin", "Access-Control-Request-Headers", "Access-Control-Request-Method",
|
||||
"User-Agent", "X-Forwarded-For")),
|
||||
selection("CookieBehavior", "none", "Cookies", List.of()),
|
||||
selection("QueryStringBehavior", "all", "QueryStrings", List.of())));
|
||||
policies.put(MANAGED_HOST_HEADER_ONLY_ORIGIN_REQUEST_POLICY_ID, managedOriginRequestPolicy(
|
||||
MANAGED_HOST_HEADER_ONLY_ORIGIN_REQUEST_POLICY_ID, "Managed-HostHeaderOnly",
|
||||
"Includes only the Host header from the viewer request",
|
||||
selection("HeaderBehavior", "whitelist", "Headers", List.of("Host")),
|
||||
selection("CookieBehavior", "none", "Cookies", List.of()),
|
||||
selection("QueryStringBehavior", "none", "QueryStrings", List.of())));
|
||||
policies.put(MANAGED_USER_AGENT_REFERER_ORIGIN_REQUEST_POLICY_ID, managedOriginRequestPolicy(
|
||||
MANAGED_USER_AGENT_REFERER_ORIGIN_REQUEST_POLICY_ID, "Managed-UserAgentRefererHeaders",
|
||||
"Includes only the User-Agent and Referer headers",
|
||||
selection("HeaderBehavior", "whitelist", "Headers", List.of("User-Agent", "Referer")),
|
||||
selection("CookieBehavior", "none", "Cookies", List.of()),
|
||||
selection("QueryStringBehavior", "none", "QueryStrings", List.of())));
|
||||
return Map.copyOf(policies);
|
||||
}
|
||||
|
||||
private static OriginRequestPolicy managedOriginRequestPolicy(
|
||||
String id, String name, String comment, 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);
|
||||
OriginRequestPolicy policy = new OriginRequestPolicy();
|
||||
policy.setId(id);
|
||||
policy.setName(name);
|
||||
policy.setComment(comment);
|
||||
policy.setEtag("E23ZP02F085DFQ");
|
||||
policy.setLastModifiedTime(Instant.EPOCH);
|
||||
policy.setConfig(Map.copyOf(config));
|
||||
return policy;
|
||||
}
|
||||
|
||||
/** One policy selection block, in the shape {@link CloudFrontPolicyConfigCodec} stores. */
|
||||
private static Map<String, Object> selection(String behaviorKey, String behavior,
|
||||
String listKey, List<String> names) {
|
||||
Map<String, Object> block = new LinkedHashMap<>();
|
||||
block.put(behaviorKey, behavior);
|
||||
if (!names.isEmpty()) {
|
||||
block.put(listKey, List.copyOf(names));
|
||||
}
|
||||
return Map.copyOf(block);
|
||||
}
|
||||
|
||||
private static ResponseHeadersPolicy managedResponseHeadersPolicy(
|
||||
String id, String name, String comment, Map<String, Object> config) {
|
||||
ResponseHeadersPolicy policy = new ResponseHeadersPolicy();
|
||||
|
||||
+106
-51
@@ -11,6 +11,8 @@ import io.github.hectorvent.floci.services.s3.S3Service;
|
||||
import io.github.hectorvent.floci.services.s3.model.S3Object;
|
||||
import io.quarkus.vertx.http.runtime.CurrentVertxRequest;
|
||||
import io.vertx.core.http.HttpServerRequest;
|
||||
import io.vertx.core.http.HttpVersion;
|
||||
import io.vertx.core.net.SocketAddress;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
@@ -59,7 +61,9 @@ import java.util.Set;
|
||||
* <ul>
|
||||
* <li>S3 origins are read in-process through {@link S3Service} (the bucket is derived from the
|
||||
* origin domain name).</li>
|
||||
* <li>Custom origins are fetched over HTTP(S) with {@link java.net.http.HttpClient}.</li>
|
||||
* <li>Custom origins are fetched over HTTP(S) with {@link CloudFrontOriginHttpClient}, carrying the
|
||||
* viewer headers, cookies and query strings the matched behavior forwards, as decided by
|
||||
* {@link CloudFrontOriginRequestBuilder}.</li>
|
||||
* <li>When an origin returns an error status, a matching {@code CustomErrorResponse} is applied —
|
||||
* most importantly the single-page-app fallback that rewrites 403/404 to 200 {@code /index.html}.
|
||||
* If the configured error page is itself missing, the received status is returned (no loop).</li>
|
||||
@@ -128,7 +132,7 @@ public class CloudFrontServingController {
|
||||
RequestHost.of(request),
|
||||
headers.getHeaderString(HttpHeaders.AUTHORIZATION),
|
||||
request.getHeader("Origin"), "GET", null, null,
|
||||
request.getHeader("Pragma"), null, null);
|
||||
request.getHeader("Pragma"), null);
|
||||
}
|
||||
|
||||
@HEAD
|
||||
@@ -142,7 +146,7 @@ public class CloudFrontServingController {
|
||||
RequestHost.of(request),
|
||||
headers.getHeaderString(HttpHeaders.AUTHORIZATION),
|
||||
request.getHeader("Origin"), "HEAD", null, null,
|
||||
request.getHeader("Pragma"), null, null);
|
||||
request.getHeader("Pragma"), null);
|
||||
}
|
||||
|
||||
@OPTIONS
|
||||
@@ -158,7 +162,7 @@ public class CloudFrontServingController {
|
||||
request.getHeader("Origin"), "OPTIONS",
|
||||
request.getHeader("Access-Control-Request-Method"),
|
||||
request.getHeader("Access-Control-Request-Headers"),
|
||||
request.getHeader("Pragma"), null, null);
|
||||
request.getHeader("Pragma"), null);
|
||||
}
|
||||
|
||||
@POST
|
||||
@@ -203,8 +207,7 @@ public class CloudFrontServingController {
|
||||
headers.getHeaderString(HttpHeaders.AUTHORIZATION),
|
||||
request.getHeader("Origin"), method, null, null,
|
||||
request.getHeader("Pragma"),
|
||||
body != null ? body : new byte[0],
|
||||
request.getHeader(HttpHeaders.CONTENT_TYPE));
|
||||
body != null ? body : new byte[0]);
|
||||
}
|
||||
|
||||
private Response serve(String distId, String rawViewerPath, String decodedViewerPath,
|
||||
@@ -214,8 +217,7 @@ public class CloudFrontServingController {
|
||||
String accessControlRequestMethod,
|
||||
String accessControlRequestHeaders,
|
||||
String pragma,
|
||||
byte[] viewerBody,
|
||||
String viewerContentType) {
|
||||
byte[] viewerBody) {
|
||||
boolean includeBody = !"HEAD".equals(method);
|
||||
boolean preflightRequest = "OPTIONS".equals(method)
|
||||
&& viewerOrigin != null && !viewerOrigin.isBlank()
|
||||
@@ -258,8 +260,7 @@ public class CloudFrontServingController {
|
||||
|
||||
OriginResponse origin = route(dist, normalized, rawViewerPath, decodedViewerPath,
|
||||
viewerScheme, viewerAuthorization, method, viewerOrigin,
|
||||
accessControlRequestMethod, accessControlRequestHeaders,
|
||||
viewerBody, viewerContentType);
|
||||
accessControlRequestMethod, accessControlRequestHeaders, viewerBody);
|
||||
|
||||
if (origin.status() >= 400) {
|
||||
Response fallback = applyCustomError(
|
||||
@@ -406,7 +407,7 @@ public class CloudFrontServingController {
|
||||
String method, String viewerOrigin,
|
||||
String accessControlRequestMethod,
|
||||
String accessControlRequestHeaders,
|
||||
byte[] viewerBody, String viewerContentType) {
|
||||
byte[] viewerBody) {
|
||||
DistributionConfig config = distribution.getConfig();
|
||||
String originId = CloudFrontRequestRouter.matchTargetOriginId(config, normalized);
|
||||
Origin origin = CloudFrontRequestRouter.findOrigin(config, originId);
|
||||
@@ -433,12 +434,86 @@ public class CloudFrontServingController {
|
||||
}
|
||||
String forwardUri = CloudFrontRequestRouter.resolveForwardUri(
|
||||
origin.getOriginPath(), rawViewerPath, config.getDefaultRootObject());
|
||||
// CloudFront forwards viewer query strings only when selected by a cache policy,
|
||||
// origin request policy, or legacy ForwardedValues configuration. Those policy
|
||||
// semantics are not modeled in the data plane yet, so the AWS default is to omit them.
|
||||
return fetchFromCustomOrigin(origin, forwardUri, null, viewerScheme, method,
|
||||
viewerOrigin, accessControlRequestMethod, accessControlRequestHeaders,
|
||||
viewerAuthorization, viewerBody, viewerContentType);
|
||||
CloudFrontOriginRequestBuilder.OriginRequest originRequest =
|
||||
CloudFrontOriginRequestBuilder.build(viewerRequest(method, viewerScheme),
|
||||
forwarding(config, normalized), distribution.getDomainName());
|
||||
return fetchFromCustomOrigin(
|
||||
origin, forwardUri, originRequest, viewerScheme, method, viewerBody);
|
||||
}
|
||||
|
||||
/** The live viewer request, with CloudFront's signing parameters removed from its query. */
|
||||
private CloudFrontOriginRequestBuilder.ViewerRequest viewerRequest(
|
||||
String method, String viewerScheme) {
|
||||
HttpServerRequest request = currentVertxRequest.getCurrent().request();
|
||||
List<CloudFrontOriginRequestBuilder.Header> headers = new ArrayList<>();
|
||||
for (Map.Entry<String, String> header : request.headers()) {
|
||||
headers.add(new CloudFrontOriginRequestBuilder.Header(header.getKey(), header.getValue()));
|
||||
}
|
||||
String appQuery = stripCloudFrontSigningParams(request.query());
|
||||
SocketAddress client = request.remoteAddress();
|
||||
return new CloudFrontOriginRequestBuilder.ViewerRequest(method, headers,
|
||||
appQuery.isEmpty() ? null : appQuery,
|
||||
client != null ? client.hostAddress() : null,
|
||||
client != null ? client.port() : -1,
|
||||
viewerScheme, httpVersion(request.version()));
|
||||
}
|
||||
|
||||
private static String httpVersion(HttpVersion version) {
|
||||
if (version == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (version) {
|
||||
case HTTP_1_0 -> "1.0";
|
||||
case HTTP_1_1 -> "1.1";
|
||||
case HTTP_2 -> "2.0";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves what the matched behavior forwards: legacy {@code ForwardedValues} when it has no cache
|
||||
* policy, otherwise its cache policy parameters and origin request policy.
|
||||
*/
|
||||
private CloudFrontOriginRequestBuilder.Forwarding forwarding(
|
||||
DistributionConfig config, String normalized) {
|
||||
CloudFrontRequestRouter.BehaviorForwarding behavior =
|
||||
CloudFrontRequestRouter.matchForwarding(config, normalized);
|
||||
boolean optionsCached = behavior.cachedMethods() != null
|
||||
&& behavior.cachedMethods().contains("OPTIONS");
|
||||
if (behavior.cachePolicyId() == null || behavior.cachePolicyId().isBlank()) {
|
||||
return CloudFrontOriginRequestBuilder.Forwarding.legacy(
|
||||
behavior.forwardedValues(), optionsCached);
|
||||
}
|
||||
return CloudFrontOriginRequestBuilder.Forwarding.policies(
|
||||
cachePolicyParameters(behavior.cachePolicyId()),
|
||||
originRequestPolicyConfig(behavior.originRequestPolicyId()), optionsCached);
|
||||
}
|
||||
|
||||
private Map<?, ?> cachePolicyParameters(String cachePolicyId) {
|
||||
try {
|
||||
Map<String, Object> config = service.getCachePolicy(cachePolicyId).getConfig();
|
||||
return config != null
|
||||
&& config.get("ParametersInCacheKeyAndForwardedToOrigin") instanceof Map<?, ?> parameters
|
||||
? parameters : null;
|
||||
} catch (AwsException e) {
|
||||
// AWS managed cache policies are not modeled. The common ones (CachingDisabled,
|
||||
// CachingOptimized) forward no viewer values, which is what an unknown id contributes.
|
||||
LOG.debugv("Cache policy {0} is not defined; it forwards no viewer values", cachePolicyId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<?, ?> originRequestPolicyConfig(String originRequestPolicyId) {
|
||||
if (originRequestPolicyId == null || originRequestPolicyId.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return service.getOriginRequestPolicy(originRequestPolicyId).getConfig();
|
||||
} catch (AwsException e) {
|
||||
// Distribution writes do not validate this reference, so keep serving without the policy
|
||||
// but make the dangling id visible.
|
||||
LOG.warnv("Distribution references missing origin request policy {0}", originRequestPolicyId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private OriginResponse fetchFromS3(
|
||||
@@ -560,17 +635,17 @@ public class CloudFrontServingController {
|
||||
"This CORS request is not allowed."));
|
||||
}
|
||||
|
||||
/** Fetches from a custom (non-S3) origin. {@code forwardUri} already includes the origin path. */
|
||||
private OriginResponse fetchFromCustomOrigin(Origin origin, String forwardUri, String rawQuery,
|
||||
String viewerScheme, String method, String viewerOrigin,
|
||||
String accessControlRequestMethod,
|
||||
String accessControlRequestHeaders,
|
||||
String viewerAuthorization,
|
||||
byte[] viewerBody, String viewerContentType) {
|
||||
/**
|
||||
* Fetches from a custom (non-S3) origin. {@code forwardUri} already includes the origin path;
|
||||
* {@code originRequest} carries the forwarded query and headers.
|
||||
*/
|
||||
private OriginResponse fetchFromCustomOrigin(Origin origin, String forwardUri,
|
||||
CloudFrontOriginRequestBuilder.OriginRequest originRequest,
|
||||
String viewerScheme, String method, byte[] viewerBody) {
|
||||
boolean includeBody = !"HEAD".equals(method);
|
||||
try {
|
||||
URI target = buildCustomOriginUri(
|
||||
origin, viewerScheme, forwardUri, rawQuery);
|
||||
origin, viewerScheme, forwardUri, originRequest.rawQuery());
|
||||
HttpRequest.Builder rb = HttpRequest.newBuilder()
|
||||
.uri(target)
|
||||
.timeout(Duration.ofSeconds(30));
|
||||
@@ -589,24 +664,10 @@ public class CloudFrontServingController {
|
||||
}
|
||||
}
|
||||
rb.method(method, HttpRequest.BodyPublishers.noBody());
|
||||
if ("OPTIONS".equals(method)) {
|
||||
addRequestHeader(rb, "Origin", viewerOrigin);
|
||||
addRequestHeader(rb, "Access-Control-Request-Method", accessControlRequestMethod);
|
||||
addRequestHeader(rb, "Access-Control-Request-Headers", accessControlRequestHeaders);
|
||||
}
|
||||
if (BODY_METHODS.contains(method)) {
|
||||
// CloudFront removes Authorization from GET and HEAD requests but forwards it on
|
||||
// POST, PUT, PATCH and DELETE, so the origin can authenticate the write.
|
||||
addRequestHeader(rb, "Authorization", viewerAuthorization);
|
||||
}
|
||||
byte[] originBody = originRequestBody(method, viewerBody);
|
||||
if (originBody != null) {
|
||||
// CloudFront forwards the viewer's Content-Type with the body; the transport derives
|
||||
// Content-Length from the body itself.
|
||||
addRequestHeader(rb, "Content-Type", viewerContentType);
|
||||
}
|
||||
HttpResponse<byte[]> resp = httpClient.send(
|
||||
rb.build(), originHeaders, originBody, HttpResponse.BodyHandlers.ofByteArray());
|
||||
// The transport derives Content-Length from the body itself.
|
||||
HttpResponse<byte[]> resp = httpClient.send(rb.build(), originRequest.headers(),
|
||||
originHeaders, originRequestBody(method, viewerBody),
|
||||
HttpResponse.BodyHandlers.ofByteArray());
|
||||
String ct = resp.headers().firstValue("content-type").orElse(DEFAULT_CONTENT_TYPE);
|
||||
byte[] body = resp.body() != null ? resp.body() : new byte[0];
|
||||
long contentLength = includeBody ? body.length : responseContentLength(resp);
|
||||
@@ -804,8 +865,8 @@ public class CloudFrontServingController {
|
||||
} else {
|
||||
String forwardUri = CloudFrontRequestRouter.resolveForwardUri(errOrigin.getOriginPath(), errNormalized, null);
|
||||
page = fetchFromCustomOrigin(
|
||||
errOrigin, forwardUri, null, viewerScheme,
|
||||
includeBody ? "GET" : "HEAD", null, null, null, null, null, null);
|
||||
errOrigin, forwardUri, CloudFrontOriginRequestBuilder.OriginRequest.empty(),
|
||||
viewerScheme, includeBody ? "GET" : "HEAD", null);
|
||||
}
|
||||
if (page.status() >= 400) {
|
||||
// Custom error page unavailable → return the status received from the error-page origin
|
||||
@@ -1103,12 +1164,6 @@ public class CloudFrontServingController {
|
||||
return values;
|
||||
}
|
||||
|
||||
private static void addRequestHeader(HttpRequest.Builder request, String name, String value) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
request.header(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static String str(Object value) {
|
||||
return value != null ? value.toString() : "";
|
||||
}
|
||||
|
||||
+217
-1
@@ -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"));
|
||||
|
||||
+36
@@ -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/">
|
||||
|
||||
+1
-1
@@ -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());
|
||||
|
||||
+413
@@ -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();
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -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));
|
||||
|
||||
|
||||
+34
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user