Skip to content

Commit a56e095

Browse files
authored
#1611 Introduce SNS endpoint verification (awspring#1612)
1 parent adf1661 commit a56e095

15 files changed

Lines changed: 201 additions & 19 deletions

File tree

docs/src/main/asciidoc/_configprops.adoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
|spring.cloud.aws.sns.enabled | `+++true+++` | Enables SNS integration.
102102
|spring.cloud.aws.sns.endpoint | | Overrides the default endpoint.
103103
|spring.cloud.aws.sns.region | | Overrides the default region.
104+
|spring.cloud.aws.sns.verification | `+++true+++` | Defines if SNS massages will be verified. By default, verification is used.
104105
|spring.cloud.aws.sqs.convert-message-id-to-uuid | `+++true+++` | Whether to convert SQS message IDs to UUIDs. Set to {@code false} for SQS-compatible providers that return non-UUID message IDs.
105106
|spring.cloud.aws.sqs.dualstack-enabled | | Configure whether the AWS client should use the AWS dualstack endpoint. Note that not each AWS service supports dual-stack. For complete list check <a href="https://docs.aws.amazon.com/vpc/latest/userguide/aws-ipv6-support.html">AWS services that support IPv6</a>
106107
|spring.cloud.aws.sqs.enabled | `+++true+++` | Enables SQS integration.

docs/src/main/asciidoc/sns.adoc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,13 @@ SNS sends three type of requests to an HTTP topic listener endpoint, for each of
289289
* Notification request -> `@NotificationMessageMapping`
290290
* Unsubscription request -> `@NotificationUnsubscribeMapping`
291291

292+
[NOTE]
293+
====
294+
Since 4.1.0 verification has been introduced for Notification request and is turned on by default. Verification uses the same region as `SNSClient`.
295+
To turn off verification simply set property `spring.cloud.aws.sns.verification=false`.
296+
For more information about SNS verification https://docs.aws.amazon.com/AWSJavaSDK/latest/javadoc/com/amazonaws/services/sns/message/SnsMessageManager.html[here].
297+
====
298+
292299
HTTP endpoints are based on Spring MVC controllers. Spring Cloud AWS added some custom argument resolvers to extract the message and subject out of the notification requests.
293300

294301
Example of integration:

spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sns/SnsAutoConfiguration.java

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
import org.springframework.messaging.support.ChannelInterceptor;
6262
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
6363
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
64+
import software.amazon.awssdk.messagemanager.sns.SnsMessageManager;
6465
import software.amazon.awssdk.services.sns.SnsAsyncClient;
6566
import software.amazon.awssdk.services.sns.SnsClient;
6667
import tools.jackson.databind.json.JsonMapper;
@@ -95,6 +96,17 @@ public SnsClient snsClient(SnsProperties properties, AwsClientBuilderConfigurer
9596
.build();
9697
}
9798

