diff --git a/docs/services/cloudfront.md b/docs/services/cloudfront.md index 1dd8c19..a46ec5d 100644 --- a/docs/services/cloudfront.md +++ b/docs/services/cloudfront.md @@ -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. diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontOriginHttpClient.java b/src/main/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontOriginHttpClient.java index 993d218..3c6b8ba 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontOriginHttpClient.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontOriginHttpClient.java @@ -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 send(HttpRequest request, Map originHeaders, HttpResponse.BodyHandler 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 send(HttpRequest request, Map originHeaders, + byte[] requestBody, HttpResponse.BodyHandler 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); diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontServingController.java b/src/main/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontServingController.java index 3b55460..4c8c8e8 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontServingController.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontServingController.java @@ -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; * * *

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 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 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 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 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 diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontCustomOriginServingTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontCustomOriginServingTest.java index 97dd780..2880804 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontCustomOriginServingTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontCustomOriginServingTest.java @@ -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 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 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 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 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 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 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 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 receivedQuery, AtomicReference receivedPath, AtomicReference receivedCustomHeader, diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontDistributionServingTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontDistributionServingTest.java index 2b59ef2..43b0f94 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontDistributionServingTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontDistributionServingTest.java @@ -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(); diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontOriginHttpClientTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontOriginHttpClientTest.java index 9412c7f..22546c3 100644 --- a/src/test/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontOriginHttpClientTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/cloudfront/CloudFrontOriginHttpClientTest.java @@ -173,6 +173,46 @@ class CloudFrontOriginHttpClientTest { } } + @Test + void sendsTheBodyWithItsExactLengthAndTheCallerContentType() throws Exception { + AtomicReference method = new AtomicReference<>(); + AtomicReference contentType = new AtomicReference<>(); + AtomicReference contentLength = new AtomicReference<>(); + AtomicReference 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 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) {