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/3] 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/3] 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/3] 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: "