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 3a98a2ea..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", @@ -1610,10 +1620,18 @@ private Response handlePresignedPost(String bucket, String contentType, byte[] b "Bucket POST must contain a file field.", 400); } + // 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 = fields.get("policy"); + 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 @@ -1673,10 +1691,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", @@ -1703,7 +1722,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: " @@ -1713,7 +1732,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: " 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()