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.
|
`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
|
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.
|
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
|
- 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
|
resolves: `localhost`, `localhost.floci.io`, `localhost.localstack.cloud`, `FLOCI_HOSTNAME` and
|
||||||
every `FLOCI_DNS_EXTRA_SUFFIXES` entry. `{id}.cloudfront.localhost.floci.io` 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
|
HTTPS certificate, so a signed URL for either can be downloaded over `https://`. See
|
||||||
[Downloading over HTTPS](#downloading-over-https).
|
[Downloading over HTTPS](#downloading-over-https).
|
||||||
- Origin custom headers are persisted through the CloudFront API and CloudFormation. They replace
|
- 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
|
configured `Origin` header is used for S3 CORS evaluation. AWS-prohibited names, malformed
|
||||||
values, inconsistent quantities, duplicates, and quota violations are rejected with modeled
|
values, inconsistent quantities, duplicates, and quota violations are rejected with modeled
|
||||||
CloudFront errors when the distribution is created or updated.
|
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
|
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
|
authentication is enabled. OAC `always`, `never`, and unsigned `no-override` requests follow their
|
||||||
documented signing behavior; signed `no-override` viewer requests retain their authorization.
|
documented signing behavior; signed `no-override` viewer requests retain their authorization.
|
||||||
- Cache-policy, origin-request-policy, and legacy `ForwardedValues` data-plane evaluation is not
|
- Requests to custom origins carry what the matched cache behavior forwards. With a cache policy,
|
||||||
implemented yet. Viewer query strings therefore follow CloudFront's default behavior and are not
|
the viewer headers, cookies and query strings selected by the cache policy's
|
||||||
forwarded to origins.
|
`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.
|
- 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 headers policies validate the AWS configuration shape and are applied after the origin
|
||||||
response, including CORS preflight fields, origin override behavior, custom headers, security
|
response, including CORS preflight fields, origin override behavior, custom headers, security
|
||||||
|
|||||||
+3
-2
@@ -468,7 +468,7 @@ public class CloudFrontController {
|
|||||||
@QueryParam("Type") String type) {
|
@QueryParam("Type") String type) {
|
||||||
try {
|
try {
|
||||||
Page<OriginRequestPolicy> page = page(
|
Page<OriginRequestPolicy> page = page(
|
||||||
service.listOriginRequestPolicies(marker, paginationFetchLimit(maxItems)),
|
service.listOriginRequestPolicies(marker, paginationFetchLimit(maxItems), type),
|
||||||
maxItems, OriginRequestPolicy::getId);
|
maxItems, OriginRequestPolicy::getId);
|
||||||
|
|
||||||
XmlBuilder xml = new XmlBuilder()
|
XmlBuilder xml = new XmlBuilder()
|
||||||
@@ -479,7 +479,8 @@ public class CloudFrontController {
|
|||||||
.start("Items");
|
.start("Items");
|
||||||
for (OriginRequestPolicy p : page.items()) {
|
for (OriginRequestPolicy p : page.items()) {
|
||||||
xml.start("OriginRequestPolicySummary")
|
xml.start("OriginRequestPolicySummary")
|
||||||
.elem("Type", "custom")
|
.elem("Type", CloudFrontService.isManagedOriginRequestPolicy(p.getId())
|
||||||
|
? "managed" : "custom")
|
||||||
.raw(xmlOriginRequestPolicyResponse(p))
|
.raw(xmlOriginRequestPolicyResponse(p))
|
||||||
.end("OriginRequestPolicySummary");
|
.end("OriginRequestPolicySummary");
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-9
@@ -41,6 +41,7 @@ import java.util.Map;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.TreeMap;
|
import java.util.TreeMap;
|
||||||
|
import java.util.TreeSet;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,16 +121,20 @@ final class CloudFrontOriginHttpClient implements AutoCloseable {
|
|||||||
HttpResponse<byte[]> send(HttpRequest request, Map<String, String> originHeaders,
|
HttpResponse<byte[]> send(HttpRequest request, Map<String, String> originHeaders,
|
||||||
HttpResponse.BodyHandler<byte[]> bodyHandler)
|
HttpResponse.BodyHandler<byte[]> bodyHandler)
|
||||||
throws IOException, InterruptedException {
|
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
|
* Sends {@code request} with {@code forwardedHeaders} added and {@code requestBody} as its
|
||||||
* {@code null}. The request's own body publisher must be empty: the body travels as bytes so its
|
* entity, or with no entity when it is {@code null}. Each origin header replaces every
|
||||||
* {@code Content-Length} is exact.
|
* 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,
|
HttpResponse<byte[]> send(HttpRequest request,
|
||||||
byte[] requestBody, HttpResponse.BodyHandler<byte[]> bodyHandler)
|
List<CloudFrontOriginRequestBuilder.Header> forwardedHeaders,
|
||||||
|
Map<String, String> originHeaders, byte[] requestBody,
|
||||||
|
HttpResponse.BodyHandler<byte[]> bodyHandler)
|
||||||
throws IOException, InterruptedException {
|
throws IOException, InterruptedException {
|
||||||
if (Thread.currentThread().isInterrupted()) {
|
if (Thread.currentThread().isInterrupted()) {
|
||||||
throw new InterruptedException("CloudFront origin request interrupted");
|
throw new InterruptedException("CloudFront origin request interrupted");
|
||||||
@@ -141,9 +146,19 @@ final class CloudFrontOriginHttpClient implements AutoCloseable {
|
|||||||
ClassicRequestBuilder builder = ClassicRequestBuilder.create(request.method())
|
ClassicRequestBuilder builder = ClassicRequestBuilder.create(request.method())
|
||||||
.setUri(request.uri())
|
.setUri(request.uri())
|
||||||
.setVersion(HttpVersion.HTTP_1_1);
|
.setVersion(HttpVersion.HTTP_1_1);
|
||||||
request.headers().map().forEach((name, values) ->
|
Set<String> replaced = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
|
||||||
values.forEach(value -> builder.addHeader(name, value)));
|
replaced.addAll(originHeaders.keySet());
|
||||||
originHeaders.forEach(builder::setHeader);
|
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) {
|
if (requestBody != null) {
|
||||||
// No entity content type: the caller's Content-Type request header is sent as is.
|
// No entity content type: the caller's Content-Type request header is sent as is.
|
||||||
builder.setEntity(new ByteArrayEntity(requestBody, null));
|
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);
|
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) {
|
private static List<String> allowedMethodsOrDefault(List<String> methods) {
|
||||||
return methods == null || methods.isEmpty() ? List.of("GET", "HEAD") : 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";
|
"60669652-455b-4ae9-85a4-c4c02393f86c";
|
||||||
private static final Map<String, ResponseHeadersPolicy> MANAGED_RESPONSE_HEADERS_POLICIES =
|
private static final Map<String, ResponseHeadersPolicy> MANAGED_RESPONSE_HEADERS_POLICIES =
|
||||||
managedResponseHeadersPolicies();
|
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, Distribution> distStore;
|
||||||
private final StorageBackend<String, List<Invalidation>> invalidationStore;
|
private final StorageBackend<String, List<Invalidation>> invalidationStore;
|
||||||
@@ -543,14 +561,19 @@ public class CloudFrontService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public OriginRequestPolicy getOriginRequestPolicy(String id) {
|
public OriginRequestPolicy getOriginRequestPolicy(String id) {
|
||||||
return orpStore.get(id).orElseThrow(() ->
|
return orpStore.get(id)
|
||||||
new AwsException("NoSuchOriginRequestPolicy",
|
.or(() -> Optional.ofNullable(MANAGED_ORIGIN_REQUEST_POLICIES.get(id)))
|
||||||
|
.orElseThrow(() -> new AwsException("NoSuchOriginRequestPolicy",
|
||||||
"The specified origin request policy does not exist.", 404));
|
"The specified origin request policy does not exist.", 404));
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized OriginRequestPolicy updateOriginRequestPolicy(String id, String ifMatch,
|
public synchronized OriginRequestPolicy updateOriginRequestPolicy(String id, String ifMatch,
|
||||||
OriginRequestPolicy updated) {
|
OriginRequestPolicy updated) {
|
||||||
OriginRequestPolicy existing = getOriginRequestPolicy(id);
|
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)) {
|
if (!existing.getEtag().equals(ifMatch)) {
|
||||||
throw new AwsException("InvalidIfMatchVersion",
|
throw new AwsException("InvalidIfMatchVersion",
|
||||||
"The If-Match version is missing or not valid for the resource.", 400);
|
"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) {
|
public synchronized void deleteOriginRequestPolicy(String id, String ifMatch) {
|
||||||
OriginRequestPolicy existing = getOriginRequestPolicy(id);
|
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)) {
|
if (!existing.getEtag().equals(ifMatch)) {
|
||||||
throw new AwsException("InvalidIfMatchVersion",
|
throw new AwsException("InvalidIfMatchVersion",
|
||||||
"The If-Match version is missing or not valid for the resource.", 400);
|
"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) {
|
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
|
all.sort((a, b) -> a.getName() != null && b.getName() != null
|
||||||
? a.getName().compareTo(b.getName()) : a.getId().compareTo(b.getId()));
|
? a.getName().compareTo(b.getName()) : a.getId().compareTo(b.getId()));
|
||||||
return paginate(all, marker, maxItems, OriginRequestPolicy::getId);
|
return paginate(all, marker, maxItems, OriginRequestPolicy::getId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static boolean isManagedOriginRequestPolicy(String id) {
|
||||||
|
return MANAGED_ORIGIN_REQUEST_POLICIES.containsKey(id);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Response Headers Policies ─────────────────────────────────────────────
|
// ── Response Headers Policies ─────────────────────────────────────────────
|
||||||
|
|
||||||
public synchronized ResponseHeadersPolicy createResponseHeadersPolicy(ResponseHeadersPolicy policy) {
|
public synchronized ResponseHeadersPolicy createResponseHeadersPolicy(ResponseHeadersPolicy policy) {
|
||||||
@@ -840,6 +889,112 @@ public class CloudFrontService {
|
|||||||
return Map.copyOf(policies);
|
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(
|
private static ResponseHeadersPolicy managedResponseHeadersPolicy(
|
||||||
String id, String name, String comment, Map<String, Object> config) {
|
String id, String name, String comment, Map<String, Object> config) {
|
||||||
ResponseHeadersPolicy policy = new ResponseHeadersPolicy();
|
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.github.hectorvent.floci.services.s3.model.S3Object;
|
||||||
import io.quarkus.vertx.http.runtime.CurrentVertxRequest;
|
import io.quarkus.vertx.http.runtime.CurrentVertxRequest;
|
||||||
import io.vertx.core.http.HttpServerRequest;
|
import io.vertx.core.http.HttpServerRequest;
|
||||||
|
import io.vertx.core.http.HttpVersion;
|
||||||
|
import io.vertx.core.net.SocketAddress;
|
||||||
import jakarta.annotation.PreDestroy;
|
import jakarta.annotation.PreDestroy;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
@@ -59,7 +61,9 @@ import java.util.Set;
|
|||||||
* <ul>
|
* <ul>
|
||||||
* <li>S3 origins are read in-process through {@link S3Service} (the bucket is derived from the
|
* <li>S3 origins are read in-process through {@link S3Service} (the bucket is derived from the
|
||||||
* origin domain name).</li>
|
* 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 —
|
* <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}.
|
* 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>
|
* 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),
|
RequestHost.of(request),
|
||||||
headers.getHeaderString(HttpHeaders.AUTHORIZATION),
|
headers.getHeaderString(HttpHeaders.AUTHORIZATION),
|
||||||
request.getHeader("Origin"), "GET", null, null,
|
request.getHeader("Origin"), "GET", null, null,
|
||||||
request.getHeader("Pragma"), null, null);
|
request.getHeader("Pragma"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HEAD
|
@HEAD
|
||||||
@@ -142,7 +146,7 @@ public class CloudFrontServingController {
|
|||||||
RequestHost.of(request),
|
RequestHost.of(request),
|
||||||
headers.getHeaderString(HttpHeaders.AUTHORIZATION),
|
headers.getHeaderString(HttpHeaders.AUTHORIZATION),
|
||||||
request.getHeader("Origin"), "HEAD", null, null,
|
request.getHeader("Origin"), "HEAD", null, null,
|
||||||
request.getHeader("Pragma"), null, null);
|
request.getHeader("Pragma"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OPTIONS
|
@OPTIONS
|
||||||
@@ -158,7 +162,7 @@ public class CloudFrontServingController {
|
|||||||
request.getHeader("Origin"), "OPTIONS",
|
request.getHeader("Origin"), "OPTIONS",
|
||||||
request.getHeader("Access-Control-Request-Method"),
|
request.getHeader("Access-Control-Request-Method"),
|
||||||
request.getHeader("Access-Control-Request-Headers"),
|
request.getHeader("Access-Control-Request-Headers"),
|
||||||
request.getHeader("Pragma"), null, null);
|
request.getHeader("Pragma"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@@ -203,8 +207,7 @@ public class CloudFrontServingController {
|
|||||||
headers.getHeaderString(HttpHeaders.AUTHORIZATION),
|
headers.getHeaderString(HttpHeaders.AUTHORIZATION),
|
||||||
request.getHeader("Origin"), method, null, null,
|
request.getHeader("Origin"), method, null, null,
|
||||||
request.getHeader("Pragma"),
|
request.getHeader("Pragma"),
|
||||||
body != null ? body : new byte[0],
|
body != null ? body : new byte[0]);
|
||||||
request.getHeader(HttpHeaders.CONTENT_TYPE));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Response serve(String distId, String rawViewerPath, String decodedViewerPath,
|
private Response serve(String distId, String rawViewerPath, String decodedViewerPath,
|
||||||
@@ -214,8 +217,7 @@ public class CloudFrontServingController {
|
|||||||
String accessControlRequestMethod,
|
String accessControlRequestMethod,
|
||||||
String accessControlRequestHeaders,
|
String accessControlRequestHeaders,
|
||||||
String pragma,
|
String pragma,
|
||||||
byte[] viewerBody,
|
byte[] viewerBody) {
|
||||||
String viewerContentType) {
|
|
||||||
boolean includeBody = !"HEAD".equals(method);
|
boolean includeBody = !"HEAD".equals(method);
|
||||||
boolean preflightRequest = "OPTIONS".equals(method)
|
boolean preflightRequest = "OPTIONS".equals(method)
|
||||||
&& viewerOrigin != null && !viewerOrigin.isBlank()
|
&& viewerOrigin != null && !viewerOrigin.isBlank()
|
||||||
@@ -258,8 +260,7 @@ public class CloudFrontServingController {
|
|||||||
|
|
||||||
OriginResponse origin = route(dist, normalized, rawViewerPath, decodedViewerPath,
|
OriginResponse origin = route(dist, normalized, rawViewerPath, decodedViewerPath,
|
||||||
viewerScheme, viewerAuthorization, method, viewerOrigin,
|
viewerScheme, viewerAuthorization, method, viewerOrigin,
|
||||||
accessControlRequestMethod, accessControlRequestHeaders,
|
accessControlRequestMethod, accessControlRequestHeaders, viewerBody);
|
||||||
viewerBody, viewerContentType);
|
|
||||||
|
|
||||||
if (origin.status() >= 400) {
|
if (origin.status() >= 400) {
|
||||||
Response fallback = applyCustomError(
|
Response fallback = applyCustomError(
|
||||||
@@ -406,7 +407,7 @@ public class CloudFrontServingController {
|
|||||||
String method, String viewerOrigin,
|
String method, String viewerOrigin,
|
||||||
String accessControlRequestMethod,
|
String accessControlRequestMethod,
|
||||||
String accessControlRequestHeaders,
|
String accessControlRequestHeaders,
|
||||||
byte[] viewerBody, String viewerContentType) {
|
byte[] viewerBody) {
|
||||||
DistributionConfig config = distribution.getConfig();
|
DistributionConfig config = distribution.getConfig();
|
||||||
String originId = CloudFrontRequestRouter.matchTargetOriginId(config, normalized);
|
String originId = CloudFrontRequestRouter.matchTargetOriginId(config, normalized);
|
||||||
Origin origin = CloudFrontRequestRouter.findOrigin(config, originId);
|
Origin origin = CloudFrontRequestRouter.findOrigin(config, originId);
|
||||||
@@ -433,12 +434,86 @@ public class CloudFrontServingController {
|
|||||||
}
|
}
|
||||||
String forwardUri = CloudFrontRequestRouter.resolveForwardUri(
|
String forwardUri = CloudFrontRequestRouter.resolveForwardUri(
|
||||||
origin.getOriginPath(), rawViewerPath, config.getDefaultRootObject());
|
origin.getOriginPath(), rawViewerPath, config.getDefaultRootObject());
|
||||||
// CloudFront forwards viewer query strings only when selected by a cache policy,
|
CloudFrontOriginRequestBuilder.OriginRequest originRequest =
|
||||||
// origin request policy, or legacy ForwardedValues configuration. Those policy
|
CloudFrontOriginRequestBuilder.build(viewerRequest(method, viewerScheme),
|
||||||
// semantics are not modeled in the data plane yet, so the AWS default is to omit them.
|
forwarding(config, normalized), distribution.getDomainName());
|
||||||
return fetchFromCustomOrigin(origin, forwardUri, null, viewerScheme, method,
|
return fetchFromCustomOrigin(
|
||||||
viewerOrigin, accessControlRequestMethod, accessControlRequestHeaders,
|
origin, forwardUri, originRequest, viewerScheme, method, viewerBody);
|
||||||
viewerAuthorization, viewerBody, viewerContentType);
|
}
|
||||||
|
|
||||||
|
/** 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(
|
private OriginResponse fetchFromS3(
|
||||||
@@ -560,17 +635,17 @@ public class CloudFrontServingController {
|
|||||||
"This CORS request is not allowed."));
|
"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,
|
* Fetches from a custom (non-S3) origin. {@code forwardUri} already includes the origin path;
|
||||||
String viewerScheme, String method, String viewerOrigin,
|
* {@code originRequest} carries the forwarded query and headers.
|
||||||
String accessControlRequestMethod,
|
*/
|
||||||
String accessControlRequestHeaders,
|
private OriginResponse fetchFromCustomOrigin(Origin origin, String forwardUri,
|
||||||
String viewerAuthorization,
|
CloudFrontOriginRequestBuilder.OriginRequest originRequest,
|
||||||
byte[] viewerBody, String viewerContentType) {
|
String viewerScheme, String method, byte[] viewerBody) {
|
||||||
boolean includeBody = !"HEAD".equals(method);
|
boolean includeBody = !"HEAD".equals(method);
|
||||||
try {
|
try {
|
||||||
URI target = buildCustomOriginUri(
|
URI target = buildCustomOriginUri(
|
||||||
origin, viewerScheme, forwardUri, rawQuery);
|
origin, viewerScheme, forwardUri, originRequest.rawQuery());
|
||||||
HttpRequest.Builder rb = HttpRequest.newBuilder()
|
HttpRequest.Builder rb = HttpRequest.newBuilder()
|
||||||
.uri(target)
|
.uri(target)
|
||||||
.timeout(Duration.ofSeconds(30));
|
.timeout(Duration.ofSeconds(30));
|
||||||
@@ -589,24 +664,10 @@ public class CloudFrontServingController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
rb.method(method, HttpRequest.BodyPublishers.noBody());
|
rb.method(method, HttpRequest.BodyPublishers.noBody());
|
||||||
if ("OPTIONS".equals(method)) {
|
// The transport derives Content-Length from the body itself.
|
||||||
addRequestHeader(rb, "Origin", viewerOrigin);
|
HttpResponse<byte[]> resp = httpClient.send(rb.build(), originRequest.headers(),
|
||||||
addRequestHeader(rb, "Access-Control-Request-Method", accessControlRequestMethod);
|
originHeaders, originRequestBody(method, viewerBody),
|
||||||
addRequestHeader(rb, "Access-Control-Request-Headers", accessControlRequestHeaders);
|
HttpResponse.BodyHandlers.ofByteArray());
|
||||||
}
|
|
||||||
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());
|
|
||||||
String ct = resp.headers().firstValue("content-type").orElse(DEFAULT_CONTENT_TYPE);
|
String ct = resp.headers().firstValue("content-type").orElse(DEFAULT_CONTENT_TYPE);
|
||||||
byte[] body = resp.body() != null ? resp.body() : new byte[0];
|
byte[] body = resp.body() != null ? resp.body() : new byte[0];
|
||||||
long contentLength = includeBody ? body.length : responseContentLength(resp);
|
long contentLength = includeBody ? body.length : responseContentLength(resp);
|
||||||
@@ -804,8 +865,8 @@ public class CloudFrontServingController {
|
|||||||
} else {
|
} else {
|
||||||
String forwardUri = CloudFrontRequestRouter.resolveForwardUri(errOrigin.getOriginPath(), errNormalized, null);
|
String forwardUri = CloudFrontRequestRouter.resolveForwardUri(errOrigin.getOriginPath(), errNormalized, null);
|
||||||
page = fetchFromCustomOrigin(
|
page = fetchFromCustomOrigin(
|
||||||
errOrigin, forwardUri, null, viewerScheme,
|
errOrigin, forwardUri, CloudFrontOriginRequestBuilder.OriginRequest.empty(),
|
||||||
includeBody ? "GET" : "HEAD", null, null, null, null, null, null);
|
viewerScheme, includeBody ? "GET" : "HEAD", null);
|
||||||
}
|
}
|
||||||
if (page.status() >= 400) {
|
if (page.status() >= 400) {
|
||||||
// Custom error page unavailable → return the status received from the error-page origin
|
// Custom error page unavailable → return the status received from the error-page origin
|
||||||
@@ -1103,12 +1164,6 @@ public class CloudFrontServingController {
|
|||||||
return values;
|
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) {
|
private static String str(Object value) {
|
||||||
return value != null ? value.toString() : "";
|
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.HttpExchange;
|
||||||
import com.sun.net.httpserver.HttpServer;
|
import com.sun.net.httpserver.HttpServer;
|
||||||
import io.github.hectorvent.floci.services.cloudfront.model.CacheBehavior;
|
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.DefaultCacheBehavior;
|
||||||
import io.github.hectorvent.floci.services.cloudfront.model.Distribution;
|
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.DistributionConfig;
|
||||||
import io.github.hectorvent.floci.services.cloudfront.model.KeyGroup;
|
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.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.PublicKey;
|
||||||
import io.github.hectorvent.floci.services.cloudfront.model.ResponseHeadersPolicy;
|
import io.github.hectorvent.floci.services.cloudfront.model.ResponseHeadersPolicy;
|
||||||
import io.quarkus.test.junit.QuarkusTest;
|
import io.quarkus.test.junit.QuarkusTest;
|
||||||
import io.quarkus.test.junit.QuarkusTestProfile;
|
import io.quarkus.test.junit.QuarkusTestProfile;
|
||||||
import io.quarkus.test.junit.TestProfile;
|
import io.quarkus.test.junit.TestProfile;
|
||||||
|
import io.restassured.response.Response;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -46,6 +49,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
@TestProfile(CloudFrontCustomOriginServingTest.PrivateOriginProfile.class)
|
@TestProfile(CloudFrontCustomOriginServingTest.PrivateOriginProfile.class)
|
||||||
class CloudFrontCustomOriginServingTest {
|
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
|
@Inject
|
||||||
CloudFrontService cloudFrontService;
|
CloudFrontService cloudFrontService;
|
||||||
|
|
||||||
@@ -448,6 +454,9 @@ class CloudFrontCustomOriginServingTest {
|
|||||||
|
|
||||||
DefaultCacheBehavior behavior = allMethodsBehavior();
|
DefaultCacheBehavior behavior = allMethodsBehavior();
|
||||||
behavior.setTrustedKeyGroups(List.of(keyGroup.getId()));
|
behavior.setTrustedKeyGroups(List.of(keyGroup.getId()));
|
||||||
|
behavior.setCachePolicyId(CACHING_DISABLED);
|
||||||
|
behavior.setOriginRequestPolicyId(
|
||||||
|
CloudFrontService.MANAGED_ALL_VIEWER_ORIGIN_REQUEST_POLICY_ID);
|
||||||
Distribution created = cloudFrontService.createDistribution(
|
Distribution created = cloudFrontService.createDistribution(
|
||||||
customOriginDistribution(behavior), Map.of());
|
customOriginDistribution(behavior), Map.of());
|
||||||
|
|
||||||
@@ -470,6 +479,7 @@ class CloudFrontCustomOriginServingTest {
|
|||||||
|
|
||||||
given()
|
given()
|
||||||
.header("Host", created.getDomainName())
|
.header("Host", created.getDomainName())
|
||||||
|
.queryParam("color", "blue")
|
||||||
.queryParam("Policy", cfBase64(policy.getBytes(StandardCharsets.UTF_8)))
|
.queryParam("Policy", cfBase64(policy.getBytes(StandardCharsets.UTF_8)))
|
||||||
.queryParam("Signature", cfBase64(signer.sign()))
|
.queryParam("Signature", cfBase64(signer.sign()))
|
||||||
.queryParam("Key-Pair-Id", publicKey.getId())
|
.queryParam("Key-Pair-Id", publicKey.getId())
|
||||||
@@ -482,7 +492,193 @@ class CloudFrontCustomOriginServingTest {
|
|||||||
assertEquals(1, hits.get());
|
assertEquals(1, hits.get());
|
||||||
assertEquals("{\"signed\":true}",
|
assertEquals("{\"signed\":true}",
|
||||||
new String(received.get().body(), StandardCharsets.UTF_8));
|
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)
|
private void startRecordingOrigin(AtomicReference<ReceivedRequest> received, AtomicInteger hits)
|
||||||
@@ -500,6 +696,7 @@ class CloudFrontCustomOriginServingTest {
|
|||||||
.getBytes(StandardCharsets.UTF_8);
|
.getBytes(StandardCharsets.UTF_8);
|
||||||
exchange.getResponseHeaders().add("Content-Type", "text/plain");
|
exchange.getResponseHeaders().add("Content-Type", "text/plain");
|
||||||
exchange.getResponseHeaders().add("X-Origin-Method", exchange.getRequestMethod());
|
exchange.getResponseHeaders().add("X-Origin-Method", exchange.getRequestMethod());
|
||||||
|
exchange.getResponseHeaders().add("Set-Cookie", "session=renewed; Path=/; HttpOnly");
|
||||||
exchange.sendResponseHeaders(
|
exchange.sendResponseHeaders(
|
||||||
"POST".equals(exchange.getRequestMethod()) ? 201 : 200, body.length);
|
"POST".equals(exchange.getRequestMethod()) ? 201 : 200, body.length);
|
||||||
exchange.getResponseBody().write(body);
|
exchange.getResponseBody().write(body);
|
||||||
@@ -509,10 +706,16 @@ class CloudFrontCustomOriginServingTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Distribution customOriginDistribution(DefaultCacheBehavior behavior) {
|
private Distribution customOriginDistribution(DefaultCacheBehavior behavior) {
|
||||||
|
return customOriginDistribution(behavior, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Distribution customOriginDistribution(DefaultCacheBehavior behavior,
|
||||||
|
List<Map<String, String>> customHeaders) {
|
||||||
Origin customOrigin = new Origin();
|
Origin customOrigin = new Origin();
|
||||||
customOrigin.setId("custom-origin");
|
customOrigin.setId("custom-origin");
|
||||||
customOrigin.setDomainName("127.0.0.1");
|
customOrigin.setDomainName("127.0.0.1");
|
||||||
customOrigin.setCustomOriginConfig(customOriginConfig(originServer.getAddress().getPort()));
|
customOrigin.setCustomOriginConfig(customOriginConfig(originServer.getAddress().getPort()));
|
||||||
|
customOrigin.setCustomHeaders(customHeaders);
|
||||||
DistributionConfig config = new DistributionConfig();
|
DistributionConfig config = new DistributionConfig();
|
||||||
config.setEnabled(true);
|
config.setEnabled(true);
|
||||||
config.setOrigins(List.of(customOrigin));
|
config.setOrigins(List.of(customOrigin));
|
||||||
@@ -522,6 +725,19 @@ class CloudFrontCustomOriginServingTest {
|
|||||||
return distribution;
|
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() {
|
private static DefaultCacheBehavior allMethodsBehavior() {
|
||||||
DefaultCacheBehavior behavior = defaultBehavior("custom-origin");
|
DefaultCacheBehavior behavior = defaultBehavior("custom-origin");
|
||||||
behavior.setAllowedMethods(List.of("GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"));
|
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) {
|
private static String cachePolicyBody(String name, String comment, String defaultTtl, String cookie) {
|
||||||
return """
|
return """
|
||||||
<CachePolicyConfig xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
|
<CachePolicyConfig xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
|
||||||
|
|||||||
+1
-1
@@ -201,7 +201,7 @@ class CloudFrontOriginHttpClientTest {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpResponse<byte[]> response = client.send(
|
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(204, response.statusCode());
|
||||||
assertEquals("PUT", method.get());
|
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 org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
@@ -121,6 +122,32 @@ class CloudFrontRequestRouterTest {
|
|||||||
assertNull(CloudFrontRequestRouter.matchResponseHeadersPolicyId(cfg, "/index.html"));
|
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
|
@Test
|
||||||
void matchAllowedMethodsUsesTheSelectedBehaviorAndDefaultsToGetHead() {
|
void matchAllowedMethodsUsesTheSelectedBehaviorAndDefaultsToGetHead() {
|
||||||
CacheBehavior api = behavior("api/*", "api-origin");
|
CacheBehavior api = behavior("api/*", "api-origin");
|
||||||
@@ -175,7 +202,7 @@ class CloudFrontRequestRouterTest {
|
|||||||
Origin s3 = new Origin();
|
Origin s3 = new Origin();
|
||||||
s3.setId("s3-origin");
|
s3.setId("s3-origin");
|
||||||
s3.setDomainName("my-bucket.s3.us-east-1.amazonaws.com");
|
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();
|
DistributionConfig cfg = new DistributionConfig();
|
||||||
cfg.setOrigins(List.of(s3));
|
cfg.setOrigins(List.of(s3));
|
||||||
|
|
||||||
|
|||||||
+34
@@ -982,6 +982,40 @@ class CloudFrontServiceTest {
|
|||||||
assertAws("NoSuchOriginRequestPolicy", () -> service.getOriginRequestPolicy(policy.getId()));
|
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
|
@Test
|
||||||
void deletesAnUnattachedCachePolicy() {
|
void deletesAnUnattachedCachePolicy() {
|
||||||
CloudFrontService service = serviceWithDomainSuffix("cloudfront.net");
|
CloudFrontService service = serviceWithDomainSuffix("cloudfront.net");
|
||||||
|
|||||||
Reference in New Issue
Block a user