feat(cloudfront): serve POST, PUT, PATCH and DELETE viewer requests (#4229) #4
@@ -3,7 +3,8 @@
|
||||
CloudFront management-plane and local content-delivery emulation. Supports distribution lifecycle,
|
||||
cache policies, origin request policies, response headers policies, origin access controls, origin
|
||||
access identities, public keys, trusted key groups, CloudFront Functions, invalidations, tagging, and
|
||||
GET/HEAD/OPTIONS delivery from S3 or custom origins.
|
||||
viewer delivery from S3 or custom origins for every CloudFront viewer method (GET, HEAD, OPTIONS, POST,
|
||||
PUT, PATCH and DELETE).
|
||||
|
||||
**Protocol:** REST XML
|
||||
**API version:** `2020-05-31`
|
||||
@@ -150,9 +151,17 @@ GET/HEAD/OPTIONS delivery from S3 or custom origins.
|
||||
- CNAME aliases are globally unique. `AssociateAlias` atomically transfers an alias from its current
|
||||
owner to the target distribution. Exact aliases take precedence over the most-specific matching
|
||||
wildcard alias.
|
||||
- Viewer GET/HEAD requests, and OPTIONS requests allowed by the matched cache behavior, addressed to
|
||||
an enabled distribution's generated domain or alias are routed to the matching S3 or custom
|
||||
origin. Origin forwarding preserves the raw path; custom-origin redirects are not followed.
|
||||
- Viewer requests addressed to an enabled distribution's generated domain or alias are routed to the
|
||||
matching S3 or custom origin when the matched cache behavior's `AllowedMethods` includes the
|
||||
method; any other method returns 403 `Invalid method.`. A behavior without `AllowedMethods` allows
|
||||
GET and HEAD. Origin forwarding preserves the raw path; custom-origin redirects are not followed.
|
||||
- POST, PUT, PATCH and DELETE are forwarded to custom origins with the viewer request body and its
|
||||
`Content-Type`; `Content-Length` is set from the body. Their responses are never cached, as on AWS.
|
||||
Signed URL and signed cookie enforcement applies to them like any other method. Request bodies are
|
||||
bounded by the emulator-wide `floci.protocols.max-request-size` limit.
|
||||
- The viewer's `Authorization` header is forwarded to custom origins on POST, PUT, PATCH and DELETE
|
||||
and removed from GET and HEAD requests, as on AWS. Floci also removes it from OPTIONS requests;
|
||||
AWS removes it there only when OPTIONS is a cached method.
|
||||
- Every distribution is also served as `{id}.cloudfront.{host}` for each endpoint host Floci
|
||||
resolves: `localhost`, `localhost.floci.io`, `localhost.localstack.cloud`, `FLOCI_HOSTNAME` and
|
||||
every `FLOCI_DNS_EXTRA_SUFFIXES` entry. `{id}.cloudfront.localhost.floci.io` and
|
||||
@@ -160,7 +169,7 @@ GET/HEAD/OPTIONS delivery from S3 or custom origins.
|
||||
HTTPS certificate, so a signed URL for either can be downloaded over `https://`. See
|
||||
[Downloading over HTTPS](#downloading-over-https).
|
||||
- Origin custom headers are persisted through the CloudFront API and CloudFormation. They replace
|
||||
same-named viewer headers on custom-origin GET/HEAD/OPTIONS requests. For in-process S3 origins, a
|
||||
same-named viewer headers on every custom-origin request. For in-process S3 origins, a
|
||||
configured `Origin` header is used for S3 CORS evaluation. AWS-prohibited names, malformed
|
||||
values, inconsistent quantities, duplicates, and quota violations are rejected with modeled
|
||||
CloudFront errors when the distribution is created or updated.
|
||||
@@ -339,3 +348,6 @@ as it is.
|
||||
- CloudFormation provisioning of custom `AWS::CloudFront::ResponseHeadersPolicy` resources
|
||||
(literal custom or managed policy IDs are supported on distributions)
|
||||
- Persistent edge caching and global CDN propagation
|
||||
- Forwarding POST, PUT, PATCH and DELETE to S3 origins. AWS passes them to S3, which evaluates them
|
||||
against the bucket policy (OAC supports `PUT` and `DELETE`); Floci's in-process S3 origin answers
|
||||
them with 403 `Access Denied`, as S3 does for a request that holds no write grant.
|
||||
|
||||
+17
-1
@@ -16,6 +16,7 @@ import org.apache.hc.client5.http.protocol.HttpClientContext;
|
||||
import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder;
|
||||
import org.apache.hc.core5.http.Header;
|
||||
import org.apache.hc.core5.http.HttpVersion;
|
||||
import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
|
||||
import org.apache.hc.core5.http.io.entity.EntityUtils;
|
||||
import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
|
||||
import org.apache.hc.core5.http2.HttpVersionPolicy;
|
||||
@@ -119,11 +120,22 @@ final class CloudFrontOriginHttpClient implements AutoCloseable {
|
||||
HttpResponse<byte[]> send(HttpRequest request, Map<String, String> originHeaders,
|
||||
HttpResponse.BodyHandler<byte[]> bodyHandler)
|
||||
throws IOException, InterruptedException {
|
||||
return send(request, originHeaders, null, bodyHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends {@code request} with {@code requestBody} as its entity, or with no entity when it is
|
||||
* {@code null}. The request's own body publisher must be empty: the body travels as bytes so its
|
||||
* {@code Content-Length} is exact.
|
||||
*/
|
||||
HttpResponse<byte[]> send(HttpRequest request, Map<String, String> originHeaders,
|
||||
byte[] requestBody, HttpResponse.BodyHandler<byte[]> bodyHandler)
|
||||
throws IOException, InterruptedException {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
throw new InterruptedException("CloudFront origin request interrupted");
|
||||
}
|
||||
if (request.bodyPublisher().filter(publisher -> publisher.contentLength() != 0).isPresent()) {
|
||||
throw new IOException("CloudFront origin request bodies are not supported");
|
||||
throw new IOException("CloudFront origin request bodies must be passed as bytes");
|
||||
}
|
||||
|
||||
ClassicRequestBuilder builder = ClassicRequestBuilder.create(request.method())
|
||||
@@ -132,6 +144,10 @@ final class CloudFrontOriginHttpClient implements AutoCloseable {
|
||||
request.headers().map().forEach((name, values) ->
|
||||
values.forEach(value -> builder.addHeader(name, value)));
|
||||
originHeaders.forEach(builder::setHeader);
|
||||
if (requestBody != null) {
|
||||
// No entity content type: the caller's Content-Type request header is sent as is.
|
||||
builder.setEntity(new ByteArrayEntity(requestBody, null));
|
||||
}
|
||||
|
||||
HttpClientContext context = HttpClientContext.create();
|
||||
Duration responseTimeout = request.timeout().orElse(RESPONSE_TIMEOUT);
|
||||
|
||||
+108
-11
@@ -10,11 +10,17 @@ import io.github.hectorvent.floci.services.cloudfront.model.OriginAccessControl;
|
||||
import io.github.hectorvent.floci.services.s3.S3Service;
|
||||
import io.github.hectorvent.floci.services.s3.model.S3Object;
|
||||
import io.quarkus.vertx.http.runtime.CurrentVertxRequest;
|
||||
import io.vertx.core.http.HttpServerRequest;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DELETE;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.HEAD;
|
||||
import jakarta.ws.rs.OPTIONS;
|
||||
import jakarta.ws.rs.PATCH;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
@@ -60,7 +66,10 @@ import java.util.Set;
|
||||
* </ul>
|
||||
*
|
||||
* <p>Viewer-protocol-policy enforcement is intentionally out of scope for this layer: the emulator
|
||||
* is HTTP-first. GET/HEAD are served, and OPTIONS is served only when the matched behavior allows it.
|
||||
* is HTTP-first. Every CloudFront viewer method (GET, HEAD, OPTIONS, POST, PUT, PATCH, DELETE) is
|
||||
* served when the matched behavior's {@code AllowedMethods} includes it. POST, PUT, PATCH and DELETE
|
||||
* are forwarded to custom origins with the viewer request body and {@code Authorization} header; they
|
||||
* are not forwarded to in-process S3 origins.
|
||||
*/
|
||||
@Path("/_cloudfront/{distId}")
|
||||
public class CloudFrontServingController {
|
||||
@@ -80,6 +89,8 @@ public class CloudFrontServingController {
|
||||
|
||||
private static final java.util.Set<String> CLOUDFRONT_SIGNING_PARAMS =
|
||||
java.util.Set.of("Expires", "Signature", "Key-Pair-Id", "Policy", "Hash-Algorithm");
|
||||
/** Viewer methods that can carry a request body and that CloudFront never caches. */
|
||||
private static final Set<String> BODY_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");
|
||||
|
||||
private final CloudFrontService service;
|
||||
private final S3Service s3Service;
|
||||
@@ -117,7 +128,7 @@ public class CloudFrontServingController {
|
||||
RequestHost.of(request),
|
||||
headers.getHeaderString(HttpHeaders.AUTHORIZATION),
|
||||
request.getHeader("Origin"), "GET", null, null,
|
||||
request.getHeader("Pragma"));
|
||||
request.getHeader("Pragma"), null, null);
|
||||
}
|
||||
|
||||
@HEAD
|
||||
@@ -131,7 +142,7 @@ public class CloudFrontServingController {
|
||||
RequestHost.of(request),
|
||||
headers.getHeaderString(HttpHeaders.AUTHORIZATION),
|
||||
request.getHeader("Origin"), "HEAD", null, null,
|
||||
request.getHeader("Pragma"));
|
||||
request.getHeader("Pragma"), null, null);
|
||||
}
|
||||
|
||||
@OPTIONS
|
||||
@@ -147,7 +158,53 @@ public class CloudFrontServingController {
|
||||
request.getHeader("Origin"), "OPTIONS",
|
||||
request.getHeader("Access-Control-Request-Method"),
|
||||
request.getHeader("Access-Control-Request-Headers"),
|
||||
request.getHeader("Pragma"));
|
||||
request.getHeader("Pragma"), null, null);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{proxy:.*}")
|
||||
@Consumes(MediaType.WILDCARD)
|
||||
public Response post(@PathParam("distId") String distId, @PathParam("proxy") String proxy,
|
||||
@Context HttpHeaders headers, @Context UriInfo uriInfo, byte[] body) {
|
||||
return serveWithBody(distId, "POST", headers, uriInfo, body);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("/{proxy:.*}")
|
||||
@Consumes(MediaType.WILDCARD)
|
||||
public Response put(@PathParam("distId") String distId, @PathParam("proxy") String proxy,
|
||||
@Context HttpHeaders headers, @Context UriInfo uriInfo, byte[] body) {
|
||||
return serveWithBody(distId, "PUT", headers, uriInfo, body);
|
||||
}
|
||||
|
||||
@PATCH
|
||||
@Path("/{proxy:.*}")
|
||||
@Consumes(MediaType.WILDCARD)
|
||||
public Response patch(@PathParam("distId") String distId, @PathParam("proxy") String proxy,
|
||||
@Context HttpHeaders headers, @Context UriInfo uriInfo, byte[] body) {
|
||||
return serveWithBody(distId, "PATCH", headers, uriInfo, body);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/{proxy:.*}")
|
||||
@Consumes(MediaType.WILDCARD)
|
||||
public Response delete(@PathParam("distId") String distId, @PathParam("proxy") String proxy,
|
||||
@Context HttpHeaders headers, @Context UriInfo uriInfo, byte[] body) {
|
||||
return serveWithBody(distId, "DELETE", headers, uriInfo, body);
|
||||
}
|
||||
|
||||
private Response serveWithBody(String distId, String method, HttpHeaders headers,
|
||||
UriInfo uriInfo, byte[] body) {
|
||||
HttpServerRequest request = currentVertxRequest.getCurrent().request();
|
||||
String rawViewerPath = rawViewerPath(request.uri());
|
||||
return serve(distId, rawViewerPath, decodedViewerPath(rawViewerPath),
|
||||
uriInfo.getRequestUri().getScheme(),
|
||||
RequestHost.of(request),
|
||||
headers.getHeaderString(HttpHeaders.AUTHORIZATION),
|
||||
request.getHeader("Origin"), method, null, null,
|
||||
request.getHeader("Pragma"),
|
||||
body != null ? body : new byte[0],
|
||||
request.getHeader(HttpHeaders.CONTENT_TYPE));
|
||||
}
|
||||
|
||||
private Response serve(String distId, String rawViewerPath, String decodedViewerPath,
|
||||
@@ -156,7 +213,9 @@ public class CloudFrontServingController {
|
||||
String viewerOrigin, String method,
|
||||
String accessControlRequestMethod,
|
||||
String accessControlRequestHeaders,
|
||||
String pragma) {
|
||||
String pragma,
|
||||
byte[] viewerBody,
|
||||
String viewerContentType) {
|
||||
boolean includeBody = !"HEAD".equals(method);
|
||||
boolean preflightRequest = "OPTIONS".equals(method)
|
||||
&& viewerOrigin != null && !viewerOrigin.isBlank()
|
||||
@@ -199,7 +258,8 @@ public class CloudFrontServingController {
|
||||
|
||||
OriginResponse origin = route(dist, normalized, rawViewerPath, decodedViewerPath,
|
||||
viewerScheme, viewerAuthorization, method, viewerOrigin,
|
||||
accessControlRequestMethod, accessControlRequestHeaders);
|
||||
accessControlRequestMethod, accessControlRequestHeaders,
|
||||
viewerBody, viewerContentType);
|
||||
|
||||
if (origin.status() >= 400) {
|
||||
Response fallback = applyCustomError(
|
||||
@@ -345,7 +405,8 @@ public class CloudFrontServingController {
|
||||
String viewerScheme, String viewerAuthorization,
|
||||
String method, String viewerOrigin,
|
||||
String accessControlRequestMethod,
|
||||
String accessControlRequestHeaders) {
|
||||
String accessControlRequestHeaders,
|
||||
byte[] viewerBody, String viewerContentType) {
|
||||
DistributionConfig config = distribution.getConfig();
|
||||
String originId = CloudFrontRequestRouter.matchTargetOriginId(config, normalized);
|
||||
Origin origin = CloudFrontRequestRouter.findOrigin(config, originId);
|
||||
@@ -357,6 +418,14 @@ public class CloudFrontServingController {
|
||||
return fetchS3Preflight(origin, viewerOrigin, accessControlRequestMethod,
|
||||
accessControlRequestHeaders);
|
||||
}
|
||||
if (BODY_METHODS.contains(method)) {
|
||||
// AWS forwards these methods and S3 evaluates them against the bucket policy (OAC
|
||||
// supports PUT and DELETE). The in-process origin authorizes reads only, so a write is
|
||||
// answered as S3 answers a request that holds no write grant.
|
||||
LOG.debugv("CloudFront does not forward {0} requests to in-process S3 origin {1}",
|
||||
method, origin.getId());
|
||||
return OriginResponse.error(403, "Access Denied");
|
||||
}
|
||||
String key = CloudFrontRequestRouter.resolveOriginKey(
|
||||
origin.getOriginPath(), decodedViewerPath, config.getDefaultRootObject());
|
||||
return fetchFromS3(
|
||||
@@ -368,7 +437,8 @@ public class CloudFrontServingController {
|
||||
// origin request policy, or legacy ForwardedValues configuration. Those policy
|
||||
// semantics are not modeled in the data plane yet, so the AWS default is to omit them.
|
||||
return fetchFromCustomOrigin(origin, forwardUri, null, viewerScheme, method,
|
||||
viewerOrigin, accessControlRequestMethod, accessControlRequestHeaders);
|
||||
viewerOrigin, accessControlRequestMethod, accessControlRequestHeaders,
|
||||
viewerAuthorization, viewerBody, viewerContentType);
|
||||
}
|
||||
|
||||
private OriginResponse fetchFromS3(
|
||||
@@ -494,7 +564,9 @@ public class CloudFrontServingController {
|
||||
private OriginResponse fetchFromCustomOrigin(Origin origin, String forwardUri, String rawQuery,
|
||||
String viewerScheme, String method, String viewerOrigin,
|
||||
String accessControlRequestMethod,
|
||||
String accessControlRequestHeaders) {
|
||||
String accessControlRequestHeaders,
|
||||
String viewerAuthorization,
|
||||
byte[] viewerBody, String viewerContentType) {
|
||||
boolean includeBody = !"HEAD".equals(method);
|
||||
try {
|
||||
URI target = buildCustomOriginUri(
|
||||
@@ -522,8 +594,19 @@ public class CloudFrontServingController {
|
||||
addRequestHeader(rb, "Access-Control-Request-Method", accessControlRequestMethod);
|
||||
addRequestHeader(rb, "Access-Control-Request-Headers", accessControlRequestHeaders);
|
||||
}
|
||||
if (BODY_METHODS.contains(method)) {
|
||||
// CloudFront removes Authorization from GET and HEAD requests but forwards it on
|
||||
// POST, PUT, PATCH and DELETE, so the origin can authenticate the write.
|
||||
addRequestHeader(rb, "Authorization", viewerAuthorization);
|
||||
}
|
||||
byte[] originBody = originRequestBody(method, viewerBody);
|
||||
if (originBody != null) {
|
||||
// CloudFront forwards the viewer's Content-Type with the body; the transport derives
|
||||
// Content-Length from the body itself.
|
||||
addRequestHeader(rb, "Content-Type", viewerContentType);
|
||||
}
|
||||
HttpResponse<byte[]> resp = httpClient.send(
|
||||
rb.build(), originHeaders, HttpResponse.BodyHandlers.ofByteArray());
|
||||
rb.build(), originHeaders, originBody, HttpResponse.BodyHandlers.ofByteArray());
|
||||
String ct = resp.headers().firstValue("content-type").orElse(DEFAULT_CONTENT_TYPE);
|
||||
byte[] body = resp.body() != null ? resp.body() : new byte[0];
|
||||
long contentLength = includeBody ? body.length : responseContentLength(resp);
|
||||
@@ -542,6 +625,20 @@ public class CloudFrontServingController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The body CloudFront sends to the origin: POST, PUT and PATCH always carry one (possibly empty, so
|
||||
* the origin still sees {@code Content-Length: 0}); DELETE carries one only when the viewer sent it.
|
||||
*/
|
||||
private static byte[] originRequestBody(String method, byte[] viewerBody) {
|
||||
if (viewerBody == null || !BODY_METHODS.contains(method)) {
|
||||
return null;
|
||||
}
|
||||
if ("DELETE".equals(method) && viewerBody.length == 0) {
|
||||
return null;
|
||||
}
|
||||
return viewerBody;
|
||||
}
|
||||
|
||||
private static String originAccessIdentityId(Origin origin) {
|
||||
Map<String, String> config = origin.getS3OriginConfig();
|
||||
String value = config != null ? config.get("OriginAccessIdentity") : null;
|
||||
@@ -708,7 +805,7 @@ public class CloudFrontServingController {
|
||||
String forwardUri = CloudFrontRequestRouter.resolveForwardUri(errOrigin.getOriginPath(), errNormalized, null);
|
||||
page = fetchFromCustomOrigin(
|
||||
errOrigin, forwardUri, null, viewerScheme,
|
||||
includeBody ? "GET" : "HEAD", null, null, null);
|
||||
includeBody ? "GET" : "HEAD", null, null, null, null, null, null);
|
||||
}
|
||||
if (page.status() >= 400) {
|
||||
// Custom error page unavailable → return the status received from the error-page origin
|
||||
|
||||
+321
@@ -1,11 +1,15 @@
|
||||
package io.github.hectorvent.floci.services.cloudfront;
|
||||
|
||||
import com.sun.net.httpserver.Headers;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.CacheBehavior;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.DefaultCacheBehavior;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.Distribution;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.DistributionConfig;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.KeyGroup;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.Origin;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.PublicKey;
|
||||
import io.github.hectorvent.floci.services.cloudfront.model.ResponseHeadersPolicy;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.quarkus.test.junit.QuarkusTestProfile;
|
||||
@@ -17,13 +21,21 @@ import org.junit.jupiter.api.Test;
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.Signature;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.Matchers.equalTo;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
@@ -220,6 +232,315 @@ class CloudFrontCustomOriginServingTest {
|
||||
removeOnlyResponse.getHeader("Date"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardsWriteMethodsWithTheirBodyToTheOrigin() throws Exception {
|
||||
AtomicReference<ReceivedRequest> received = new AtomicReference<>();
|
||||
AtomicInteger hits = new AtomicInteger();
|
||||
startRecordingOrigin(received, hits);
|
||||
Distribution created = cloudFrontService.createDistribution(
|
||||
customOriginDistribution(allMethodsBehavior()), Map.of());
|
||||
|
||||
String json = "{\"name\":\"widget\",\"note\":\"café\"}";
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.contentType("application/json; charset=UTF-8")
|
||||
.body(json.getBytes(StandardCharsets.UTF_8))
|
||||
.when()
|
||||
.post("/api/things")
|
||||
.then()
|
||||
.statusCode(201)
|
||||
.header("X-Origin-Method", equalTo("POST"))
|
||||
.body(equalTo("created"));
|
||||
ReceivedRequest post = received.get();
|
||||
assertEquals("POST", post.method());
|
||||
assertEquals("/api/things", post.path());
|
||||
assertEquals("application/json; charset=UTF-8", post.header("Content-Type"));
|
||||
assertEquals(Integer.toString(json.getBytes(StandardCharsets.UTF_8).length),
|
||||
post.header("Content-Length"));
|
||||
assertArrayEquals(json.getBytes(StandardCharsets.UTF_8), post.body());
|
||||
|
||||
byte[] binary = new byte[] {0, 1, 2, (byte) 0xff, (byte) 0xfe, 10, 13};
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.contentType("application/octet-stream")
|
||||
.body(binary)
|
||||
.when()
|
||||
.put("/api/things/1")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.header("X-Origin-Method", equalTo("PUT"));
|
||||
assertEquals("PUT", received.get().method());
|
||||
assertTrue(received.get().header("Content-Type").startsWith("application/octet-stream"));
|
||||
assertArrayEquals(binary, received.get().body());
|
||||
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.contentType("application/x-www-form-urlencoded")
|
||||
.body("name=widget&size=2")
|
||||
.when()
|
||||
.patch("/api/things/1")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.header("X-Origin-Method", equalTo("PATCH"));
|
||||
assertEquals("PATCH", received.get().method());
|
||||
assertTrue(received.get().header("Content-Type")
|
||||
.startsWith("application/x-www-form-urlencoded"));
|
||||
assertEquals("name=widget&size=2",
|
||||
new String(received.get().body(), StandardCharsets.UTF_8));
|
||||
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.when()
|
||||
.delete("/api/things/1")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.header("X-Origin-Method", equalTo("DELETE"));
|
||||
assertEquals("DELETE", received.get().method());
|
||||
assertEquals("/api/things/1", received.get().path());
|
||||
assertEquals(0, received.get().body().length);
|
||||
assertNull(received.get().header("Content-Type"));
|
||||
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.when()
|
||||
.post("/api/empty")
|
||||
.then()
|
||||
.statusCode(201);
|
||||
assertEquals("0", received.get().header("Content-Length"));
|
||||
assertEquals(5, hits.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardsTheViewerAuthorizationOnWriteMethodsOnly() throws Exception {
|
||||
AtomicReference<ReceivedRequest> received = new AtomicReference<>();
|
||||
AtomicInteger hits = new AtomicInteger();
|
||||
startRecordingOrigin(received, hits);
|
||||
Distribution created = cloudFrontService.createDistribution(
|
||||
customOriginDistribution(allMethodsBehavior()), Map.of());
|
||||
|
||||
for (String method : List.of("POST", "PUT", "PATCH", "DELETE")) {
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.header("Authorization", "Bearer viewer-token")
|
||||
.when()
|
||||
.request(method, "/api/things/1")
|
||||
.then()
|
||||
.header("X-Origin-Method", equalTo(method));
|
||||
assertEquals("Bearer viewer-token", received.get().header("Authorization"), method);
|
||||
}
|
||||
|
||||
// CloudFront removes Authorization from GET and HEAD requests.
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.header("Authorization", "Bearer viewer-token")
|
||||
.when()
|
||||
.get("/api/things/1")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.header("X-Origin-Method", equalTo("GET"));
|
||||
assertNull(received.get().header("Authorization"));
|
||||
assertEquals(5, hits.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void customErrorPageForAWriteIsFetchedWithoutTheViewerAuthorization() throws Exception {
|
||||
Map<String, String> authorizationByRequest = new ConcurrentHashMap<>();
|
||||
originServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
originServer.createContext("/", exchange -> {
|
||||
String request = exchange.getRequestMethod() + " "
|
||||
+ exchange.getRequestURI().getRawPath();
|
||||
authorizationByRequest.put(request,
|
||||
String.valueOf(exchange.getRequestHeaders().getFirst("Authorization")));
|
||||
boolean errorPage = "GET /errors/500.html".equals(request);
|
||||
byte[] body = (errorPage ? "error-page" : "failed").getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "text/plain");
|
||||
exchange.sendResponseHeaders(errorPage ? 200 : 500, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
});
|
||||
originServer.start();
|
||||
|
||||
Map<String, Object> errorPage = new LinkedHashMap<>(Map.of(
|
||||
"ErrorCode", "500", "ResponseCode", "503", "ResponsePagePath", "/errors/500.html"));
|
||||
Distribution distribution = customOriginDistribution(allMethodsBehavior());
|
||||
distribution.getConfig().setCustomErrorResponses(List.of(errorPage));
|
||||
Distribution created = cloudFrontService.createDistribution(distribution, Map.of());
|
||||
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.header("Authorization", "Bearer viewer-token")
|
||||
.contentType("application/json")
|
||||
.body("{}")
|
||||
.when()
|
||||
.post("/api/things")
|
||||
.then()
|
||||
.statusCode(503)
|
||||
.body(equalTo("error-page"));
|
||||
assertEquals(Map.of(
|
||||
"POST /api/things", "Bearer viewer-token",
|
||||
"GET /errors/500.html", "null"), authorizationByRequest);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMethodsTheMatchedBehaviorDoesNotAllowWithoutContactingTheOrigin() throws Exception {
|
||||
AtomicReference<ReceivedRequest> received = new AtomicReference<>();
|
||||
AtomicInteger hits = new AtomicInteger();
|
||||
startRecordingOrigin(received, hits);
|
||||
|
||||
CacheBehavior readOnly = new CacheBehavior();
|
||||
readOnly.setPathPattern("/static/*");
|
||||
readOnly.setTargetOriginId("custom-origin");
|
||||
readOnly.setViewerProtocolPolicy("allow-all");
|
||||
readOnly.setAllowedMethods(List.of("GET", "HEAD"));
|
||||
DistributionConfig config = customOriginDistribution(allMethodsBehavior()).getConfig();
|
||||
config.setCacheBehaviors(List.of(readOnly));
|
||||
Distribution distribution = new Distribution();
|
||||
distribution.setConfig(config);
|
||||
Distribution created = cloudFrontService.createDistribution(distribution, Map.of());
|
||||
|
||||
for (String method : List.of("POST", "PUT", "PATCH", "DELETE")) {
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.body("ignored")
|
||||
.when()
|
||||
.request(method, "/static/app.js")
|
||||
.then()
|
||||
.statusCode(403)
|
||||
.body(equalTo("Invalid method."));
|
||||
}
|
||||
assertEquals(0, hits.get());
|
||||
|
||||
// A behavior without AllowedMethods keeps CloudFront's GET/HEAD minimum.
|
||||
Distribution defaultsOnly = cloudFrontService.createDistribution(
|
||||
customOriginDistribution(defaultBehavior("custom-origin")), Map.of());
|
||||
given()
|
||||
.header("Host", defaultsOnly.getDomainName())
|
||||
.body("ignored")
|
||||
.when()
|
||||
.post("/api/things")
|
||||
.then()
|
||||
.statusCode(403)
|
||||
.body(equalTo("Invalid method."));
|
||||
assertEquals(0, hits.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void signedBehaviorsRequireASignatureForWriteMethods() throws Exception {
|
||||
AtomicReference<ReceivedRequest> received = new AtomicReference<>();
|
||||
AtomicInteger hits = new AtomicInteger();
|
||||
startRecordingOrigin(received, hits);
|
||||
|
||||
String suffix = Long.toString(System.nanoTime(), 36);
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
KeyPair keyPair = generator.generateKeyPair();
|
||||
PublicKey publicKey = new PublicKey();
|
||||
publicKey.setName("pk-" + suffix);
|
||||
publicKey.setCallerReference("cr-" + suffix);
|
||||
publicKey.setEncodedKey("-----BEGIN PUBLIC KEY-----\n"
|
||||
+ Base64.getMimeEncoder().encodeToString(keyPair.getPublic().getEncoded())
|
||||
+ "\n-----END PUBLIC KEY-----");
|
||||
publicKey = cloudFrontService.createPublicKey(publicKey);
|
||||
KeyGroup keyGroup = new KeyGroup();
|
||||
keyGroup.setName("kg-" + suffix);
|
||||
keyGroup.setItems(List.of(publicKey.getId()));
|
||||
keyGroup = cloudFrontService.createKeyGroup(keyGroup);
|
||||
|
||||
DefaultCacheBehavior behavior = allMethodsBehavior();
|
||||
behavior.setTrustedKeyGroups(List.of(keyGroup.getId()));
|
||||
Distribution created = cloudFrontService.createDistribution(
|
||||
customOriginDistribution(behavior), Map.of());
|
||||
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.contentType("application/json")
|
||||
.body("{\"unsigned\":true}")
|
||||
.when()
|
||||
.post("/api/things")
|
||||
.then()
|
||||
.statusCode(403);
|
||||
assertEquals(0, hits.get());
|
||||
|
||||
long expires = Instant.now().getEpochSecond() + 3600;
|
||||
String policy = "{\"Statement\":[{\"Resource\":\"*\",\"Condition\":{\"DateLessThan\":"
|
||||
+ "{\"AWS:EpochTime\":" + expires + "}}}]}";
|
||||
Signature signer = Signature.getInstance("SHA1withRSA");
|
||||
signer.initSign(keyPair.getPrivate());
|
||||
signer.update(policy.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
given()
|
||||
.header("Host", created.getDomainName())
|
||||
.queryParam("Policy", cfBase64(policy.getBytes(StandardCharsets.UTF_8)))
|
||||
.queryParam("Signature", cfBase64(signer.sign()))
|
||||
.queryParam("Key-Pair-Id", publicKey.getId())
|
||||
.contentType("application/json")
|
||||
.body("{\"signed\":true}")
|
||||
.when()
|
||||
.post("/api/things")
|
||||
.then()
|
||||
.statusCode(201);
|
||||
assertEquals(1, hits.get());
|
||||
assertEquals("{\"signed\":true}",
|
||||
new String(received.get().body(), StandardCharsets.UTF_8));
|
||||
assertNull(received.get().query());
|
||||
}
|
||||
|
||||
private void startRecordingOrigin(AtomicReference<ReceivedRequest> received, AtomicInteger hits)
|
||||
throws IOException {
|
||||
originServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
originServer.createContext("/", exchange -> {
|
||||
hits.incrementAndGet();
|
||||
received.set(new ReceivedRequest(
|
||||
exchange.getRequestMethod(),
|
||||
exchange.getRequestURI().getRawPath(),
|
||||
exchange.getRequestURI().getRawQuery(),
|
||||
exchange.getRequestHeaders(),
|
||||
exchange.getRequestBody().readAllBytes()));
|
||||
byte[] body = ("POST".equals(exchange.getRequestMethod()) ? "created" : "ok")
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "text/plain");
|
||||
exchange.getResponseHeaders().add("X-Origin-Method", exchange.getRequestMethod());
|
||||
exchange.sendResponseHeaders(
|
||||
"POST".equals(exchange.getRequestMethod()) ? 201 : 200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
});
|
||||
originServer.start();
|
||||
}
|
||||
|
||||
private Distribution customOriginDistribution(DefaultCacheBehavior behavior) {
|
||||
Origin customOrigin = new Origin();
|
||||
customOrigin.setId("custom-origin");
|
||||
customOrigin.setDomainName("127.0.0.1");
|
||||
customOrigin.setCustomOriginConfig(customOriginConfig(originServer.getAddress().getPort()));
|
||||
DistributionConfig config = new DistributionConfig();
|
||||
config.setEnabled(true);
|
||||
config.setOrigins(List.of(customOrigin));
|
||||
config.setDefaultCacheBehavior(behavior);
|
||||
Distribution distribution = new Distribution();
|
||||
distribution.setConfig(config);
|
||||
return distribution;
|
||||
}
|
||||
|
||||
private static DefaultCacheBehavior allMethodsBehavior() {
|
||||
DefaultCacheBehavior behavior = defaultBehavior("custom-origin");
|
||||
behavior.setAllowedMethods(List.of("GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"));
|
||||
behavior.setCachedMethods(List.of("GET", "HEAD"));
|
||||
return behavior;
|
||||
}
|
||||
|
||||
private static String cfBase64(byte[] bytes) {
|
||||
return Base64.getEncoder().encodeToString(bytes)
|
||||
.replace('+', '-').replace('=', '_').replace('/', '~');
|
||||
}
|
||||
|
||||
private record ReceivedRequest(String method, String path, String query, Headers headers,
|
||||
byte[] body) {
|
||||
String header(String name) {
|
||||
return headers.getFirst(name);
|
||||
}
|
||||
}
|
||||
|
||||
private static void respond(HttpExchange exchange, AtomicReference<String> receivedQuery,
|
||||
AtomicReference<String> receivedPath,
|
||||
AtomicReference<String> receivedCustomHeader,
|
||||
|
||||
+28
@@ -1424,6 +1424,34 @@ class CloudFrontDistributionServingTest {
|
||||
.then().statusCode(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeMethodsAreNotForwardedToInProcessS3Origins() {
|
||||
String suffix = suffix();
|
||||
String bucket = "cf-writes-" + suffix;
|
||||
createBucket(bucket);
|
||||
putObject(bucket, "doc.txt", "ORIGINAL-" + suffix, "text/plain");
|
||||
|
||||
DefaultCacheBehavior dflt = defaultBehavior("only-origin");
|
||||
dflt.setAllowedMethods(List.of("GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"));
|
||||
DistributionConfig config = new DistributionConfig();
|
||||
config.setEnabled(true);
|
||||
config.setOrigins(List.of(s3Origin("only-origin", bucket)));
|
||||
config.setDefaultCacheBehavior(dflt);
|
||||
Distribution distribution = cloudFrontService.createDistribution(distribution(config), Map.of());
|
||||
|
||||
for (String method : List.of("POST", "PUT", "PATCH", "DELETE")) {
|
||||
given()
|
||||
.header("Host", distribution.getDomainName())
|
||||
.body("REPLACED-" + suffix)
|
||||
.when().request(method, "/doc.txt")
|
||||
.then().statusCode(403)
|
||||
.body(equalTo("Access Denied"));
|
||||
}
|
||||
|
||||
given().header("Host", distribution.getDomainName()).when().get("/doc.txt")
|
||||
.then().statusCode(200).body(equalTo("ORIGINAL-" + suffix));
|
||||
}
|
||||
|
||||
@Test
|
||||
void managedPreflightPoliciesEmitOnlyAllowOriginForSimpleRequests() {
|
||||
String suffix = suffix();
|
||||
|
||||
+40
@@ -173,6 +173,46 @@ class CloudFrontOriginHttpClientTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsTheBodyWithItsExactLengthAndTheCallerContentType() throws Exception {
|
||||
AtomicReference<String> method = new AtomicReference<>();
|
||||
AtomicReference<String> contentType = new AtomicReference<>();
|
||||
AtomicReference<String> contentLength = new AtomicReference<>();
|
||||
AtomicReference<byte[]> body = new AtomicReference<>();
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/", exchange -> {
|
||||
method.set(exchange.getRequestMethod());
|
||||
contentType.set(exchange.getRequestHeaders().getFirst("Content-Type"));
|
||||
contentLength.set(exchange.getRequestHeaders().getFirst("Content-Length"));
|
||||
body.set(exchange.getRequestBody().readAllBytes());
|
||||
exchange.sendResponseHeaders(204, -1);
|
||||
exchange.close();
|
||||
});
|
||||
server.start();
|
||||
|
||||
byte[] payload = "{\"a\":1}".getBytes(StandardCharsets.UTF_8);
|
||||
try (CloudFrontOriginHttpClient client = new CloudFrontOriginHttpClient(
|
||||
resolver(InetAddress.getByName("127.0.0.1")), List.of("origin.invalid"))) {
|
||||
HttpRequest request = HttpRequest.newBuilder(java.net.URI.create(
|
||||
"http://origin.invalid:" + server.getAddress().getPort() + "/items"))
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.header("Content-Type", "application/json")
|
||||
.method("PUT", HttpRequest.BodyPublishers.noBody())
|
||||
.build();
|
||||
|
||||
HttpResponse<byte[]> response = client.send(
|
||||
request, Map.of(), payload, HttpResponse.BodyHandlers.ofByteArray());
|
||||
|
||||
assertEquals(204, response.statusCode());
|
||||
assertEquals("PUT", method.get());
|
||||
assertEquals("application/json", contentType.get());
|
||||
assertEquals(Integer.toString(payload.length), contentLength.get());
|
||||
assertArrayEquals(payload, body.get());
|
||||
} finally {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void httpsPinningPreservesSniAndHostnameVerification() throws Exception {
|
||||
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
|
||||
|
||||
Reference in New Issue
Block a user