Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
5 changes: 4 additions & 1 deletion Dockerfile.jvm-package
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
ENTRYPOINT ["java", "-jar", "quarkus-app/quarkus-run.jar", "-Dquarkus.http.host=0.0.0.0"]
3 changes: 3 additions & 0 deletions Dockerfile.native
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
5 changes: 4 additions & 1 deletion Dockerfile.native-package
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
CMD ["./application", "-Dquarkus.http.host=0.0.0.0"]
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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<String, String> lcFields = new LinkedHashMap<>(fields.size());
for (Map.Entry<String, String> 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
Expand Down Expand Up @@ -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",
Expand All @@ -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: "
Expand All @@ -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: "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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()
Expand Down