99+
@ConditionalOnProperty(name = "spring.cloud.aws.sns.verification", havingValue = "true", matchIfMissing = true)
100+
@ConditionalOnMissingBean
101+
@Bean
102+
public SnsMessageManager snsMessageManager(SnsProperties snsProperties,
103+
ObjectProvider<AwsConnectionDetails> connectionDetails,
104+
AwsClientBuilderConfigurer awsClientBuilderConfigurer) {
105+
return SnsMessageManager.builder()
106+
.region(awsClientBuilderConfigurer.resolveRegion(snsProperties, connectionDetails.getIfAvailable()))
107+
.build();
108+
}
109+
98110
@ConditionalOnMissingBean(SnsSmsOperations.class)
99111
@Bean
100112
public SnsSmsTemplate snsSmsTemplate(SnsClient snsClient) {
@@ -195,20 +207,23 @@ public SnsBatchTemplate snsBatchTemplate(SnsMessageConverter snsMessageConverter
195207
static class SnsWebConfiguration {
196208

197209
@Bean
198-
public WebMvcConfigurer snsWebMvcConfigurer(SnsClient snsClient) {
210+
public WebMvcConfigurer snsWebMvcConfigurer(SnsClient snsClient,
211+
ObjectProvider<SnsMessageManager> snsMessageManager) {
199212
if (JacksonPresent.isJackson3Present()) {
200213
return new WebMvcConfigurer() {
201214
@Override
202215
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
203-
resolvers.add(getNotificationHandlerMethodArgumentResolver(snsClient));
216+
resolvers.add(getNotificationHandlerMethodArgumentResolver(snsClient,
217+
snsMessageManager.getIfAvailable()));
204218
}
205219
};
206220
}
207221
else if (JacksonPresent.isJackson2Present()) {
208222
return new WebMvcConfigurer() {
209223
@Override
210224
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
211-
resolvers.add(getNotificationHandlerMethodArgumentResolverLegacyJackson2(snsClient));
225+
resolvers.add(getNotificationHandlerMethodArgumentResolverLegacyJackson2(snsClient,
226+
snsMessageManager.getIfAvailable()));
212227
}
213228
};
214229
}

spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sns/SnsProperties.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,17 @@ public class SnsProperties extends AwsClientProperties {
3131
* The prefix used for AWS SNS configuration.
3232
*/
3333
public static final String PREFIX = "spring.cloud.aws.sns";
34+
35+
/**
36+
* Defines if SNS massages will be verified. By default, verification is used.
37+
*/
38+
private boolean verification = true;
39+
40+
public boolean getVerification() {
41+
return verification;
42+
}
43+
44+
public void setVerification(boolean verification) {
45+
this.verification = verification;
46+
}
3447
}

spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/sns/SnsAutoConfigurationTest.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import org.springframework.messaging.support.ChannelInterceptor;
4444
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
4545
import software.amazon.awssdk.arns.Arn;
46+
import software.amazon.awssdk.messagemanager.sns.SnsMessageManager;
4647
import software.amazon.awssdk.services.sns.SnsAsyncClient;
4748
import software.amazon.awssdk.services.sns.SnsClient;
4849

@@ -73,18 +74,37 @@ void snsAutoConfigurationIsEnabled() {
7374
assertThat(context).hasSingleBean(SnsTemplate.class);
7475
assertThat(context).hasSingleBean(SnsSmsTemplate.class);
7576
assertThat(context).hasBean("snsWebMvcConfigurer");
77+
assertThat(context).hasSingleBean(SnsMessageManager.class);
7678

7779
ConfiguredAwsClient client = new ConfiguredAwsClient(context.getBean(SnsClient.class));
7880
assertThat(client.getEndpoint()).isEqualTo(URI.create("https://sns.eu-west-1.amazonaws.com"));
7981

8082
});
8183
}
8284

85+
@Test
86+
void snsAutoConfigurationIsEnabledAndVerifyIsDisabled() {
87+
this.contextRunner
88+
.withPropertyValues("spring.cloud.aws.sns.enabled:true", "spring.cloud.aws.sns.verification:false")
89+
.run(context -> {
90+
assertThat(context).hasSingleBean(SnsClient.class);
91+
assertThat(context).hasSingleBean(SnsTemplate.class);
92+
assertThat(context).hasSingleBean(SnsSmsTemplate.class);
93+
assertThat(context).hasBean("snsWebMvcConfigurer");
94+
assertThat(context).doesNotHaveBean(SnsMessageManager.class);
95+
96+
ConfiguredAwsClient client = new ConfiguredAwsClient(context.getBean(SnsClient.class));
97+
assertThat(client.getEndpoint()).isEqualTo(URI.create("https://sns.eu-west-1.amazonaws.com"));
98+
99+
});
100+
}
101+
83102
@Test
84103
void withCustomEndpoint() {
85104
this.contextRunner.withPropertyValues("spring.cloud.aws.sns.endpoint:http://localhost:8090").run(context -> {
86105
assertThat(context).hasSingleBean(SnsTemplate.class);
87106
assertThat(context).hasBean("snsWebMvcConfigurer");
107+
assertThat(context).hasSingleBean(SnsMessageManager.class);
88108

89109
ConfiguredAwsClient client = new ConfiguredAwsClient(context.getBean(SnsClient.class));
90110
assertThat(client.getEndpoint()).isEqualTo(URI.create("http://localhost:8090"));
@@ -107,6 +127,7 @@ void doesNotConfigureArgumentResolversWhenSpringWebNotOnTheClasspath() {
107127
assertThat(context).hasSingleBean(SnsMessageConverter.class);
108128
assertThat(context).hasSingleBean(BatchExecutionStrategy.class);
109129
assertThat(context).hasSingleBean(SnsSmsTemplate.class);
130+
assertThat(context).hasSingleBean(SnsMessageManager.class);
110131
assertThat(context).doesNotHaveBean("snsWebMvcConfigurer");
111132
});
112133
}
@@ -141,6 +162,7 @@ void snsAsyncTemplateIsNotCreatedWhenSnsAsyncClientIsNotPresent() {
141162
assertThat(context).hasSingleBean(SnsClient.class);
142163
assertThat(context).hasSingleBean(SnsTemplate.class);
143164
assertThat(context).doesNotHaveBean(SnsAsyncTemplate.class);
165+
assertThat(context).hasSingleBean(SnsMessageManager.class);
144166
});
145167
}
146168

