diff --git a/app/routes/teller.py b/app/routes/teller.py index 0cb95c8..efa84ee 100644 --- a/app/routes/teller.py +++ b/app/routes/teller.py @@ -271,12 +271,48 @@ def webhook(): Does NOT auto-import — user must confirm via the UI. """ # Verify Teller webhook signature + # Header format: Teller-Signature: t=,v1=,v1= + # Signed message: . signing_secret = current_app.config.get('TELLER_WEBHOOK_SECRET', '') if signing_secret: sig_header = request.headers.get('Teller-Signature', '') body = request.get_data() - expected = hmac.new(signing_secret.encode(), body, hashlib.sha256).hexdigest() - if not hmac.compare_digest(f'sha256={expected}', sig_header): + + if not sig_header: + log.warning('[teller] missing Teller-Signature header') + return jsonify({'error': 'Missing signature'}), 401 + + # Parse header: t=,v1=,v1=... + parts = dict( + (p.split('=', 1) if '=' in p else (p, '')) + for p in sig_header.split(',') + ) + timestamp = parts.get('t', '') + # Collect all v1 signatures (may be multiple during key rotation) + signatures = [v for k, v in + [p.split('=', 1) for p in sig_header.split(',') if p.startswith('v1=')] + ] + + if not timestamp or not signatures: + log.warning('[teller] malformed Teller-Signature header') + return jsonify({'error': 'Invalid signature'}), 401 + + # Reject replays older than 5 minutes + import time + try: + if abs(time.time() - int(timestamp)) > 300: + log.warning('[teller] webhook replay attack detected') + return jsonify({'error': 'Timestamp too old'}), 401 + except ValueError: + pass + + # signed_message = timestamp + "." + raw_body + signed_message = f'{timestamp}.'.encode() + body + expected = hmac.new( + signing_secret.encode(), signed_message, hashlib.sha256 + ).hexdigest() + + if not any(hmac.compare_digest(expected, sig) for sig in signatures): log.warning('[teller] webhook signature mismatch') return jsonify({'error': 'Invalid signature'}), 401