Skip to content

Commit e6943aa

Browse files
committed
fix(s3): enforce presigned POST policy conditions (eq, starts-with, content-type)
1 parent 46e7f7e commit e6943aa

2 files changed

Lines changed: 225 additions & 34 deletions

File tree

src/main/java/io/github/hectorvent/floci/services/s3/S3Controller.java

Lines changed: 81 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,16 @@
3333
import java.time.format.DateTimeFormatter;
3434
import java.util.ArrayList;
3535
import java.util.Arrays;
36+
import java.util.Iterator;
3637
import java.util.LinkedHashMap;
3738
import java.util.List;
3839
import java.util.Locale;
3940
import java.util.Map;
40-
import java.util.Arrays;
4141
import java.util.Optional;
4242
import java.util.Set;
4343

44+
import com.fasterxml.jackson.databind.JsonNode;
45+
import com.fasterxml.jackson.databind.ObjectMapper;
4446
import org.jboss.logging.Logger;
4547

4648
/**
@@ -56,6 +58,8 @@ public class S3Controller {
5658
.ofPattern("EEE, dd MMM yyyy HH:mm:ss z", Locale.US)
5759
.withZone(ZoneId.of("GMT"));
5860

61+
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
62+
5963
private final S3Service s3Service;
6064
private final S3SelectService s3SelectService;
6165
private final RegionResolver regionResolver;
@@ -1555,10 +1559,10 @@ private Response handlePresignedPost(String bucket, String contentType, byte[] b
15551559
"Bucket POST must contain a file field.", 400);
15561560
}
15571561

1558-
// Validate content-length-range from policy if present
1562+
// Validate policy conditions if present
15591563
String policy = fields.get("policy");
15601564
if (policy != null && !policy.isEmpty()) {
1561-
validateContentLengthRange(policy, fileData.length);
1565+
validatePolicyConditions(policy, bucket, fields, fileData.length);
15621566
}
15631567

15641568
// Use Content-Type from form fields, fall back to file part Content-Type
@@ -1588,45 +1592,92 @@ private Response handlePresignedPost(String bucket, String contentType, byte[] b
15881592
.build();
15891593
}
15901594

1591-
private void validateContentLengthRange(String policyBase64, int contentLength) {
1595+
private void validatePolicyConditions(String policyBase64, String bucket,
1596+
Map<String, String> fields, int contentLength) {
15921597
try {
1593-
String decoded = new String(java.util.Base64.getDecoder().decode(policyBase64), StandardCharsets.UTF_8);
1594-
// Parse conditions array from the policy JSON to find content-length-range
1595-
int condIdx = decoded.indexOf("\"conditions\"");
1596-
if (condIdx < 0) {
1597-
return;
1598-
}
1599-
// Look for content-length-range condition: ["content-length-range", min, max]
1600-
String lower = decoded.toLowerCase(Locale.ROOT);
1601-
int rangeIdx = lower.indexOf("content-length-range");
1602-
if (rangeIdx < 0) {
1598+
byte[] decoded = java.util.Base64.getDecoder().decode(policyBase64);
1599+
JsonNode policy = OBJECT_MAPPER.readTree(decoded);
1600+
JsonNode conditions = policy.get("conditions");
1601+
if (conditions == null || !conditions.isArray()) {
16031602
return;
16041603
}
1605-
// Find the enclosing array bracket
1606-
int bracketStart = decoded.lastIndexOf('[', rangeIdx);
1607-
int bracketEnd = decoded.indexOf(']', rangeIdx);
1608-
if (bracketStart < 0 || bracketEnd < 0) {
1609-
return;
1610-
}
1611-
String rangeArray = decoded.substring(bracketStart, bracketEnd + 1);
1612-
// Extract min and max values
1613-
String[] tokens = rangeArray.replaceAll("[\\[\\]\"]", "").split(",");
1614-
if (tokens.length >= 3) {
1615-
long min = Long.parseLong(tokens[1].trim());
1616-
long max = Long.parseLong(tokens[2].trim());
1617-
if (contentLength < min || contentLength > max) {
1618-
throw new AwsException("EntityTooLarge",
1619-
"Your proposed upload exceeds the maximum allowed size.", 400);
1604+
for (JsonNode condition : conditions) {
1605+
if (condition.isObject()) {
1606+
validateExactMatchCondition(condition, bucket, fields);
1607+
} else if (condition.isArray()) {
1608+
validateArrayCondition(condition, bucket, fields, contentLength);
16201609
}
16211610
}
16221611
} catch (AwsException e) {
16231612
throw e;
16241613
} catch (Exception e) {
1625-
// If policy parsing fails, skip validation (match AWS lenient behavior for emulator)
16261614
LOG.debugv("Failed to parse presigned POST policy: {0}", e.getMessage());
16271615
}
16281616
}
16291617

1618+
private void validateExactMatchCondition(JsonNode condition, String bucket, Map<String, String> fields) {
1619+
Iterator<Map.Entry<String, JsonNode>> fieldIter = condition.fields();
1620+
while (fieldIter.hasNext()) {
1621+
Map.Entry<String, JsonNode> entry = fieldIter.next();
1622+
String fieldName = entry.getKey();
1623+
String expectedValue = entry.getValue().asText();
1624+
String actualValue;
1625+
if ("bucket".equals(fieldName)) {
1626+
actualValue = bucket;
1627+
} else {
1628+
actualValue = fields.get(fieldName);
1629+
}
1630+
if (actualValue == null || !actualValue.equals(expectedValue)) {
1631+
throw new AwsException("AccessDenied",
1632+
"Invalid according to Policy: Policy Condition failed: "
1633+
+ "[\"eq\", \"$" + fieldName + "\", \"" + expectedValue + "\"]", 403);
1634+
}
1635+
}
1636+
}
1637+
1638+
private void validateArrayCondition(JsonNode condition, String bucket,
1639+
Map<String, String> fields, int contentLength) {
1640+
if (condition.size() < 3) {
1641+
return;
1642+
}
1643+
String operator = condition.get(0).asText().toLowerCase(Locale.ROOT);
1644+
if ("content-length-range".equals(operator)) {
1645+
long min = condition.get(1).asLong();
1646+
long max = condition.get(2).asLong();
1647+
if (contentLength < min || contentLength > max) {
1648+
throw new AwsException("EntityTooLarge",
1649+
"Your proposed upload exceeds the maximum allowed size.", 400);
1650+
}
1651+
} else if ("eq".equals(operator)) {
1652+
String fieldRef = condition.get(1).asText();
1653+
String expectedValue = condition.get(2).asText();
1654+
String fieldName = fieldRef.startsWith("$") ? fieldRef.substring(1) : fieldRef;
1655+
String actualValue = resolveFieldValue(fieldName, bucket, fields);
1656+
if (actualValue == null || !actualValue.equals(expectedValue)) {
1657+
throw new AwsException("AccessDenied",
1658+
"Invalid according to Policy: Policy Condition failed: "
1659+
+ "[\"eq\", \"$" + fieldName + "\", \"" + expectedValue + "\"]", 403);
1660+
}
1661+
} else if ("starts-with".equals(operator)) {
1662+
String fieldRef = condition.get(1).asText();
1663+
String prefix = condition.get(2).asText();
1664+
String fieldName = fieldRef.startsWith("$") ? fieldRef.substring(1) : fieldRef;
1665+
String actualValue = resolveFieldValue(fieldName, bucket, fields);
1666+
if (actualValue == null || !actualValue.startsWith(prefix)) {
1667+
throw new AwsException("AccessDenied",
1668+
"Invalid according to Policy: Policy Condition failed: "
1669+
+ "[\"starts-with\", \"$" + fieldName + "\", \"" + prefix + "\"]", 403);
1670+
}
1671+
}
1672+
}
1673+
1674+
private static String resolveFieldValue(String fieldName, String bucket, Map<String, String> fields) {
1675+
if ("bucket".equals(fieldName)) {
1676+
return bucket;
1677+
}
1678+
return fields.get(fieldName);
1679+
}
1680+
16301681
private static String extractBoundary(String contentType) {
16311682
if (contentType == null) {
16321683
return null;

src/test/java/io/github/hectorvent/floci/services/s3/S3PresignedPostIntegrationTest.java

Lines changed: 144 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,10 @@ void presignedPostRejectsExceedingContentLength() {
129129
.post("/" + BUCKET)
130130
.then()
131131
.statusCode(400)
132-
.body(containsString("EntityTooLarge"));
132+
.contentType("application/xml")
133+
.body(hasXPath("/Error/Code", equalTo("EntityTooLarge")))
134+
.body(hasXPath("/Error/Message", equalTo(
135+
"Your proposed upload exceeds the maximum allowed size.")));
133136
}
134137

135138
@Test
@@ -143,7 +146,10 @@ void presignedPostRequiresKeyField() {
143146
.post("/" + BUCKET)
144147
.then()
145148
.statusCode(400)
146-
.body(containsString("InvalidArgument"));
149+
.contentType("application/xml")
150+
.body(hasXPath("/Error/Code", equalTo("InvalidArgument")))
151+
.body(hasXPath("/Error/Message", equalTo(
152+
"Bucket POST must contain a field named 'key'.")));
147153
}
148154

149155
@Test
@@ -157,7 +163,10 @@ void presignedPostRequiresFileField() {
157163
.post("/" + BUCKET)
158164
.then()
159165
.statusCode(400)
160-
.body(containsString("InvalidArgument"));
166+
.contentType("application/xml")
167+
.body(hasXPath("/Error/Code", equalTo("InvalidArgument")))
168+
.body(hasXPath("/Error/Message", equalTo(
169+
"Bucket POST must contain a file field.")));
161170
}
162171

163172
@Test
@@ -227,7 +236,8 @@ void presignedPostToNonExistentBucketFails() {
227236
.post("/nonexistent-presigned-bucket")
228237
.then()
229238
.statusCode(404)
230-
.body(containsString("NoSuchBucket"));
239+
.contentType("application/xml")
240+
.body(hasXPath("/Error/Code", equalTo("NoSuchBucket")));
231241
}
232242

233243
@Test
@@ -255,6 +265,117 @@ void presignedPostWithContentLengthWithinRange() {
255265
.statusCode(204);
256266
}
257267

268+
@Test
269+
@Order(91)
270+
void presignedPostRejectsContentTypeMismatch() {
271+
String key = "uploads/ct-mismatch.png";
272+
String fileContent = "not a real png";
273+
274+
String policy = buildPolicy(BUCKET, key, "image/png", 0, 10485760);
275+
String policyBase64 = Base64.getEncoder().encodeToString(policy.getBytes(StandardCharsets.UTF_8));
276+
277+
given()
278+
.multiPart("key", key)
279+
.multiPart("Content-Type", "image/gif")
280+
.multiPart("policy", policyBase64)
281+
.multiPart("x-amz-algorithm", "AWS4-HMAC-SHA256")
282+
.multiPart("x-amz-credential", "AKIAIOSFODNN7EXAMPLE/20260330/us-east-1/s3/aws4_request")
283+
.multiPart("x-amz-date", AMZ_DATE_FORMAT.format(Instant.now()))
284+
.multiPart("x-amz-signature", "dummysignature")
285+
.multiPart("file", "ct-mismatch.png", fileContent.getBytes(StandardCharsets.UTF_8), "image/gif")
286+
.when()
287+
.post("/" + BUCKET)
288+
.then()
289+
.statusCode(403)
290+
.contentType("application/xml")
291+
.body(hasXPath("/Error/Code", equalTo("AccessDenied")))
292+
.body(hasXPath("/Error/Message", equalTo(
293+
"Invalid according to Policy: Policy Condition failed: "
294+
+ "[\"eq\", \"$Content-Type\", \"image/png\"]")));
295+
}
296+
297+
@Test
298+
@Order(92)
299+
void presignedPostRejectsKeyMismatch() {
300+
String key = "uploads/wrong-key.txt";
301+
String fileContent = "test content";
302+
303+
String policy = buildPolicy(BUCKET, "uploads/expected-key.txt", "text/plain", 0, 10485760);
304+
String policyBase64 = Base64.getEncoder().encodeToString(policy.getBytes(StandardCharsets.UTF_8));
305+
306+
given()
307+
.multiPart("key", key)
308+
.multiPart("Content-Type", "text/plain")
309+
.multiPart("policy", policyBase64)
310+
.multiPart("x-amz-algorithm", "AWS4-HMAC-SHA256")
311+
.multiPart("x-amz-credential", "AKIAIOSFODNN7EXAMPLE/20260330/us-east-1/s3/aws4_request")
312+
.multiPart("x-amz-date", AMZ_DATE_FORMAT.format(Instant.now()))
313+
.multiPart("x-amz-signature", "dummysignature")
314+
.multiPart("file", "wrong-key.txt", fileContent.getBytes(StandardCharsets.UTF_8), "text/plain")
315+
.when()
316+
.post("/" + BUCKET)
317+
.then()
318+
.statusCode(403)
319+
.contentType("application/xml")
320+
.body(hasXPath("/Error/Code", equalTo("AccessDenied")))
321+
.body(hasXPath("/Error/Message", equalTo(
322+
"Invalid according to Policy: Policy Condition failed: "
323+
+ "[\"eq\", \"$key\", \"uploads/expected-key.txt\"]")));
324+
}
325+
326+
@Test
327+
@Order(93)
328+
void presignedPostWithStartsWithCondition() {
329+
String key = "uploads/prefix-test.txt";
330+
String fileContent = "starts-with test";
331+
332+
String policy = buildStartsWithPolicy(BUCKET, "uploads/", "text/", 0, 10485760);
333+
String policyBase64 = Base64.getEncoder().encodeToString(policy.getBytes(StandardCharsets.UTF_8));
334+
335+
given()
336+
.multiPart("key", key)
337+
.multiPart("Content-Type", "text/plain")
338+
.multiPart("policy", policyBase64)
339+
.multiPart("x-amz-algorithm", "AWS4-HMAC-SHA256")
340+
.multiPart("x-amz-credential", "AKIAIOSFODNN7EXAMPLE/20260330/us-east-1/s3/aws4_request")
341+
.multiPart("x-amz-date", AMZ_DATE_FORMAT.format(Instant.now()))
342+
.multiPart("x-amz-signature", "dummysignature")
343+
.multiPart("file", "prefix-test.txt", fileContent.getBytes(StandardCharsets.UTF_8), "text/plain")
344+
.when()
345+
.post("/" + BUCKET)
346+
.then()
347+
.statusCode(204);
348+
}
349+
350+
@Test
351+
@Order(94)
352+
void presignedPostRejectsStartsWithMismatch() {
353+
String key = "other/wrong-prefix.txt";
354+
String fileContent = "starts-with mismatch";
355+
356+
String policy = buildStartsWithPolicy(BUCKET, "uploads/", "text/", 0, 10485760);
357+
String policyBase64 = Base64.getEncoder().encodeToString(policy.getBytes(StandardCharsets.UTF_8));
358+
359+
given()
360+
.multiPart("key", key)
361+
.multiPart("Content-Type", "text/plain")
362+
.multiPart("policy", policyBase64)
363+
.multiPart("x-amz-algorithm", "AWS4-HMAC-SHA256")
364+
.multiPart("x-amz-credential", "AKIAIOSFODNN7EXAMPLE/20260330/us-east-1/s3/aws4_request")
365+
.multiPart("x-amz-date", AMZ_DATE_FORMAT.format(Instant.now()))
366+
.multiPart("x-amz-signature", "dummysignature")
367+
.multiPart("file", "wrong-prefix.txt", fileContent.getBytes(StandardCharsets.UTF_8), "text/plain")
368+
.when()
369+
.post("/" + BUCKET)
370+
.then()
371+
.statusCode(403)
372+
.contentType("application/xml")
373+
.body(hasXPath("/Error/Code", equalTo("AccessDenied")))
374+
.body(hasXPath("/Error/Message", equalTo(
375+
"Invalid according to Policy: Policy Condition failed: "
376+
+ "[\"starts-with\", \"$key\", \"uploads/\"]")));
377+
}
378+
258379
@Test
259380
@Order(100)
260381
void cleanupBucket() {
@@ -264,6 +385,7 @@ void cleanupBucket() {
264385
given().delete("/" + BUCKET + "/uploads/no-policy.txt");
265386
given().delete("/" + BUCKET + "/uploads/typed-file.json");
266387
given().delete("/" + BUCKET + "/uploads/within-range.txt");
388+
given().delete("/" + BUCKET + "/uploads/prefix-test.txt");
267389

268390
given()
269391
.when()
@@ -288,4 +410,22 @@ private String buildPolicy(String bucket, String key, String contentType, long m
288410
}
289411
""".formatted(expiration, bucket, key, contentType, minSize, maxSize);
290412
}
413+
414+
private String buildStartsWithPolicy(String bucket, String keyPrefix, String contentTypePrefix,
415+
long minSize, long maxSize) {
416+
String expiration = Instant.now().plusSeconds(3600)
417+
.atZone(ZoneOffset.UTC)
418+
.format(DateTimeFormatter.ISO_INSTANT);
419+
return """
420+
{
421+
"expiration": "%s",
422+
"conditions": [
423+
{"bucket": "%s"},
424+
["starts-with", "$key", "%s"],
425+
["starts-with", "$Content-Type", "%s"],
426+
["content-length-range", %d, %d]
427+
]
428+
}
429+
""".formatted(expiration, bucket, keyPrefix, contentTypePrefix, minSize, maxSize);
430+
}
291431
}

0 commit comments

Comments
 (0)