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..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,10 +1610,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 +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", @@ -1703,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: " @@ -1713,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: " 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()