diff --git a/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java b/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java index e913e85f..b9a9bc20 100644 --- a/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java +++ b/src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java @@ -1590,6 +1590,16 @@ private Instant parseHttpDate(String dateStr) { } private Response handlePresignedPost(String bucket, String contentType, byte[] body) { + try { + return doHandlePresignedPost(bucket, contentType, body); + } catch (AwsException e) { + // Presigned POST errors must be returned as XML (matching LocalStack/AWS), + // not JSON which is what the global AwsExceptionMapper would produce. + return xmlErrorResponse(e); + } + } + + private Response doHandlePresignedPost(String bucket, String contentType, byte[] body) { String boundary = extractBoundary(contentType); if (boundary == null) { throw new AwsException("InvalidArgument", diff --git a/src/test/java/io/github/hectorvent/floci/services/s3/S3PresignedPostIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/services/s3/S3PresignedPostIntegrationTest.java index fc0e733f..739ae1d6 100644 --- a/src/test/java/io/github/hectorvent/floci/services/s3/S3PresignedPostIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/s3/S3PresignedPostIntegrationTest.java @@ -13,6 +13,7 @@ import java.util.Base64; import static io.restassured.RestAssured.given; +import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; @QuarkusTest @@ -376,6 +377,47 @@ void presignedPostRejectsStartsWithMismatch() { + "[\"starts-with\", \"$key\", \"uploads/\"]"))); } + @Test + @Order(94) + void presignedPostReturnsXmlErrorResponseBody() { + // Verify the raw XML wire format matches what AWS S3 and LocalStack return. + // This ensures clients that parse the raw response body (e.g. seadn) see the + // expected XML structure with "-encoded quotes, not JSON. + String key = "uploads/xml-error-check.png"; + String fileContent = "not a real png"; + + String policy = buildPolicy(BUCKET, key, "image/png", 0, 10485760); + String policyBase64 = Base64.getEncoder().encodeToString(policy.getBytes(StandardCharsets.UTF_8)); + + String responseBody = + given() + .multiPart("key", key) + .multiPart("Content-Type", "image/gif") + .multiPart("policy", policyBase64) + .multiPart("x-amz-algorithm", "AWS4-HMAC-SHA256") + .multiPart("x-amz-credential", "AKIAIOSFODNN7EXAMPLE/20260330/us-east-1/s3/aws4_request") + .multiPart("x-amz-date", AMZ_DATE_FORMAT.format(Instant.now())) + .multiPart("x-amz-signature", "dummysignature") + .multiPart("file", "xml-error-check.png", fileContent.getBytes(StandardCharsets.UTF_8), "image/gif") + .when() + .post("/" + BUCKET) + .then() + .statusCode(403) + .contentType("application/xml") + .extract().body().asString(); + + // Assert the exact XML structure, matching what AWS S3 and LocalStack return. + // The RequestId is a random UUID, so we match it with a regex. + assertThat(responseBody, matchesRegex( + "\\Q\\E" + + "\\Q\\E" + + "\\QAccessDenied\\E" + + "\\QInvalid according to Policy: Policy Condition failed: \\E" + + "\\Q["eq", "$Content-Type", "image/png"]\\E" + + "[0-9a-f\\-]+" + + "\\Q\\E")); + } + @Test @Order(95) void presignedPostEnforcesPolicyWithCapitalPFieldName() {