Skip to content

Commit 5922d85

Browse files
authored
feat(large-messages): add optional allowedBuckets allowlist (#2555)
* feat(large-messages): add optional allowedBuckets allowlist Add an opt-in bucket allowlist to the v2 large messages utility. The S3 bucket in the message pointer is controlled by the sender, so this gives consumers fine-grained, in-application control over which buckets the utility may read from and delete, complementing IAM permissions. Configure via LargeMessageConfig.withAllowedBuckets(Set). When a non-empty allowlist is set, a message whose pointer names a bucket outside the allowlist is rejected with LargeMessageProcessingException before any S3 interaction. An empty allowlist (default) preserves the existing behavior, so the change is backward compatible and applies to both the @LargeMessage annotation and the functional API. Includes unit tests, an e2e handler and negative e2e test, and docs with a security note, resource-scoped IAM example, and usage examples. * fix(large-messages): wrap getAllowedBuckets() return in unmodifiableSet Avoid exposing the internal set reference (SpotBugs EI_EXPOSE_REP).
1 parent 1d2fd25 commit 5922d85

15 files changed

Lines changed: 524 additions & 24 deletions

File tree

docs/utilities/large_messages.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,40 @@ on the S3 bucket used for the large messages offloading:
174174
- `s3:GetObject`
175175
- `s3:DeleteObject`
176176

177+
### Security: Restrict which buckets the utility accesses
178+
179+
???+ warning "The message sender controls the `s3BucketName` in the payload pointer"
180+
The large message body contains a pointer of the form
181+
`["software.amazon.payloadoffloading.PayloadS3Pointer",{"s3BucketName":"...","s3Key":"..."}]`.
182+
The utility reads `s3BucketName` from this pointer and fetches the object using your Lambda function's
183+
own IAM credentials. Any sender that can write to your queue, or publish to a topic that fans out to it,
184+
can name any bucket your execution role can reach. Your function then reads from that bucket. Delete-after-read
185+
is enabled by default (`deleteS3Object=true`), so your function also deletes the object it read.
186+
187+
Apply both of the following controls:
188+
189+
1. **Configure an allowlist** with `LargeMessageConfig.init().withAllowedBuckets(...)`. The utility rejects any
190+
message whose pointer names a bucket outside the allowlist before it reads or deletes from S3. See
191+
[Restricting allowed buckets](#restricting-allowed-buckets).
192+
2. **Scope your IAM policy** to the offload bucket. Grant `s3:GetObject` and `s3:DeleteObject` on the bucket
193+
ARN instead of `*`:
194+
195+
```json
196+
{
197+
"Version": "2012-10-17",
198+
"Statement": [
199+
{
200+
"Effect": "Allow",
201+
"Action": [
202+
"s3:GetObject",
203+
"s3:DeleteObject"
204+
],
205+
"Resource": "arn:aws:s3:::<your-offload-bucket>/*"
206+
}
207+
]
208+
}
209+
```
210+
177211
## Usage
178212

179213
You can use the Large Messages utility with either the `@LargeMessage` annotation or the functional API.
@@ -481,6 +515,66 @@ If you need to customize this `S3Client`, you can leverage the `LargeMessageConf
481515
}
482516
```
483517

518+
## Restricting allowed buckets
519+
520+
The message sender controls the bucket named in the message pointer (see
521+
[the security note in Permissions](#security-restrict-which-buckets-the-utility-accesses)). Use the
522+
`LargeMessageConfig` singleton to restrict which S3 buckets the utility reads from and deletes. This works with
523+
both the annotation and the functional API. When you configure a non-empty allowlist, the utility rejects any
524+
message whose pointer names a bucket outside the allowlist. It throws a `LargeMessageProcessingException` before
525+
it reads or deletes from S3. An empty allowlist, the default, applies no restriction.
526+
527+
=== "@LargeMessage annotation"
528+
```java hl_lines="6"
529+
import software.amazon.lambda.powertools.largemessages.LargeMessage;
530+
531+
public class SqsMessageHandler implements RequestHandler<SQSEvent, SQSBatchResponse> {
532+
533+
public SqsMessageHandler() {
534+
LargeMessageConfig.init().withAllowedBuckets(Collections.singleton("my-offload-bucket"));
535+
}
536+
537+
@Override
538+
public SQSBatchResponse handleRequest(SQSEvent event, Context context) {
539+
for (SQSMessage message: event.getRecords()) {
540+
// throws LargeMessageProcessingException if the pointer's bucket is not in the allowlist
541+
processRawMessage(message, context);
542+
}
543+
return SQSBatchResponse.builder().build();
544+
}
545+
546+
@LargeMessage
547+
private void processRawMessage(SQSEvent.SQSMessage sqsMessage, Context context) {
548+
// sqsMessage.getBody() will contain the content of the S3 object
549+
}
550+
}
551+
```
552+
553+
=== "Functional API"
554+
```java hl_lines="6"
555+
import software.amazon.lambda.powertools.largemessages.LargeMessages;
556+
557+
public class SqsMessageHandler implements RequestHandler<SQSEvent, SQSBatchResponse> {
558+
559+
public SqsMessageHandler() {
560+
LargeMessageConfig.init().withAllowedBuckets(Collections.singleton("my-offload-bucket"));
561+
}
562+
563+
@Override
564+
public SQSBatchResponse handleRequest(SQSEvent event, Context context) {
565+
for (SQSMessage message: event.getRecords()) {
566+
// throws LargeMessageProcessingException if the pointer's bucket is not in the allowlist
567+
LargeMessages.processLargeMessage(message, this::processRawMessage);
568+
}
569+
return SQSBatchResponse.builder().build();
570+
}
571+
572+
private void processRawMessage(SQSEvent.SQSMessage sqsMessage) {
573+
// sqsMessage.getBody() will contain the content of the S3 object
574+
}
575+
}
576+
```
577+
484578
## Migration from the SQS Large Message utility
485579

486580
- Replace the dependency in maven / gradle: `powertools-sqs` ==> `powertools-large-messages`

examples/powertools-examples-kafka/src/main/java/org/demo/kafka/protobuf/ProtobufProduct.java

Lines changed: 19 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/powertools-examples-kafka/src/main/java/org/demo/kafka/protobuf/ProtobufProductOrBuilder.java

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/powertools-examples-kafka/src/main/java/org/demo/kafka/protobuf/ProtobufProductOuterClass.java

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
2+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
3+
<modelVersion>4.0.0</modelVersion>
4+
5+
<parent>
6+
<groupId>software.amazon.lambda</groupId>
7+
<artifactId>e2e-test-handlers-parent</artifactId>
8+
<version>2.10.0</version>
9+
</parent>
10+
11+
<artifactId>e2e-test-handler-largemessage-restricted</artifactId>
12+
<packaging>jar</packaging>
13+
<name>E2E test handler – Large message (allowedBuckets restricted)</name>
14+
15+
<dependencies>
16+
<dependency>
17+
<groupId>software.amazon.awssdk</groupId>
18+
<artifactId>dynamodb</artifactId>
19+
</dependency>
20+
<dependency>
21+
<groupId>software.amazon.lambda</groupId>
22+
<artifactId>powertools-large-messages</artifactId>
23+
</dependency>
24+
<dependency>
25+
<groupId>software.amazon.lambda</groupId>
26+
<artifactId>powertools-logging-log4j</artifactId>
27+
</dependency>
28+
<dependency>
29+
<groupId>com.amazonaws</groupId>
30+
<artifactId>aws-lambda-java-events</artifactId>
31+
</dependency>
32+
<dependency>
33+
<groupId>org.aspectj</groupId>
34+
<artifactId>aspectjrt</artifactId>
35+
</dependency>
36+
</dependencies>
37+
38+
<build>
39+
<plugins>
40+
<plugin>
41+
<groupId>dev.aspectj</groupId>
42+
<artifactId>aspectj-maven-plugin</artifactId>
43+
<configuration>
44+
<source>${maven.compiler.source}</source>
45+
<target>${maven.compiler.target}</target>
46+
<complianceLevel>${maven.compiler.target}</complianceLevel>
47+
<aspectLibraries>
48+
<aspectLibrary>
49+
<groupId>software.amazon.lambda</groupId>
50+
<artifactId>powertools-large-messages</artifactId>
51+
</aspectLibrary>
52+
<aspectLibrary>
53+
<groupId>software.amazon.lambda</groupId>
54+
<artifactId>powertools-logging</artifactId>
55+
</aspectLibrary>
56+
</aspectLibraries>
57+
</configuration>
58+
<executions>
59+
<execution>
60+
<goals>
61+
<goal>compile</goal>
62+
</goals>
63+
</execution>
64+
</executions>
65+
</plugin>
66+
<plugin>
67+
<groupId>org.apache.maven.plugins</groupId>
68+
<artifactId>maven-shade-plugin</artifactId>
69+
</plugin>
70+
</plugins>
71+
</build>
72+
</project>
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
* Copyright 2023 Amazon.com, Inc. or its affiliates.
3+
* Licensed under the Apache License, Version 2.0 (the
4+
* "License"); you may not use this file except in compliance
5+
* with the License. You may obtain a copy of the License at
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
* Unless required by applicable law or agreed to in writing, software
8+
* distributed under the License is distributed on an "AS IS" BASIS,
9+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
* See the License for the specific language governing permissions and
11+
* limitations under the License.
12+
*
13+
*/
14+
15+
package software.amazon.lambda.powertools.e2e;
16+
17+
import com.amazonaws.services.lambda.runtime.Context;
18+
import com.amazonaws.services.lambda.runtime.RequestHandler;
19+
import com.amazonaws.services.lambda.runtime.events.SQSBatchResponse;
20+
import com.amazonaws.services.lambda.runtime.events.SQSEvent;
21+
import com.amazonaws.services.lambda.runtime.events.SQSEvent.SQSMessage;
22+
import java.nio.charset.StandardCharsets;
23+
import java.util.Collections;
24+
import java.util.HashMap;
25+
import java.util.Map;
26+
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
27+
import software.amazon.awssdk.regions.Region;
28+
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
29+
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
30+
import software.amazon.awssdk.services.dynamodb.model.PutItemRequest;
31+
import software.amazon.awssdk.utils.BinaryUtils;
32+
import software.amazon.awssdk.utils.Md5Utils;
33+
import software.amazon.lambda.powertools.largemessages.LargeMessage;
34+
import software.amazon.lambda.powertools.largemessages.LargeMessageConfig;
35+
import software.amazon.lambda.powertools.logging.Logging;
36+
37+
public class Function implements RequestHandler<SQSEvent, SQSBatchResponse> {
38+
39+
// The real offload bucket is created per test run with a random name (largemessagebucket<random>).
40+
// By pinning the allowlist to a fixed, non-matching bucket name, every offloaded message must be
41+
// rejected before any S3 read or delete, exercising the bucket-allowlist security check.
42+
private static final String DISALLOWED_BUCKET = "powertools-e2e-disallowed-bucket";
43+
44+
private static final String TABLE_FOR_ASYNC_TESTS = System.getenv("TABLE_FOR_ASYNC_TESTS");
45+
private DynamoDbClient client;
46+
47+
public Function() {
48+
LargeMessageConfig.init().withAllowedBuckets(Collections.singleton(DISALLOWED_BUCKET));
49+
if (client == null) {
50+
client = DynamoDbClient.builder()
51+
.httpClient(UrlConnectionHttpClient.builder().build())
52+
.region(Region.of(System.getenv("AWS_REGION")))
53+
.build();
54+
}
55+
}
56+
57+
@Logging(logEvent = true)
58+
public SQSBatchResponse handleRequest(SQSEvent event, Context context) {
59+
for (SQSMessage message : event.getRecords()) {
60+
processRawMessage(message, context);
61+
}
62+
return SQSBatchResponse.builder().build();
63+
}
64+
65+
@LargeMessage
66+
private void processRawMessage(SQSMessage sqsMessage, Context context) {
67+
String bodyMD5 = md5(sqsMessage.getBody());
68+
if (!sqsMessage.getMd5OfBody().equals(bodyMD5)) {
69+
throw new SecurityException(
70+
String.format("message digest does not match, expected %s, got %s", sqsMessage.getMd5OfBody(),
71+
bodyMD5));
72+
}
73+
74+
Map<String, AttributeValue> item = new HashMap<>();
75+
item.put("functionName", AttributeValue.builder().s(context.getFunctionName()).build());
76+
item.put("id", AttributeValue.builder().s(sqsMessage.getMessageId()).build());
77+
item.put("bodyMD5", AttributeValue.builder().s(bodyMD5).build());
78+
item.put("bodySize",
79+
AttributeValue.builder().n(String.valueOf(sqsMessage.getBody().getBytes(StandardCharsets.UTF_8).length))
80+
.build());
81+
82+
client.putItem(PutItemRequest.builder().tableName(TABLE_FOR_ASYNC_TESTS).item(item).build());
83+
}
84+
85+
private String md5(String message) {
86+
return BinaryUtils.toHex(Md5Utils.computeMD5Hash(message.getBytes(StandardCharsets.UTF_8)));
87+
}
88+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<Configuration>
3+
<Appenders>
4+
<Console name="JsonAppender" target="SYSTEM_OUT">
5+
<JsonTemplateLayout eventTemplateUri="classpath:LambdaJsonLayout.json" />
6+
</Console>
7+
</Appenders>
8+
<Loggers>
9+
<Root level="INFO">
10+
<AppenderRef ref="JsonAppender"/>
11+
</Root>
12+
<Logger name="JsonLogger" level="INFO" additivity="false">
13+
<AppenderRef ref="JsonAppender"/>
14+
</Logger>
15+
</Loggers>
16+
</Configuration>

powertools-e2e-tests/handlers/pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
<module>batch</module>
2929
<module>largemessage</module>
3030
<module>largemessage-functional</module>
31+
<module>largemessage-restricted</module>
3132
<module>largemessage_idempotent</module>
3233
<module>logging-log4j</module>
3334
<module>logging-log4j-fluent-api</module>

0 commit comments

Comments
 (0)