From 5b73ba92a572548722196bbbe51ba4ba7579184e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:50:58 +0000 Subject: [PATCH 1/4] fix(s3): check both 'Policy' and 'policy' field names in presigned POST The AWS SDK sends the presigned POST policy field as 'Policy' (capital P), but the validation code was looking for 'policy' (lowercase). This caused policy condition validation to be silently skipped for all presigned POST uploads made via the AWS SDK. Now checks both 'Policy' and 'policy' for compatibility. Co-Authored-By: Matej Snuderl --- .../io/github/hectorvent/floci/services/s3/S3Controller.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 3a98a2ea..7702f6b5 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 @@ -1611,7 +1611,10 @@ private Response handlePresignedPost(String bucket, String contentType, byte[] b } // Validate policy conditions if present - String policy = fields.get("policy"); + String policy = fields.get("Policy"); + if (policy == null) { + policy = fields.get("policy"); + } if (policy != null && !policy.isEmpty()) { validatePolicyConditions(policy, bucket, fields, fileData.length); } From b794ad12ba6033bf1b0d5e6838e7531b04abc47c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:33:44 +0000 Subject: [PATCH 2/4] test(s3): add tests for uppercase 'Policy' field name in presigned POST Co-Authored-By: Matej Snuderl --- .../s3/S3PresignedPostIntegrationTest.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) 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 ee3874ff..fc0e733f 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 @@ -376,6 +376,64 @@ void presignedPostRejectsStartsWithMismatch() { + "[\"starts-with\", \"$key\", \"uploads/\"]"))); } + @Test + @Order(95) + void presignedPostEnforcesPolicyWithCapitalPFieldName() { + // The AWS SDK sends the policy field as "Policy" (capital P). + // This test verifies that validation works regardless of casing. + String key = "uploads/capital-p-reject.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)); + + // Send with capital-P "Policy" and mismatched Content-Type — should be rejected + 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", "capital-p-reject.png", fileContent.getBytes(StandardCharsets.UTF_8), "image/gif") + .when() + .post("/" + BUCKET) + .then() + .statusCode(403) + .contentType("application/xml") + .body(hasXPath("/Error/Code", equalTo("AccessDenied"))) + .body(hasXPath("/Error/Message", equalTo( + "Invalid according to Policy: Policy Condition failed: " + + "[\"eq\", \"$Content-Type\", \"image/png\"]"))); + } + + @Test + @Order(96) + void presignedPostSucceedsWithCapitalPFieldName() { + // Verify that a valid upload with capital-P "Policy" also succeeds + String key = "uploads/capital-p-ok.txt"; + String fileContent = "capital P success"; + + String policy = buildPolicy(BUCKET, key, "text/plain", 0, 10485760); + String policyBase64 = Base64.getEncoder().encodeToString(policy.getBytes(StandardCharsets.UTF_8)); + + given() + .multiPart("key", key) + .multiPart("Content-Type", "text/plain") + .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", "capital-p-ok.txt", fileContent.getBytes(StandardCharsets.UTF_8), "text/plain") + .when() + .post("/" + BUCKET) + .then() + .statusCode(204) + .header("ETag", notNullValue()); + } + @Test @Order(100) void cleanupBucket() { @@ -386,6 +444,7 @@ void cleanupBucket() { given().delete("/" + BUCKET + "/uploads/typed-file.json"); given().delete("/" + BUCKET + "/uploads/within-range.txt"); given().delete("/" + BUCKET + "/uploads/prefix-test.txt"); + given().delete("/" + BUCKET + "/uploads/capital-p-ok.txt"); given() .when() From dc05cb8df64840b583772237b2fd066e4b8cafdb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:42:40 +0000 Subject: [PATCH 3/4] fix(s3): use case-insensitive field lookup for presigned POST policy validation Normalize form field keys to lowercase before policy lookup and condition matching, matching the behaviour of LocalStack and real AWS S3. Previously only 'Policy' and 'policy' were checked; now any casing is handled correctly. Co-Authored-By: Matej Snuderl --- .../floci/services/s3/S3Controller.java | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) 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 7702f6b5..61941df5 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 @@ -1610,13 +1610,18 @@ private Response handlePresignedPost(String bucket, String contentType, byte[] b "Bucket POST must contain a file field.", 400); } - // Validate policy conditions if present - String policy = fields.get("Policy"); - if (policy == null) { - policy = fields.get("policy"); + // Build a case-insensitive (lowercased) view of the form fields for policy + // validation, matching the behaviour of LocalStack and real AWS S3. + // The AWS SDK sends "Policy" (capital P) while some clients use "policy". + Map lcFields = new LinkedHashMap<>(fields.size()); + for (Map.Entry e : fields.entrySet()) { + lcFields.put(e.getKey().toLowerCase(Locale.ROOT), e.getValue()); } + + // Validate policy conditions if present + String policy = lcFields.get("policy"); if (policy != null && !policy.isEmpty()) { - validatePolicyConditions(policy, bucket, fields, fileData.length); + validatePolicyConditions(policy, bucket, lcFields, fileData.length); } // Use Content-Type from form fields, fall back to file part Content-Type @@ -1676,10 +1681,11 @@ private void validateExactMatchCondition(JsonNode condition, String bucket, Map< String fieldName = entry.getKey(); String expectedValue = entry.getValue().asText(); String actualValue; - if ("bucket".equals(fieldName)) { + String lookupKey = fieldName.toLowerCase(Locale.ROOT); + if ("bucket".equals(lookupKey)) { actualValue = bucket; } else { - actualValue = fields.get(fieldName); + actualValue = fields.get(lookupKey); } if (actualValue == null || !actualValue.equals(expectedValue)) { throw new AwsException("AccessDenied", @@ -1706,7 +1712,7 @@ private void validateArrayCondition(JsonNode condition, String bucket, String fieldRef = condition.get(1).asText(); String expectedValue = condition.get(2).asText(); String fieldName = fieldRef.startsWith("$") ? fieldRef.substring(1) : fieldRef; - String actualValue = resolveFieldValue(fieldName, bucket, fields); + String actualValue = resolveFieldValue(fieldName.toLowerCase(Locale.ROOT), bucket, fields); if (actualValue == null || !actualValue.equals(expectedValue)) { throw new AwsException("AccessDenied", "Invalid according to Policy: Policy Condition failed: " @@ -1716,7 +1722,7 @@ private void validateArrayCondition(JsonNode condition, String bucket, String fieldRef = condition.get(1).asText(); String prefix = condition.get(2).asText(); String fieldName = fieldRef.startsWith("$") ? fieldRef.substring(1) : fieldRef; - String actualValue = resolveFieldValue(fieldName, bucket, fields); + String actualValue = resolveFieldValue(fieldName.toLowerCase(Locale.ROOT), bucket, fields); if (actualValue == null || !actualValue.startsWith(prefix)) { throw new AwsException("AccessDenied", "Invalid according to Policy: Policy Condition failed: " From 4d736d8374579c579e425333f3f3b6e689d72240 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:45:36 +0000 Subject: [PATCH 4/4] fix(s3): return XML error responses for presigned POST failures Presigned POST errors were being caught by the global AwsExceptionMapper which returned JSON format. Real AWS S3 and LocalStack return XML for presigned POST errors. Wrapped handlePresignedPost in a try-catch that converts AwsException to XML via xmlErrorResponse(). Also added HEALTHCHECK to all Dockerfiles so the image is a true drop-in replacement for LocalStack without needing healthcheck definitions in docker-compose. Co-Authored-By: Matej Snuderl --- Dockerfile | 5 +++++ Dockerfile.jvm-package | 5 ++++- Dockerfile.native | 3 +++ Dockerfile.native-package | 5 ++++- .../hectorvent/floci/services/s3/S3Controller.java | 10 ++++++++++ 5 files changed, 26 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index afdca72a..84adfb40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,8 +19,13 @@ COPY --from=build /build/target/quarkus-app/ quarkus-app/ RUN mkdir -p /app/data VOLUME /app/data +RUN apk add --no-cache curl + EXPOSE 4566 6379-6399 +HEALTHCHECK --interval=5s --timeout=3s --retries=5 \ + CMD curl -f http://localhost:4566/_floci/health || exit 1 + ARG VERSION=latest ENV FLOCI_VERSION=${VERSION} diff --git a/Dockerfile.jvm-package b/Dockerfile.jvm-package index e3eee37a..4df9b155 100644 --- a/Dockerfile.jvm-package +++ b/Dockerfile.jvm-package @@ -21,6 +21,9 @@ COPY --chown=1001:root target/quarkus-app quarkus-app/ EXPOSE 4566 6379-6399 +HEALTHCHECK --interval=5s --timeout=3s --retries=5 \ + CMD curl -f http://localhost:4566/_floci/health || exit 1 + USER 1001 -ENTRYPOINT ["java", "-jar", "quarkus-app/quarkus-run.jar", "-Dquarkus.http.host=0.0.0.0"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "quarkus-app/quarkus-run.jar", "-Dquarkus.http.host=0.0.0.0"] diff --git a/Dockerfile.native b/Dockerfile.native index 0c84c732..10bcc1f4 100644 --- a/Dockerfile.native +++ b/Dockerfile.native @@ -25,6 +25,9 @@ VOLUME /app/data EXPOSE 4566 +HEALTHCHECK --interval=5s --timeout=3s --retries=5 \ + CMD curl -f http://localhost:4566/_floci/health || exit 1 + ARG VERSION=latest ENV FLOCI_VERSION=${VERSION} diff --git a/Dockerfile.native-package b/Dockerfile.native-package index 10e60812..6f542cc7 100644 --- a/Dockerfile.native-package +++ b/Dockerfile.native-package @@ -18,6 +18,9 @@ COPY --chown=1001:root target/*-runner /app/application EXPOSE 4566 +HEALTHCHECK --interval=5s --timeout=3s --retries=5 \ + CMD curl -f http://localhost:4566/_floci/health || exit 1 + USER 1001 -CMD ["./application", "-Dquarkus.http.host=0.0.0.0"] \ No newline at end of file +CMD ["./application", "-Dquarkus.http.host=0.0.0.0"] 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 61941df5..028e8872 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 @@ -1549,6 +1549,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",