diff --git a/.env.example b/.env.example index f043be1..4a76963 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,18 @@ BRICKSET_API_KEY=your_brickset_api_key # Generate with: python convert_to_p7b.py SQUARE_P7B_PATH=./cert.p7b +# ============================================================================ +# Square Webhook Signature Verification +# ============================================================================ + +# CRITICAL: Get these from Square Developer Dashboard → Webhooks +# Signature key used to verify webhook authenticity (prevents spoofing) +SQUARE_WEBHOOK_SIGNATURE_KEY=your_webhook_signature_key_from_square + +# Your webhook notification URL (must match exactly what's in Square Dashboard) +# Example: https://studspider.sh/hook +SQUARE_WEBHOOK_URL=https://yourdomain.com/hook + # ============================================================================ # Server Configuration (Optional) # ============================================================================ diff --git a/.gitignore b/.gitignore index 75c2324..3479eff 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,6 @@ win/ .github/ bin/ activate/ -build/ \ No newline at end of file +build/ +.pem +.p7b diff --git a/src/test_server.py b/src/test_server.py index f8523bf..cf8cb08 100644 --- a/src/test_server.py +++ b/src/test_server.py @@ -21,6 +21,8 @@ from time import time time = int(time()) import asquare as sq +import hmac +import hashlib # from asquareclass import AsyncSquareClient # type: ignore from abricksetapi import AsyncBricksetAPI @@ -28,6 +30,8 @@ from abricksetapi import AsyncBricksetAPI print("Loading environment variables...") dotenv.load_dotenv() SQUARE_LEGO_CAT_ID = os.environ.get("SQUARE_LEGO_CAT_ID") +SQUARE_WEBHOOK_SIGNATURE_KEY = os.environ.get("SQUARE_WEBHOOK_SIGNATURE_KEY") +SQUARE_WEBHOOK_URL = os.environ.get("SQUARE_WEBHOOK_URL") from asyncio_throttle.throttler import Throttler @@ -82,6 +86,51 @@ def valid_uuid(val): return False +# In-memory store for processed event IDs (idempotency) +processed_event_ids = set() +MAX_STORED_EVENT_IDS = 10000 # Prevent memory growth + + +def verify_square_webhook_signature( + signature_header: str, body: bytes, signature_key: str, webhook_url: str +) -> bool: + """ + Verify Square webhook signature using HMAC-SHA-256 + + Args: + signature_header: Value from x-square-hmacsha256-signature header + body: Raw request body bytes + signature_key: Your webhook signature key from Square Dashboard + webhook_url: Your webhook notification URL + + Returns: + bool: True if signature is valid + """ + if not signature_key or not webhook_url: + logger.error("Square webhook signature key or URL not configured in .env") + return False + + # Concatenate webhook URL + request body + payload = webhook_url.encode("utf-8") + body + + # Generate HMAC-SHA-256 signature + computed_signature = hmac.new( + signature_key.encode("utf-8"), payload, hashlib.sha256 + ).digest() + + # Decode the signature from the header (base64) + import base64 + + try: + expected_signature = base64.b64decode(signature_header) + except Exception as e: + logger.error(f"Failed to decode signature header: {e}") + return False + + # Compare signatures using constant-time comparison + return hmac.compare_digest(computed_signature, expected_signature) + + async def hook_handler(request: web.Request) -> web.Response: """Handle incoming JSON webhook POSTs on /hook endpoint""" try: @@ -95,14 +144,65 @@ async def hook_handler(request: web.Request) -> web.Response: ) return web.Response(status=405, text="Method not allowed. Use POST.") - # Get JSON data from request + # Read raw body for signature verification + body = await request.read() + + # Verify Square webhook signature + signature_header = request.headers.get("x-square-hmacsha256-signature") + if SQUARE_WEBHOOK_SIGNATURE_KEY and SQUARE_WEBHOOK_URL: + if not signature_header: + logger.error( + f"{current_time}\tMissing signature header from {request.remote}" + ) + print(f"[@DEBUG] Missing x-square-hmacsha256-signature header") + return web.Response(status=401, text="Unauthorized: Missing signature") + + if not verify_square_webhook_signature( + signature_header, body, SQUARE_WEBHOOK_SIGNATURE_KEY, SQUARE_WEBHOOK_URL + ): + logger.error(f"{current_time}\tInvalid signature from {request.remote}") + print(f"[@DEBUG] Invalid webhook signature!") + return web.Response(status=401, text="Unauthorized: Invalid signature") + + print(f"[@DEBUG] ✓ Webhook signature verified") + else: + print( + f"[@DEBUG] ⚠️ Webhook signature verification DISABLED (set SQUARE_WEBHOOK_SIGNATURE_KEY and SQUARE_WEBHOOK_URL in .env)" + ) + + # Parse JSON data try: - data = await request.json() + data = json.loads(body.decode("utf-8")) except json.JSONDecodeError as e: print(f"[@DEBUG] Invalid JSON to /hook from {request.remote}: {str(e)}") logger.error(f"Invalid JSON to /hook from {request.remote}: {str(e)}") return web.Response(status=400, text="Invalid JSON in request body") + # Check for idempotency (event_id) + event_id = data.get("event_id") + if event_id: + if event_id in processed_event_ids: + print( + f"[@DEBUG] Duplicate event {event_id} - already processed (idempotency)" + ) + logger.info( + f"{current_time}\tDuplicate event {event_id} from {request.remote}" + ) + return web.json_response( + { + "status": "success", + "message": "Event already processed (idempotency)", + "event_id": event_id, + } + ) + + # Add to processed set + processed_event_ids.add(event_id) + + # Prevent memory growth - remove oldest if too many + if len(processed_event_ids) > MAX_STORED_EVENT_IDS: + processed_event_ids.pop() + # Log the received webhook with detailed output print(f"\n{'='*60}") print(f"[@DEBUG] WEBHOOK RECEIVED at {current_time}")