Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: verify the message by AWS SNS signingCertUrl. #684

Closed
wants to merge 1 commit into from
Closed
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 @@ -22,6 +22,7 @@
import io.awspring.cloud.autoconfigure.core.AwsClientCustomizer;
import io.awspring.cloud.autoconfigure.core.CredentialsProviderAutoConfiguration;
import io.awspring.cloud.autoconfigure.core.RegionProviderAutoConfiguration;
import io.awspring.cloud.sns.core.AwsSignatureVerifier;
import io.awspring.cloud.sns.core.SnsOperations;
import io.awspring.cloud.sns.core.SnsTemplate;
import io.awspring.cloud.sns.core.TopicArnResolver;
Expand Down Expand Up @@ -54,10 +55,11 @@
* @author Maciej Walkowiak
* @author Manuel Wessner
* @author Matej Nedic
* @author kazaff
*/
@AutoConfiguration
@ConditionalOnClass({ SnsClient.class, SnsTemplate.class })
@EnableConfigurationProperties({ SnsProperties.class })
@EnableConfigurationProperties({ SnsProperties.class, VerificationProperties.class })
@AutoConfigureAfter({ CredentialsProviderAutoConfiguration.class, RegionProviderAutoConfiguration.class })
@ConditionalOnProperty(name = "spring.cloud.aws.sns.enabled", havingValue = "true", matchIfMissing = true)
public class SnsAutoConfiguration {
Expand Down Expand Up @@ -103,4 +105,11 @@ public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers)

}

