Skip to content

Commit 1767cbf

Browse files
committed
Update eventbridge-webhooks/stripe and twilio
1 parent 0e313f7 commit 1767cbf

File tree

7 files changed

+406
-16
lines changed

7 files changed

+406
-16
lines changed
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""Webhook implementation for Stripe"""
2+
3+
import os
4+
import json
5+
from datetime import datetime, timedelta
6+
import base64
7+
import hmac
8+
import hashlib
9+
from cgi import parse_header
10+
import boto3
11+
import botocore
12+
import botocore.session
13+
from aws_secretsmanager_caching import SecretCache, SecretCacheConfig
14+
15+
client = botocore.session.get_session().create_client('secretsmanager')
16+
cache_config = SecretCacheConfig()
17+
cache = SecretCache(config=cache_config, client=client)
18+
19+
stripe_webhook_secret_arn = os.environ.get('STRIPE_WEBHOOK_SECRET_ARN')
20+
event_bus_name = os.environ.get('EVENT_BUS_NAME', 'default')
21+
22+
event_bridge_client = boto3.client('events')
23+
24+
def _add_header(request, **kwargs):
25+
userAgentHeader = request.headers['User-Agent'] + ' fURLWebhook/1.0 (Stripe)'
26+
del request.headers['User-Agent']
27+
request.headers['User-Agent'] = userAgentHeader
28+
29+
event_system = event_bridge_client.meta.events
30+
event_system.register_first('before-sign.events.PutEvents', _add_header)
31+
32+
class PutEventError(Exception):
33+
"""Raised when Put Events Failed"""
34+
pass
35+
36+
def lambda_handler(event, _context):
37+
"""Webhook function"""
38+
39+
headers = event.get('headers')
40+
41+
# Input validation
42+
try:
43+
json_payload = get_json_payload(event=event)
44+
except ValueError as err:
45+
print_error(f'400 Bad Request - {err}', headers)
46+
return {'statusCode': 400, 'body': str(err)}
47+
except BaseException as err: # Unexpected Error
48+
print_error('500 Internal Server Error\n' +
49+
f'Unexpected error: {err}, {type(err)}', headers)
50+
return {'statusCode': 500, 'body': 'Internal Server Error'}
51+
52+
try:
53+
54+
timestamp, signatures = parse_signature(
55+
signature_header=headers.get('stripe-signature'))
56+
57+
if not timestamp or not timestamp_is_valid(timestamp):
58+
print_error('400 Bad Request - Invalid timestamp', headers)
59+
return {
60+
'statusCode': 400,
61+
'body': 'Invalid timestamp'
62+
}
63+
64+
if not contains_valid_signature(
65+
payload=json_payload,
66+
timestamp=timestamp,
67+
signatures=signatures):
68+
print_error('401 Unauthorized - Invalid Signature', headers)
69+
return {'statusCode': 401, 'body': 'Invalid Signature'}
70+
71+
json_format = json.loads(json_payload)
72+
detail_type = json_format.get('type', 'stripe-webhook-lambda')
73+
74+
response = forward_event(json_payload, detail_type)
75+
76+
if response['FailedEntryCount'] > 0:
77+
print_error('500 FailedEntry Error - The event was not successfully forwarded to Amazon EventBridge\n' +
78+
str(response['Entries'][0]), headers)
79+
return {'statusCode': 500, 'body': 'FailedEntry Error - The entry could not be succesfully forwarded to Amazon EventBridge'}
80+
81+
return {'statusCode': 202, 'body': 'Message forwarded to Amazon EventBridge'}
82+
83+
except PutEventError as err:
84+
print_error(f'500 Put Events Error - {err}', headers)
85+
return {'statusCode': 500, 'body': 'Internal Server Error - The request was rejected by Amazon EventBridge API'}
86+
87+
except BaseException as err: # Unexpected Error
88+
print_error('500 Client Error\n' +
89+
f'Unexpected error: {err}, {type(err)}', headers)
90+
return {'statusCode': 500, 'body': 'Internal Server Error'}
91+
92+
93+
def get_json_payload(event):
94+
"""Get JSON string from payload"""
95+
content_type = get_content_type(event.get('headers', {}))
96+
if content_type != 'application/json':
97+
raise ValueError('Unsupported content-type')
98+
99+
payload = normalize_payload(
100+
raw_payload=event.get('body'),
101+
is_base64_encoded=event['isBase64Encoded'])
102+
103+
try:
104+
json.loads(payload)
105+
106+
except ValueError as err:
107+
raise ValueError('Invalid JSON payload') from err
108+
109+
return payload
110+
111+
112+
def normalize_payload(raw_payload, is_base64_encoded):
113+
"""Decode payload if needed"""
114+
if raw_payload is None:
115+
raise ValueError('Missing event body')
116+
if is_base64_encoded:
117+
return base64.b64decode(raw_payload).decode('utf-8')
118+
return raw_payload
119+
120+
121+
def contains_valid_signature(payload, timestamp, signatures):
122+
"""Check for the payload signature
123+
Stripe documentation: https://stripe.com/docs/webhooks/signatures
124+
"""
125+
secret = cache.get_secret_string(stripe_webhook_secret_arn)
126+
payload_bytes = get_payload_bytes(
127+
timestamp=timestamp,
128+
payload=payload
129+
)
130+
computed_signature = compute_signature(
131+
payload_bytes=payload_bytes, secret=secret)
132+
return any(
133+
hmac.compare_digest(event_signature, computed_signature)
134+
for event_signature in signatures
135+
)
136+
137+
138+
def get_payload_bytes(timestamp, payload):
139+
"""Get payload bytes to feed hash function"""
140+
return (timestamp + "." + payload).encode()
141+
142+
143+
def compute_signature(payload_bytes, secret):
144+
"""Compute HMAC-SHA256"""
145+
return hmac.new(key=secret.encode(), msg=payload_bytes, digestmod=hashlib.sha256).hexdigest()
146+
147+
148+
def parse_signature(signature_header):
149+
"""
150+
Parse signature from hearders based on:
151+
https://stripe.com/docs/webhooks/signatures#prepare-payload
152+
"""
153+
if not signature_header:
154+
return None, None
155+
156+
header_elements = signature_header.split(',')
157+
timestamp, signatures = None, []
158+
159+
for element in header_elements:
160+
[k, v] = element.split('=')
161+
if k == 't':
162+
timestamp = v
163+
# Stripe will send all valid signatures as a v1=<signature>
164+
if k == 'v1':
165+
signatures.append(v)
166+
167+
return timestamp, signatures
168+
169+
170+
def timestamp_is_valid(timestamp):
171+
"""Check whether incoming timestamp is not too old (<5min old)"""
172+
current_time = datetime.today()
173+
stripe_timestamp = datetime.fromtimestamp(int(timestamp))
174+
175+
diff = current_time - stripe_timestamp
176+
177+
# Time diff is less than 5 minutes
178+
return diff < timedelta(minutes=5)
179+
180+
181+
def forward_event(payload, detail_type):
182+
"""Forward event to EventBridge"""
183+
try:
184+
return event_bridge_client.put_events(
185+
Entries=[
186+
{
187+
'Source': 'stripe.com',
188+
'DetailType': detail_type,
189+
'Detail': payload,
190+
'EventBusName': event_bus_name
191+
},
192+
]
193+
)
194+
except BaseException as err:
195+
raise PutEventError('Put Events Failed')
196+
197+
def get_content_type(headers):
198+
"""Helper function to parse content-type from the header"""
199+
raw_content_type = headers.get('content-type')
200+
201+
if raw_content_type is None:
202+
return None
203+
content_type, _ = parse_header(raw_content_type)
204+
return content_type
205+
206+
207+
def print_error(message, headers):
208+
"""Helper function to print errors"""
209+
print(f'ERROR: {message}\nHeaders: {str(headers)}')
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
boto3

eventbridge-webhooks/1-stripe/template.yml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,9 @@ Resources:
5454
"InboundWebhook-Lambda-${ID}",
5555
ID: !Select [2, !Split ["/", !Ref AWS::StackId]],
5656
] # Append the stack UUID
57-
CodeUri:
58-
Bucket: !Sub 'eventbridge-inbound-webhook-templates-prod-${AWS::Region}'
59-
Key: 'lambda-templates/stripe-lambdasrc.zip'
57+
CodeUri: ./src
6058
Handler: app.lambda_handler
61-
Runtime: python3.8
59+
Runtime: python3.13
6260
ReservedConcurrentExecutions: 10
6361
Environment:
6462
Variables:

0 commit comments

Comments
 (0)