@@ -151,6 +173,7 @@ void snsAsyncTemplateIsCreatedWhenSnsAsyncClientIsPresent() {
151173
assertThat(context).hasSingleBean(SnsTemplate.class);
152174
assertThat(context).hasSingleBean(SnsAsyncClient.class);
153175
assertThat(context).hasSingleBean(SnsAsyncTemplate.class);
176+
assertThat(context).hasSingleBean(SnsMessageManager.class);
154177
});
155178
}
156179

@@ -168,7 +191,7 @@ void bothAsyncTemplatesAndOperationsAreInjectable() {
168191
static class CustomTopicArnResolverConfiguration {
169192

170193
@Bean
171-
TopicArnResolver customS3OutputStreamProvider() {
194+
TopicArnResolver customTopicArnResolver() {
172195
return new CustomTopicArnResolver();
173196
}
174197

spring-cloud-aws-dependencies/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
<properties>
2626
<spotless.version>2.31.0</spotless.version>
27-
<awssdk-v2.version>2.41.5</awssdk-v2.version>
27+
<awssdk-v2.version>2.42.36</awssdk-v2.version>
2828
<kcl.version>3.1.2</kcl.version>
2929
<kpl.version>1.0.4</kpl.version>
3030
<amazon.dax.version>2.0.7</amazon.dax.version>

spring-cloud-aws-sns/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@
9292
<optional>true</optional>
9393
</dependency>
9494

95+
<dependency>
96+
<groupId>software.amazon.awssdk</groupId>
97+
<artifactId>sns-message-manager</artifactId>
98+
</dependency>
99+
95100
</dependencies>
96101

97102
</project>

spring-cloud-aws-sns/src/main/java/io/awspring/cloud/sns/configuration/NotificationHandlerMethodArgumentResolverConfigurationUtils.java

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@
2121
import io.awspring.cloud.sns.handlers.legacy.LegacyJackson2NotificationMessageHandlerMethodArgumentResolver;
2222
import io.awspring.cloud.sns.handlers.legacy.LegacyJackson2NotificationStatusHandlerMethodArgumentResolver;
2323
import io.awspring.cloud.sns.handlers.legacy.LegacyJackson2NotificationSubjectHandlerMethodArgumentResolver;
24+
import org.jspecify.annotations.Nullable;
2425
import org.springframework.util.Assert;
2526
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
2627
import org.springframework.web.method.support.HandlerMethodArgumentResolverComposite;
28+
import software.amazon.awssdk.messagemanager.sns.SnsMessageManager;
2729
import software.amazon.awssdk.services.sns.SnsClient;
2830

2931
/**
@@ -39,21 +41,40 @@ private NotificationHandlerMethodArgumentResolverConfigurationUtils() {
3941
throw new IllegalStateException("Can't instantiate a utility class");
4042
}
4143

42-
public static HandlerMethodArgumentResolver getNotificationHandlerMethodArgumentResolver(SnsClient snsClient) {
44+
public static HandlerMethodArgumentResolver getNotificationHandlerMethodArgumentResolver(SnsClient snsClient,
45+
@Nullable SnsMessageManager snsMessageManager) {
4346
Assert.notNull(snsClient, "snsClient is required");
4447
HandlerMethodArgumentResolverComposite composite = new HandlerMethodArgumentResolverComposite();
45-
composite.addResolver(new NotificationStatusHandlerMethodArgumentResolver(snsClient));
46-
composite.addResolver(new NotificationMessageHandlerMethodArgumentResolver());
48+
49+
if (snsMessageManager != null) {
50+
composite.addResolver(new NotificationStatusHandlerMethodArgumentResolver(snsClient, snsMessageManager));
51+
composite.addResolver(new NotificationMessageHandlerMethodArgumentResolver(
52+
NotificationMessageHandlerMethodArgumentResolver.converters, snsMessageManager));
53+
}
54+
else {
55+
composite.addResolver(new NotificationStatusHandlerMethodArgumentResolver(snsClient));
56+
composite.addResolver(new NotificationMessageHandlerMethodArgumentResolver());
57+
}
4758
composite.addResolver(new NotificationSubjectHandlerMethodArgumentResolver());
59+
4860
return composite;
4961
}
5062

5163
public static HandlerMethodArgumentResolver getNotificationHandlerMethodArgumentResolverLegacyJackson2(
52-
SnsClient snsClient) {
64+
SnsClient snsClient, @Nullable SnsMessageManager snsMessageManager) {
5365
Assert.notNull(snsClient, "snsClient is required");
5466
HandlerMethodArgumentResolverComposite composite = new HandlerMethodArgumentResolverComposite();
55-
composite.addResolver(new LegacyJackson2NotificationStatusHandlerMethodArgumentResolver(snsClient));
56-
composite.addResolver(new LegacyJackson2NotificationMessageHandlerMethodArgumentResolver());
67+
if (snsMessageManager != null) {
68+
composite.addResolver(
69+
new LegacyJackson2NotificationStatusHandlerMethodArgumentResolver(snsClient, snsMessageManager));
70+
composite.addResolver(new LegacyJackson2NotificationMessageHandlerMethodArgumentResolver(
71+
LegacyJackson2NotificationMessageHandlerMethodArgumentResolver.converters, snsMessageManager));
72+
}
73+
else {
74+
composite.addResolver(new LegacyJackson2NotificationStatusHandlerMethodArgumentResolver(snsClient));
75+
composite.addResolver(new LegacyJackson2NotificationMessageHandlerMethodArgumentResolver());
76+
}
77+
5778
composite.addResolver(new LegacyJackson2NotificationSubjectHandlerMethodArgumentResolver());
5879
return composite;
5980
}

spring-cloud-aws-sns/src/main/java/io/awspring/cloud/sns/handlers/NotificationMessageHandlerMethodArgumentResolver.java

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.nio.charset.StandardCharsets;
2323
import java.util.Arrays;
2424
import java.util.List;
25+
import org.jspecify.annotations.Nullable;
2526
import org.springframework.core.MethodParameter;
2627
import org.springframework.http.HttpHeaders;
2728
import org.springframework.http.HttpInputMessage;
@@ -31,6 +32,7 @@
3132
import org.springframework.http.converter.StringHttpMessageConverter;
3233
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
3334
import org.springframework.util.StringUtils;
35+
import software.amazon.awssdk.messagemanager.sns.SnsMessageManager;
3436
import tools.jackson.databind.JsonNode;
3537

3638
/**
@@ -46,18 +48,30 @@ public class NotificationMessageHandlerMethodArgumentResolver
4648

4749
private final List<HttpMessageConverter<?>> messageConverter;
4850

51+
@Nullable
52+
private final SnsMessageManager snsMessageManager;
53+
54+
public static final List<HttpMessageConverter<?>> converters = Arrays.asList(new JacksonJsonHttpMessageConverter(),
55+
new StringHttpMessageConverter());
56+
4957
public NotificationMessageHandlerMethodArgumentResolver() {
50-
this(Arrays.asList(new JacksonJsonHttpMessageConverter(), new StringHttpMessageConverter()));
58+
this(converters);
5159
}
5260

5361
public NotificationMessageHandlerMethodArgumentResolver(List<HttpMessageConverter<?>> messageConverter) {
62+
this(messageConverter, null);
63+
}
64+
65+
public NotificationMessageHandlerMethodArgumentResolver(List<HttpMessageConverter<?>> messageConverter,
66+
@Nullable SnsMessageManager snsMessageManager) {
67+
this.snsMessageManager = snsMessageManager;
5468
this.messageConverter = messageConverter;
5569
}
5670

5771
private static MediaType getMediaType(JsonNode content) {
5872
JsonNode contentTypeNode = content.findPath("MessageAttributes").findPath("contentType");
5973
if (contentTypeNode.isObject()) {
60-
String contentType = contentTypeNode.findPath("Value").asText();
74+
String contentType = contentTypeNode.findPath("Value").asString();
6175
if (StringUtils.hasText(contentType)) {
6276
return MediaType.parseMediaType(contentType);
6377
}
@@ -82,6 +96,9 @@ protected Object doResolveArgumentFromNotificationMessage(JsonNode content, Http
8296

8397
MediaType mediaType = getMediaType(content);
8498
String messageContent = content.findPath("Message").asString();
99+
if (snsMessageManager != null) {
100+
verifySignature(content.toString());
101+
}
85102
for (HttpMessageConverter<?> converter : this.messageConverter) {
86103
if (converter.canRead(parameterType, mediaType)) {
87104
try {
@@ -99,6 +116,10 @@ protected Object doResolveArgumentFromNotificationMessage(JsonNode content, Http
99116
"Error converting notification message with payload:" + messageContent, request);
100117
}
101118

119+
private void verifySignature(String payload) {
120+
snsMessageManager.parseMessage(payload);
121+
}
122+
102123
public static final class ByteArrayHttpInputMessage implements HttpInputMessage {
103124

104125
private final String content;

spring-cloud-aws-sns/src/main/java/io/awspring/cloud/sns/handlers/NotificationStatusHandlerMethodArgumentResolver.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
*/
1616
package io.awspring.cloud.sns.handlers;
1717

18+
import org.jspecify.annotations.Nullable;
1819
import org.springframework.core.MethodParameter;
1920
import org.springframework.http.HttpInputMessage;
21+
import software.amazon.awssdk.messagemanager.sns.SnsMessageManager;
2022
import software.amazon.awssdk.services.sns.SnsClient;
2123
import software.amazon.awssdk.services.sns.model.ConfirmSubscriptionRequest;
2224
import tools.jackson.databind.JsonNode;
@@ -33,8 +35,17 @@ public class NotificationStatusHandlerMethodArgumentResolver
3335

3436
private final SnsClient snsClient;
3537

38+
@Nullable
39+
private final SnsMessageManager snsMessageManager;
40+
3641
public NotificationStatusHandlerMethodArgumentResolver(SnsClient snsClient) {
42+
this(snsClient, null);
43+
}
44+
45+
public NotificationStatusHandlerMethodArgumentResolver(SnsClient snsClient,
46+
@Nullable SnsMessageManager snsMessageManager) {
3747
this.snsClient = snsClient;
48+
this.snsMessageManager = snsMessageManager;
3849
}
3950

4051
@Override
@@ -50,10 +61,19 @@ protected Object doResolveArgumentFromNotificationMessage(JsonNode content, Http
5061
throw new IllegalArgumentException(
5162
"NotificationStatus is only available for subscription and unsubscription requests");
5263
}
64+
65+
if (snsMessageManager != null) {
66+
verifySignature(content.toString());
67+
}
68+
5369
return new AmazonSnsNotificationStatus(this.snsClient, content.get("TopicArn").asString(),
5470
content.get("Token").asString());
5571
}
5672

73+
private void verifySignature(String payload) {
74+
snsMessageManager.parseMessage(payload);
75+
}
76+
5777
public static final class AmazonSnsNotificationStatus implements NotificationStatus {
5878

5979
private final SnsClient snsClient;

0 commit comments

Comments
 (0)