feat(cloudfront): serve POST, PUT, PATCH and DELETE viewer requests (#4229) #4
@@ -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