Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.hectorvent.floci.services.cloudwatch.metrics.CloudWatchMetricsJsonHandler;
import io.github.hectorvent.floci.services.dynamodb.DynamoDbJsonHandler;
import io.github.hectorvent.floci.services.dynamodb.DynamoDbResponses;
import io.github.hectorvent.floci.services.dynamodb.DynamoDbStreamsJsonHandler;
import io.github.hectorvent.floci.services.sns.SnsJsonHandler;
import io.github.hectorvent.floci.services.sqs.SqsJsonHandler;
Expand Down Expand Up @@ -102,11 +103,12 @@ public Response handleJsonRequest(
action = target.substring(prefix.length());
LOG.debugv("{0} JSON action: {1}", serviceName, action);

Response response;
try {
JsonNode request = objectMapper.readTree(body);
String region = regionResolver.resolveRegion(httpHeaders);

return switch (serviceName) {
response = switch (serviceName) {
case "DynamoDB" -> dynamoDbJsonHandler.handle(action, request, region);
case "DynamoDBStreams" -> dynamoDbStreamsJsonHandler.handle(action, request, region);
case "SQS" -> sqsJsonHandler.handle(action, request, region);
Expand All @@ -116,10 +118,20 @@ public Response handleJsonRequest(
default -> null;
};
} catch (AwsException e) {
return JsonErrorResponseUtils.createErrorResponse(e);
response = JsonErrorResponseUtils.createErrorResponse(e);
} catch (Exception e) {
LOG.error("Error processing " + serviceName + " JSON request", e);
return JsonErrorResponseUtils.createErrorResponse(e);
response = JsonErrorResponseUtils.createErrorResponse(e);
}

// Real AWS DynamoDB attaches X-Amz-Crc32 to every response. The Go SDK DynamoDB
// client verifies this header on body Close() and logs "failed to close HTTP
// response body" when the header is missing — attach it here at the JSON protocol
// boundary so other callers of DynamoDbJsonHandler (CBOR, API Gateway proxy,
// Step Functions tasks) keep their original ObjectNode entity.
if ("DynamoDB".equals(serviceName) || "DynamoDBStreams".equals(serviceName)) {
return DynamoDbResponses.withCrc32(response, objectMapper);
}
return response;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package io.github.hectorvent.floci.services.dynamodb;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import org.jboss.logging.Logger;

import java.util.List;
import java.util.Map;
import java.util.zip.CRC32;

/**
* Helpers for building DynamoDB JSON protocol responses.
*/
public final class DynamoDbResponses {

private static final Logger LOG = Logger.getLogger(DynamoDbResponses.class);

private DynamoDbResponses() {}

/**
* Pre-serialize the response entity and attach an {@code X-Amz-Crc32} header whose value
* is the decimal CRC32 of the serialized bytes.
*
* <p>Real AWS DynamoDB includes this header on every response, and the AWS SDK for Go v2
* DynamoDB client wraps the response body in a CRC32-verifying reader
* ({@code service/dynamodb/internal/customizations/checksum.go}). When the header is
* missing the wrapper compares the computed CRC32 against an expected value of 0 on
* {@code Close()}, returns a checksum error, and smithy-go logs
* "failed to close HTTP response body, this may affect connection reuse" for every
* call. Sending a correct header silences the warning and gives clients a real
* integrity check.
*
* <p>This is applied only at the JSON protocol boundary (e.g. {@code AwsJsonController})
* because {@link DynamoDbJsonHandler} is also invoked from CBOR, API Gateway proxy, and
* Step Functions task flows — those callers keep the original {@code ObjectNode} entity.
*/
public static Response withCrc32(Response response, ObjectMapper objectMapper) {
if (response == null) {
return null;
}
Object entity = response.getEntity();
byte[] bodyBytes;
try {
if (entity == null) {
bodyBytes = new byte[0];
} else if (entity instanceof byte[] b) {
bodyBytes = b;
} else {
bodyBytes = objectMapper.writeValueAsBytes(entity);
}
} catch (Exception e) {
LOG.warn("Failed to serialize DynamoDB response for CRC32 computation", e);
return response;
}

CRC32 crc = new CRC32();
crc.update(bodyBytes);

Response.ResponseBuilder builder = Response.status(response.getStatus())
.entity(bodyBytes)
.type(MediaType.valueOf("application/x-amz-json-1.0"))
.header("X-Amz-Crc32", Long.toString(crc.getValue()));

MultivaluedMap<String, Object> existing = response.getHeaders();
if (existing != null) {
for (Map.Entry<String, List<Object>> e : existing.entrySet()) {
String name = e.getKey();
if ("Content-Type".equalsIgnoreCase(name)
|| "Content-Length".equalsIgnoreCase(name)
|| "X-Amz-Crc32".equalsIgnoreCase(name)) {
continue;
}
for (Object v : e.getValue()) {
builder.header(name, v);
}
}
}
return builder.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@

import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.response.Response;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;

import java.util.zip.CRC32;

import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

@QuarkusTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
Expand Down Expand Up @@ -892,4 +897,41 @@ void unsupportedOperation() {
.statusCode(400)
.body("__type", equalTo("UnknownOperationException"));
}

@Test
void responseIncludesCorrectXAmzCrc32Header() {
// The AWS SDK for Go v2 DynamoDB client wraps the response body in a CRC32-verifying
// reader and emits "failed to close HTTP response body" warnings when the header is
// missing. Verify floci attaches the header on both success and error responses and
// that the value matches the CRC32 of the response body bytes.
Response listResponse = given()
.header("X-Amz-Target", "DynamoDB_20120810.ListTables")
.contentType(DYNAMODB_CONTENT_TYPE)
.body("{}")
.when()
.post("/");

listResponse.then().statusCode(200);
String crcHeader = listResponse.getHeader("X-Amz-Crc32");
assertNotNull(crcHeader, "ListTables response must carry X-Amz-Crc32");
assertEquals(Long.toString(crc32Of(listResponse.asByteArray())), crcHeader);

Response errorResponse = given()
.header("X-Amz-Target", "DynamoDB_20120810.DescribeTable")
.contentType(DYNAMODB_CONTENT_TYPE)
.body("{\"TableName\":\"does-not-exist-crc32-check\"}")
.when()
.post("/");

errorResponse.then().statusCode(400);
String errorCrc = errorResponse.getHeader("X-Amz-Crc32");
assertNotNull(errorCrc, "Error response must carry X-Amz-Crc32");
assertEquals(Long.toString(crc32Of(errorResponse.asByteArray())), errorCrc);
}

private static long crc32Of(byte[] bytes) {
CRC32 crc = new CRC32();
crc.update(bytes);
return crc.getValue();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package io.github.hectorvent.floci.services.dynamodb;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.nio.charset.StandardCharsets;
import java.util.zip.CRC32;

import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;

class DynamoDbResponsesTest {

private ObjectMapper mapper;

@BeforeEach
void setUp() {
mapper = new ObjectMapper();
}

private static long crc32Of(byte[] bytes) {
CRC32 crc = new CRC32();
crc.update(bytes);
return crc.getValue();
}

@Test
void withCrc32_serializesObjectNode_andAttachesCorrectChecksum() throws Exception {
ObjectNode entity = mapper.createObjectNode();
entity.put("TableName", "users");
entity.putObject("TableStatus").put("value", "ACTIVE");

byte[] expectedBytes = mapper.writeValueAsBytes(entity);
long expectedCrc = crc32Of(expectedBytes);

Response input = Response.ok(entity).build();
Response wrapped = DynamoDbResponses.withCrc32(input, mapper);

assertNotNull(wrapped);
assertEquals(200, wrapped.getStatus());
assertArrayEquals(expectedBytes, (byte[]) wrapped.getEntity());
assertEquals(Long.toString(expectedCrc), wrapped.getHeaderString("X-Amz-Crc32"));
assertEquals("application/x-amz-json-1.0", wrapped.getMediaType().toString());
}

@Test
void withCrc32_byteArrayEntity_usedAsIs() {
byte[] rawBody = "{\"TableNames\":[]}".getBytes(StandardCharsets.UTF_8);
long expectedCrc = crc32Of(rawBody);

Response input = Response.ok(rawBody).build();
Response wrapped = DynamoDbResponses.withCrc32(input, mapper);

assertArrayEquals(rawBody, (byte[]) wrapped.getEntity());
assertEquals(Long.toString(expectedCrc), wrapped.getHeaderString("X-Amz-Crc32"));
}

@Test
void withCrc32_nullEntity_emptyBodyAndCrc32OfEmpty() {
Response input = Response.status(204).build();
Response wrapped = DynamoDbResponses.withCrc32(input, mapper);

assertEquals(204, wrapped.getStatus());
assertArrayEquals(new byte[0], (byte[]) wrapped.getEntity());
assertEquals(Long.toString(crc32Of(new byte[0])), wrapped.getHeaderString("X-Amz-Crc32"));
}

@Test
void withCrc32_preservesStatusCode_forErrorResponse() throws Exception {
ObjectNode errorBody = mapper.createObjectNode();
errorBody.put("__type", "ResourceNotFoundException");
errorBody.put("message", "Table not found");

Response input = Response.status(400).entity(errorBody).build();
Response wrapped = DynamoDbResponses.withCrc32(input, mapper);

assertEquals(400, wrapped.getStatus());
byte[] expectedBytes = mapper.writeValueAsBytes(errorBody);
assertArrayEquals(expectedBytes, (byte[]) wrapped.getEntity());
assertEquals(Long.toString(crc32Of(expectedBytes)), wrapped.getHeaderString("X-Amz-Crc32"));
}

@Test
void withCrc32_preservesCustomHeaders_overridesContentTypeAndLength() throws Exception {
ObjectNode entity = mapper.createObjectNode();
entity.put("ok", true);

Response input = Response.ok(entity)
.header("x-amz-request-id", "req-123")
.header("x-amz-id-2", "id-456")
.header("Content-Type", MediaType.APPLICATION_JSON)
.header("Content-Length", 999)
.build();

Response wrapped = DynamoDbResponses.withCrc32(input, mapper);

assertEquals("req-123", wrapped.getHeaderString("x-amz-request-id"));
assertEquals("id-456", wrapped.getHeaderString("x-amz-id-2"));
// Content-Type must be the DynamoDB JSON protocol type, not the one from the input
assertEquals("application/x-amz-json-1.0", wrapped.getMediaType().toString());
// Content-Length from input must not leak through; JAX-RS will recompute from bytes
assertNull(wrapped.getHeaderString("Content-Length"));
}

@Test
void withCrc32_nullResponse_returnsNull() {
assertNull(DynamoDbResponses.withCrc32(null, mapper));
}
}
Loading