@ConditionalOnProperty(name = "spring.cloud.aws.sns.verification", havingValue = "true", matchIfMissing = true)
@ConditionalOnMissingBean
@Bean
public AwsSignatureVerifier awsSignatureVerifier(VerificationProperties verificationProperties) {
return new AwsSignatureVerifier(verificationProperties.getMaxLifeTimeOfMessage());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright 2013-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.awspring.cloud.autoconfigure.sns;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.lang.Nullable;

/**
* @author kazaff
*/
@ConfigurationProperties(prefix = VerificationProperties.CONFIG_PREFIX)
public class VerificationProperties {

/**
* Configuration prefix.
*/
public static final String CONFIG_PREFIX = "spring.cloud.aws.sns.verification";

@Nullable
private String enabled;

@Nullable
private long maxLifeTimeOfMessage;

@Nullable
public String getEnabled() {
return enabled;
}

public void setEnabled(@Nullable String enabled) {
this.enabled = enabled;
}

public long getMaxLifeTimeOfMessage() {
return maxLifeTimeOfMessage;
}

public void setMaxLifeTimeOfMessage(long maxLifeTimeOfMessage) {
this.maxLifeTimeOfMessage = maxLifeTimeOfMessage;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,12 @@
import io.awspring.cloud.sns.annotation.endpoint.NotificationSubscriptionMapping;
import io.awspring.cloud.sns.annotation.endpoint.NotificationUnsubscribeConfirmationMapping;
import io.awspring.cloud.sns.annotation.handlers.NotificationMessage;
import io.awspring.cloud.sns.annotation.handlers.NotificationPayload;
import io.awspring.cloud.sns.annotation.handlers.NotificationSubject;
import io.awspring.cloud.sns.core.AwsSignatureVerifier;
import io.awspring.cloud.sns.handlers.NotificationPayloads;
import io.awspring.cloud.sns.handlers.NotificationStatus;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
Expand All @@ -30,20 +34,35 @@ public class NotificationMappingController {

private static final Logger LOGGER = LoggerFactory.getLogger(NotificationMappingController.class);

private final AwsSignatureVerifier awsSignatureVerifier;

public NotificationMappingController(AwsSignatureVerifier awsSignatureVerifier) {
this.awsSignatureVerifier = awsSignatureVerifier;
}

@NotificationSubscriptionMapping(path = "/testTopic")
public void handleSubscriptionMessage(NotificationStatus status) {
status.confirmSubscription();
public void handleSubscriptionMessage(NotificationStatus status, @NotificationPayload NotificationPayloads payloads,
HttpServletRequest httpServletRequest) {
if (this.awsSignatureVerifier.verify(payloads, httpServletRequest)) {
status.confirmSubscription();
}
}

@NotificationMessageMapping(path = "/testTopic")
public void handleNotificationMessage(@NotificationSubject String subject, @NotificationMessage String message) {
LOGGER.info("NotificationMessageMapping message is: {}", message);
LOGGER.info("NotificationMessageMapping subject is: {}", subject);
public void handleNotificationMessage(@NotificationSubject String subject, @NotificationMessage String message,
@NotificationPayload NotificationPayloads payloads, HttpServletRequest httpServletRequest) {
if (this.awsSignatureVerifier.verify(payloads, httpServletRequest)) {
LOGGER.info("NotificationMessageMapping message is: {}", message);
LOGGER.info("NotificationMessageMapping subject is: {}", subject);
}
}

@NotificationUnsubscribeConfirmationMapping(path = "/testTopic")
public void handleUnsubscribeMessage(NotificationStatus status) {
status.confirmSubscription();
public void handleUnsubscribeMessage(NotificationStatus status, @NotificationPayload NotificationPayloads payloads,
HttpServletRequest httpServletRequest) {
if (this.awsSignatureVerifier.verify(payloads, httpServletRequest)) {
status.confirmSubscription();
}
}

}
6 changes: 6 additions & 0 deletions spring-cloud-aws-sns/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@
<artifactId>sqs</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.9.3</version>
</dependency>

<!-- AWS SDK v1 is required by testcontainers-localstack -->
<dependency>
<groupId>com.amazonaws</groupId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright 2013-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.awspring.cloud.sns.annotation.handlers;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Annotation that is used to map SNS message payloads to NotificationPayloads object that is annotated. Used in
* Controllers method for handling/receiving SNS notifications.
*
* @author kazaff
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface NotificationPayload {

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package io.awspring.cloud.sns.configuration;

import io.awspring.cloud.sns.handlers.NotificationMessageHandlerMethodArgumentResolver;
import io.awspring.cloud.sns.handlers.NotificationPayloadHandlerMethodArgumentResolver;
import io.awspring.cloud.sns.handlers.NotificationStatusHandlerMethodArgumentResolver;
import io.awspring.cloud.sns.handlers.NotificationSubjectHandlerMethodArgumentResolver;
import org.springframework.util.Assert;
Expand All @@ -28,6 +29,8 @@
*
* @author Alain Sahli
* @since 1.0
*
* @author kazaff
*/
public final class NotificationHandlerMethodArgumentResolverConfigurationUtils {

Expand All @@ -41,6 +44,7 @@ public static HandlerMethodArgumentResolver getNotificationHandlerMethodArgument
composite.addResolver(new NotificationStatusHandlerMethodArgumentResolver(snsClient));
composite.addResolver(new NotificationMessageHandlerMethodArgumentResolver());
composite.addResolver(new NotificationSubjectHandlerMethodArgumentResolver());
composite.addResolver(new NotificationPayloadHandlerMethodArgumentResolver());
return composite;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*
* Copyright 2013-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.awspring.cloud.sns.core;

import io.awspring.cloud.sns.handlers.NotificationPayloads;
import jakarta.servlet.http.HttpServletRequest;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.Signature;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;

/**
* Check that the request is from SNS (has the well-known header). Checks that the Signing Cert URL is hosted at
* amazonaws.com for SNS. Retrieves the signing certificate from the URL. Checks that the signing certificate is valid
* (within validity dates). Builds the string-to-sign and verifies it against the signing certificate. Verifies the
* signature against that string-to-sign. Checks that the timestamp is no more than {@link maxLifeTimeOfMessage} seconds
* prior.
*
* @author Dino Chiesa (https://github.com/DinoChiesa/Apigee-Java-AWS-SNS-Verifier)
* @author kazaff
*/
public class AwsSignatureVerifier {

private long maxLifeTimeOfMessage;

public AwsSignatureVerifier(long maxLifeTimeOfMessage) {
this.maxLifeTimeOfMessage = maxLifeTimeOfMessage;
}

public boolean verify(NotificationPayloads payloads, HttpServletRequest httpServletRequest) {

if (!isMessageHeaderValid(httpServletRequest)) {
return false;
}

if (!isMessageBodyValid(payloads)) {
return false;
}

if (maxLifeTimeOfMessage != 0L) {
Instant expiry = Instant.parse(payloads.getTimestamp()).plusSeconds(maxLifeTimeOfMessage);
Instant now = Instant.now();
long secondsRemaining = now.until(expiry, ChronoUnit.SECONDS);
if (secondsRemaining <= 0L) {
return false;
}
}

return isMessageSignatureValid(payloads);
}

private static boolean isMessageHeaderValid(HttpServletRequest httpServletRequest) {
String content = httpServletRequest.getHeader("x-amz-sns-message-type");
if (content == null || content.equals("")) {
return false;
}

content = httpServletRequest.getHeader("x-amz-sns-message-id");
if (content == null || content.equals("")) {
return false;
}

content = httpServletRequest.getHeader("x-amz-sns-topic-arn");
if (content == null || content.equals("")) {
return false;
}

return true;
}

private static boolean isMessageBodyValid(NotificationPayloads payloads) {
if (payloads.getMessage() == null || payloads.getMessageId() == null || payloads.getTimestamp() == null
|| payloads.getTopicArn() == null || payloads.getType() == null || payloads.getSigningCertUrl() == null
|| payloads.getSignatureVersion() == null || payloads.getSignature() == null) {
return false;
}
return true;
}

private static boolean isMessageSignatureValid(NotificationPayloads payloads) {
try {
String certUri = payloads.getSigningCertUrl();
verifyCertificateURL(certUri);
Signature sig = Signature.getInstance("SHA1withRSA");
sig.initVerify(CertCache.getCert(certUri).getPublicKey());
sig.update(getMessageBytesToSign(payloads));
return sig.verify(Base64.getDecoder().decode(payloads.getSignature()));
}
catch (Exception e) {
throw new SecurityException("Verify failed", e);
}
}

private static void verifyCertificateURL(String signingCertUri) {
URI certUri = URI.create(signingCertUri);
if (!"https".equals(certUri.getScheme())) {
throw new SecurityException("SigningCertURL was not using HTTPS: " + certUri.toString());
}

String hostname = certUri.getHost();
if (!hostname.startsWith("sns") || !hostname.endsWith("amazonaws.com")) {
throw new SecurityException("SigningCertUrl appears to be invalid.");
}
}

private static byte[] getMessageBytesToSign(NotificationPayloads payloads) {
String type = payloads.getType();
if ("Notification".equals(type)) {
return StringToSign.forNotification(payloads).getBytes(StandardCharsets.UTF_8);
}

if ("SubscriptionConfirmation".equals(type) || "UnsubscribeConfirmation".equals(type)) {
return StringToSign.forSubscription(payloads).getBytes(StandardCharsets.UTF_8);
}

return null;
}

private static class StringToSign {
public static String forNotification(NotificationPayloads payloads) {
String stringToSign = "Message\n" + payloads.getMessage() + "\n" + "MessageId\n" + payloads.getMessageId()
+ "\n";
if (payloads.getSubject() != null) {
stringToSign += "Subject\n" + payloads.getSubject() + "\n";
}
stringToSign += "Timestamp\n" + payloads.getTimestamp() + "\n" + "TopicArn\n" + payloads.getTopicArn()
+ "\n" + "Type\n" + payloads.getType() + "\n";
return stringToSign;
}

public static String forSubscription(NotificationPayloads payloads) {
String stringToSign = "Message\n" + payloads.getMessage() + "\n" + "MessageId\n" + payloads.getMessageId()
+ "\n" + "SubscribeURL\n" + payloads.getSubscribeUrl() + "\n" + "Timestamp\n"
+ payloads.getTimestamp() + "\n" + "Token\n" + payloads.getToken() + "\n" + "TopicArn\n"
+ payloads.getTopicArn() + "\n" + "Type\n" + payloads.getType() + "\n";
return stringToSign;
}
}
}
Loading