remove outdated validatesns library and replace with maintainable code

This commit is contained in:
jimmoffet
2022-09-20 20:11:09 -07:00
parent f1aec54665
commit a03de0dd56
10 changed files with 155 additions and 32 deletions

View File

@@ -71,6 +71,7 @@ test: ## Run tests
freeze-requirements: ## Pin all requirements including sub dependencies into requirements.txt freeze-requirements: ## Pin all requirements including sub dependencies into requirements.txt
pip install --upgrade pip-tools pip install --upgrade pip-tools
pip-compile requirements.in pip-compile requirements.in
pip3 install -r requirements.txt
.PHONY: audit .PHONY: audit
audit: audit:

View File

@@ -4,21 +4,20 @@ from json import decoder
import iso8601 import iso8601
import requests import requests
import validatesns
from celery.exceptions import Retry from celery.exceptions import Retry
from flask import Blueprint, current_app, json, jsonify, request from flask import Blueprint, current_app, json, jsonify, request
from notifications_utils.statsd_decorators import statsd
from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm.exc import NoResultFound
from app import notify_celery, redis_store, statsd_client from app import notify_celery, statsd_client
from app.celery.validate_sns import valid_sns_message
from app.config import QueueNames from app.config import QueueNames
from app.dao import notifications_dao from app.dao import notifications_dao
from app.errors import InvalidRequest, register_errors from app.errors import InvalidRequest, register_errors
from app.models import NOTIFICATION_PENDING, NOTIFICATION_SENDING from app.models import NOTIFICATION_PENDING, NOTIFICATION_SENDING
from app.notifications.notifications_ses_callback import ( from app.notifications.notifications_ses_callback import (
_check_and_queue_complaint_callback_task, _check_and_queue_complaint_callback_task,
_determine_notification_bounce_type,
check_and_queue_callback_task, check_and_queue_callback_task,
determine_notification_bounce_type,
get_aws_responses, get_aws_responses,
handle_complaint, handle_complaint,
) )
@@ -26,7 +25,6 @@ from app.notifications.notifications_ses_callback import (
ses_callback_blueprint = Blueprint('notifications_ses_callback', __name__) ses_callback_blueprint = Blueprint('notifications_ses_callback', __name__)
register_errors(ses_callback_blueprint) register_errors(ses_callback_blueprint)
class SNSMessageType(enum.Enum): class SNSMessageType(enum.Enum):
SubscriptionConfirmation = 'SubscriptionConfirmation' SubscriptionConfirmation = 'SubscriptionConfirmation'
Notification = 'Notification' Notification = 'Notification'
@@ -44,14 +42,6 @@ def verify_message_type(message_type: str):
raise InvalidMessageTypeException(f'{message_type} is not a valid message type.') raise InvalidMessageTypeException(f'{message_type} is not a valid message type.')
def get_certificate(url):
res = redis_store.get(url)
if res is not None:
return res
res = requests.get(url).content
redis_store.set(url, res, ex=60 * 60) # 60 minutes
return res
# 400 counts as a permanent failure so SNS will not retry. # 400 counts as a permanent failure so SNS will not retry.
# 500 counts as a failed delivery attempt so SNS will retry. # 500 counts as a failed delivery attempt so SNS will retry.
# See https://docs.aws.amazon.com/sns/latest/dg/DeliveryPolicies.html#DeliveryPolicies # See https://docs.aws.amazon.com/sns/latest/dg/DeliveryPolicies.html#DeliveryPolicies
@@ -62,35 +52,53 @@ def get_certificate(url):
def sns_callback_handler(): def sns_callback_handler():
message_type = request.headers.get('x-amz-sns-message-type') message_type = request.headers.get('x-amz-sns-message-type')
try: try:
print("validating message type")
verify_message_type(message_type) verify_message_type(message_type)
except InvalidMessageTypeException: except InvalidMessageTypeException:
current_app.logger.exception(f"Response headers: {request.headers}\nResponse data: {request.data}")
raise InvalidRequest("SES-SNS callback failed: invalid message type", 400) raise InvalidRequest("SES-SNS callback failed: invalid message type", 400)
try: try:
message = json.loads(request.data) print("loading message")
message = json.loads(request.data.decode('utf-8'))
except decoder.JSONDecodeError: except decoder.JSONDecodeError:
current_app.logger.exception(f"Response headers: {request.headers}\nResponse data: {request.data}")
raise InvalidRequest("SES-SNS callback failed: invalid JSON given", 400) raise InvalidRequest("SES-SNS callback failed: invalid JSON given", 400)
current_app.logger.info(f"Message type: {message_type}\nResponse data: {message}")
try: try:
validatesns.validate(message, get_certificate=get_certificate) print("attempting to validate sns")
except validatesns.ValidationError: if valid_sns_message(message) == False:
current_app.logger.error(f"SES-SNS callback failed: validation failed! Response headers: {request.headers}\nResponse data: {request.data}\nError: Signature validation failed.")
print("attempting to validate sns failed")
raise InvalidRequest("SES-SNS callback failed: validation failed", 400)
except Exception as e:
current_app.logger.exception(f"SES-SNS callback failed: validation failed! Response headers: {request.headers}\nResponse data: {request.data}\nError: {e}")
raise InvalidRequest("SES-SNS callback failed: validation failed", 400) raise InvalidRequest("SES-SNS callback failed: validation failed", 400)
if message.get('Type') == 'SubscriptionConfirmation': if message.get('Type') == 'SubscriptionConfirmation':
url = message.get('SubscribeURL') print("processing subscription")
url = message.get('SubscribeUrl') if 'SubscribeUrl' in message else message.get('SubscribeURL')
response = requests.get(url) response = requests.get(url)
try: try:
response.raise_for_status() response.raise_for_status()
except Exception as e: except Exception as e:
current_app.logger.warning("Response: {}".format(response.text)) current_app.logger.warning(f"Attempt to raise_for_status()SubscriptionConfirmation Type message files for response: {response.text} with error {e}")
raise e raise e
return jsonify( return jsonify(
result="success", message="SES-SNS auto-confirm callback succeeded" result="success", message="SES-SNS auto-confirm callback succeeded"
), 200 ), 200
print("info logging")
# TODO remove after smoke testing on prod is implemented
current_app.logger.info(f"SNS message: {message} is a valid delivery status message. Attempting to process it now.")
print("running process_ses_results")
process_ses_results.apply_async([{"Message": message.get("Message")}], queue=QueueNames.NOTIFY) process_ses_results.apply_async([{"Message": message.get("Message")}], queue=QueueNames.NOTIFY)
print("returning success")
return jsonify( return jsonify(
result="success", message="SES-SNS callback succeeded" result="success", message="SES-SNS callback succeeded"
), 200 ), 200
@@ -101,10 +109,12 @@ def process_ses_results(self, response):
try: try:
ses_message = json.loads(response["Message"]) ses_message = json.loads(response["Message"])
notification_type = ses_message["notificationType"] notification_type = ses_message["notificationType"]
# TODO remove after smoke testing on prod is implemented
current_app.logger.info(f"Attempting to process SES delivery status message from SNS with type: {notification_type} and body: {ses_message}")
bounce_message = None bounce_message = None
if notification_type == 'Bounce': if notification_type == 'Bounce':
bounce_message = _determine_notification_bounce_type(ses_message) bounce_message = determine_notification_bounce_type(ses_message)
elif notification_type == 'Complaint': elif notification_type == 'Complaint':
_check_and_queue_complaint_callback_task(*handle_complaint(ses_message)) _check_and_queue_complaint_callback_task(*handle_complaint(ses_message))
return True return True

113
app/celery/validate_sns.py Normal file
View File

@@ -0,0 +1,113 @@
import base64
import re
from urllib.parse import urlparse
import requests
from M2Crypto import X509
from app import redis_store
from app.config import Config
USE_CACHE = True
VALIDATE_ARN = True
_signing_cert_cache = {}
_cert_url_re = re.compile(
r'sns\.([a-z]{1,3}-[a-z]+-[0-9]{1,2})\.amazonaws\.com',
)
VALID_SNS_TOPICS = Config.VALID_SNS_TOPICS
# VALID_SNS_TOPICS = ['my_bounce_topic_name', 'my_success_topic_name', 'my_complaint_topic_name']
def get_certificate(url):
if USE_CACHE:
res = redis_store.get(url)
if res is not None:
return res
res = requests.get(url).text
redis_store.set(url, res, ex=60 * 60) # 60 minutes
return res
else:
return requests.get(url).text
def valid_sns_message(sns_payload):
"""
Adapted from the solution posted at
https://github.com/boto/boto3/issues/2508#issuecomment-992931814
"""
if not isinstance(sns_payload, dict):
return False
# Amazon SNS currently supports signature version 1.
if sns_payload.get('SignatureVersion') != '1':
return False
if VALIDATE_ARN:
arn = sns_payload.get('TopicArn')
topic_name = arn.split(':')[5]
if topic_name not in VALID_SNS_TOPICS:
return False
payload_type = sns_payload.get('Type')
if payload_type in ['SubscriptionConfirmation', 'UnsubscribeConfirmation']:
fields = ['Message', 'MessageId', 'SubscribeURL', 'Timestamp', 'Token', 'TopicArn', 'Type']
elif payload_type == 'Notification':
fields = ['Message', 'MessageId', 'Subject', 'Timestamp', 'TopicArn', 'Type']
else:
return False
# Build the string to be signed.
string_to_sign = ''
for field in fields:
field_value = sns_payload.get(field)
if not isinstance(field_value, str):
return False
string_to_sign += field + '\n' + field_value + '\n'
# Get the signature
try:
decoded_signature = base64.b64decode(sns_payload.get('Signature'))
except (TypeError, ValueError):
return False
# Key signing cert url via Lambda and via webhook are slightly different
signing_cert_url = sns_payload.get('SigningCertUrl') if 'SigningCertUrl' in sns_payload else sns_payload.get('SigningCertURL')
if not isinstance(signing_cert_url, str):
return False
cert_scheme, cert_netloc, *_ = urlparse(signing_cert_url)
if cert_scheme != 'https' or not re.match(_cert_url_re, cert_netloc):
# The cert doesn't seem to be from AWS
return False
certificate = _signing_cert_cache.get(signing_cert_url)
if certificate is None:
certificate = X509.load_cert_string(get_certificate(signing_cert_url))
_signing_cert_cache[signing_cert_url] = certificate
if certificate.get_subject().as_text() != 'CN=sns.amazonaws.com':
return False
# Extract the public key.
public_key = certificate.get_pubkey()
# Amazon SNS uses SHA1withRSA.
# http://sns-public-resources.s3.amazonaws.com/SNS_Message_Signing_Release_Note_Jan_25_2011.pdf
public_key.reset_context(md='sha1')
public_key.verify_init()
# Sign the string.
public_key.verify_update(string_to_sign.encode())
# Verify the signature matches.
verification_result = public_key.verify_final(decoded_signature)
# M2Crypto uses EVP_VerifyFinal() from openssl as the underlying
# verification function. 1 indicates success, anything else is either
# a failure or an error.
if verification_result != 1:
return False
return True

View File

@@ -120,6 +120,9 @@ class Config(object):
# Use notify.sandbox.10x sending domain unless overwritten by environment # Use notify.sandbox.10x sending domain unless overwritten by environment
NOTIFY_EMAIL_DOMAIN = 'notify.sandbox.10x.gsa.gov' NOTIFY_EMAIL_DOMAIN = 'notify.sandbox.10x.gsa.gov'
# AWS SNS topics for delivery receipts
VALID_SNS_TOPICS = ['notify_test_bounce', 'notify_test_success', 'notify_test_complaint']
# URL of redis instance # URL of redis instance
REDIS_URL = os.environ.get('REDIS_URL') REDIS_URL = os.environ.get('REDIS_URL')
REDIS_ENABLED = os.environ.get('REDIS_ENABLED') REDIS_ENABLED = os.environ.get('REDIS_ENABLED')

View File

@@ -17,7 +17,7 @@ from app.models import Complaint
from app.notifications.callbacks import create_complaint_callback_data from app.notifications.callbacks import create_complaint_callback_data
def _determine_notification_bounce_type(ses_message): def determine_notification_bounce_type(ses_message):
notification_type = ses_message["notificationType"] notification_type = ses_message["notificationType"]
if notification_type in ["Delivery", "Complaint"]: if notification_type in ["Delivery", "Complaint"]:
return notification_type return notification_type
@@ -51,7 +51,7 @@ def _determine_provider_response(ses_message):
def get_aws_responses(ses_message): def get_aws_responses(ses_message):
status = _determine_notification_bounce_type(ses_message) status = determine_notification_bounce_type(ses_message)
base = { base = {
"Permanent": { "Permanent": {

View File

@@ -21,6 +21,7 @@ RUN apt-get update \
openssh-client \ openssh-client \
procps \ procps \
sudo \ sudo \
swig \
tldr \ tldr \
unzip \ unzip \
vim \ vim \

View File

@@ -16,10 +16,10 @@ itsdangerous==2.1.2
jsonschema[format]==4.5.1 jsonschema[format]==4.5.1
marshmallow-sqlalchemy==0.28.1 marshmallow-sqlalchemy==0.28.1
marshmallow==3.15.0 marshmallow==3.15.0
M2Crypto==0.38.0
psycopg2-binary==2.9.3 psycopg2-binary==2.9.3
PyJWT==2.4.0 PyJWT==2.4.0
SQLAlchemy==1.4.40 SQLAlchemy==1.4.40
validatesns==0.1.1
cachetools==5.1.0 cachetools==5.1.0
beautifulsoup4==4.11.1 beautifulsoup4==4.11.1
lxml==4.9.1 lxml==4.9.1

View File

@@ -10,8 +10,6 @@ amqp==5.1.1
# via kombu # via kombu
arrow==1.2.2 arrow==1.2.2
# via isoduration # via isoduration
asn1crypto==1.5.1
# via oscrypto
async-timeout==4.0.2 async-timeout==4.0.2
# via redis # via redis
attrs==21.4.0 attrs==21.4.0
@@ -150,6 +148,8 @@ kombu==5.2.4
# via celery # via celery
lxml==4.9.1 lxml==4.9.1
# via -r requirements.in # via -r requirements.in
m2crypto==0.38.0
# via -r requirements.in
mako==1.2.0 mako==1.2.0
# via alembic # via alembic
markupsafe==2.1.1 markupsafe==2.1.1
@@ -171,8 +171,6 @@ notifications-utils @ git+https://github.com/GSA/notifications-utils.git
# via -r requirements.in # via -r requirements.in
orderedset==2.0.3 orderedset==2.0.3
# via notifications-utils # via notifications-utils
oscrypto==1.3.0
# via validatesns
packaging==21.3 packaging==21.3
# via # via
# bleach # bleach
@@ -251,7 +249,6 @@ six==1.16.0
# flask-marshmallow # flask-marshmallow
# python-dateutil # python-dateutil
# rfc3339-validator # rfc3339-validator
# validatesns
smartypants==2.0.1 smartypants==2.0.1
# via notifications-utils # via notifications-utils
soupsieve==2.3.2.post1 soupsieve==2.3.2.post1
@@ -272,8 +269,6 @@ urllib3==1.26.9
# via # via
# botocore # botocore
# requests # requests
validatesns==0.1.1
# via -r requirements.in
vine==5.0.0 vine==5.0.0
# via # via
# amqp # amqp

View File

@@ -71,7 +71,7 @@ def test_notifications_ses_400_with_certificate(client):
def test_notifications_ses_200_autoconfirms_subscription(client, mocker): def test_notifications_ses_200_autoconfirms_subscription(client, mocker):
mocker.patch("validatesns.validate") mocker.patch("app.celery.process_ses_receipts_tasks.valid_sns_message", return_value=True)
requests_mock = mocker.patch("requests.get") requests_mock = mocker.patch("requests.get")
data = json.dumps({"Type": "SubscriptionConfirmation", "SubscribeURL": "https://foo"}) data = json.dumps({"Type": "SubscriptionConfirmation", "SubscribeURL": "https://foo"})
response = client.post( response = client.post(
@@ -85,7 +85,7 @@ def test_notifications_ses_200_autoconfirms_subscription(client, mocker):
def test_notifications_ses_200_call_process_task(client, mocker): def test_notifications_ses_200_call_process_task(client, mocker):
mocker.patch("validatesns.validate") mocker.patch("app.celery.process_ses_receipts_tasks.valid_sns_message", return_value=True)
process_mock = mocker.patch("app.celery.process_ses_receipts_tasks.process_ses_results.apply_async") process_mock = mocker.patch("app.celery.process_ses_receipts_tasks.process_ses_results.apply_async")
data = {"Type": "Notification", "foo": "bar"} data = {"Type": "Notification", "foo": "bar"}
json_data = json.dumps(data) json_data = json.dumps(data)