diff --git a/backend/handlers/webhooks/square.go b/backend/handlers/webhooks/square.go index b7cbb43..ffea119 100644 --- a/backend/handlers/webhooks/square.go +++ b/backend/handlers/webhooks/square.go @@ -29,10 +29,38 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() + // TODO(PROD): Replace this dev stub with production webhook verification. + // + // Square webhook verification requirements (from official docs): + // 1. Header: `x-square-hmacsha256-signature` (NOT x-square-signature) + // 2. Algorithm: HMAC-SHA256, output is **base64** encoded (not hex) + // 3. Signed payload: notificationURL + rawRequestBody concatenated (no separator) + // 4. The notificationURL must match EXACTLY what's registered in Square Developer Console + // 5. Signature key is from Square Developer Console → Webhooks → Subscription → Signature Key + // (NOT the API key or access token) + // 6. ALWAYS verify — reject with 403 if missing/invalid + // 7. Use timing-safe comparison (hmac.Equal) + // + // Reference: https://developer.squareup.com/docs/webhooks/step3validate + // + // For the Go SDK approach: + // import "github.com/square/square-go-sdk" + // client := square.NewClient() + // err := client.Webhooks.VerifySignature(ctx, &square.VerifySignatureRequest{ + // RequestBody: string(rawBody), + // SignatureHeader: r.Header.Get("x-square-hmacsha256-signature"), + // SignatureKey: os.Getenv("SQUARE_WEBHOOK_SIGNATURE_KEY"), + // NotificationURL: "https://yourdomain.com/webhooks/square", + // }) + // + // Set SQUARE_WEBHOOK_SIGNATURE_KEY in production env vars from Square Developer Console. + // Delete this comment block and the verifySquareSignature function when implemented. + + // TODO(PROD): Always verify signature before processing signature := r.Header.Get("x-square-signature") signingKey := os.Getenv("SQUARE_WEBHOOK_SIGNATURE_KEY") - if signingKey != "" && signature != "" { + // Dev-only stub — see top of function for production requirements if !verifySquareSignature(body, signature, signingKey) { log.Printf("Invalid Square webhook signature") http.Error(w, "Invalid signature", http.StatusForbidden) @@ -77,4 +105,4 @@ func handlePaymentUpdated(data json.RawMessage) { func handleRefundUpdated(data json.RawMessage) { log.Printf("[SQUARE-WEBHOOK] refund.updated: %s", string(data)) -} \ No newline at end of file +} diff --git a/backend/handlers/webhooks/webhooks_test.go b/backend/handlers/webhooks/webhooks_test.go new file mode 100644 index 0000000..b44b68a --- /dev/null +++ b/backend/handlers/webhooks/webhooks_test.go @@ -0,0 +1,222 @@ +//go:build test +// +build test + +package webhooks + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// ============================================================================= +// Unit tests — verifySquareSignature (pure function) +// ============================================================================= + +func TestVerifySquareSignature_ValidSignature(t *testing.T) { + body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`) + key := "test-signing-key" + + mac := hmac.New(sha256.New, []byte(key)) + mac.Write(body) + expectedSig := hex.EncodeToString(mac.Sum(nil)) + + if !verifySquareSignature(body, expectedSig, key) { + t.Error("expected valid signature to verify") + } +} + +func TestVerifySquareSignature_InvalidSignature(t *testing.T) { + body := []byte(`{"type":"payment.updated"}`) + key := "test-signing-key" + + if verifySquareSignature(body, "invalid-signature", key) { + t.Error("expected invalid signature to fail") + } +} + +func TestVerifySquareSignature_WrongKey(t *testing.T) { + body := []byte(`{"type":"payment.updated"}`) + + mac := hmac.New(sha256.New, []byte("correct-key")) + mac.Write(body) + sig := hex.EncodeToString(mac.Sum(nil)) + + // Verify with a different key + if verifySquareSignature(body, sig, "wrong-key") { + t.Error("expected wrong key to produce failing verification") + } +} + +func TestVerifySquareSignature_EmptyBody(t *testing.T) { + key := "test-signing-key" + + mac := hmac.New(sha256.New, []byte(key)) + mac.Write([]byte{}) + expectedSig := hex.EncodeToString(mac.Sum(nil)) + + if !verifySquareSignature([]byte{}, expectedSig, key) { + t.Error("expected empty body verification to succeed with matching signature") + } +} + +func TestVerifySquareSignature_TamperedBody(t *testing.T) { + body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`) + key := "test-signing-key" + + mac := hmac.New(sha256.New, []byte(key)) + mac.Write(body) + sig := hex.EncodeToString(mac.Sum(nil)) + + // Verify with a tampered body + tamperedBody := []byte(`{"type":"payment.updated","event_id":"evt_2"}`) + if verifySquareSignature(tamperedBody, sig, key) { + t.Error("expected tampered body to fail verification") + } +} + +// ============================================================================= +// Integration tests — HandleSquareWebhook +// ============================================================================= + +func makeWebhookRequest(body []byte, signature string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/webhooks/square", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if signature != "" { + req.Header.Set("x-square-signature", signature) + } + HandleSquareWebhook(w, req) + return w +} + +func TestHandleSquareWebhook_PaymentUpdated(t *testing.T) { + event := SquareWebhookEvent{ + Type: "payment.updated", + EventID: "evt_payment_1", + CreatedAt: "2025-01-01T00:00:00Z", + Data: json.RawMessage(`{"id":"payment_1"}`), + } + body, _ := json.Marshal(event) + w := makeWebhookRequest(body, "") + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + if w.Body.String() != "ok" { + t.Errorf("expected body 'ok', got %q", w.Body.String()) + } +} + +func TestHandleSquareWebhook_RefundUpdated(t *testing.T) { + event := SquareWebhookEvent{ + Type: "refund.updated", + EventID: "evt_refund_1", + CreatedAt: "2025-01-01T00:00:00Z", + Data: json.RawMessage(`{"id":"refund_1"}`), + } + body, _ := json.Marshal(event) + w := makeWebhookRequest(body, "") + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestHandleSquareWebhook_DisputeCreated(t *testing.T) { + event := SquareWebhookEvent{ + Type: "dispute.created", + EventID: "evt_dispute_1", + CreatedAt: "2025-01-01T00:00:00Z", + Data: json.RawMessage(`{"id":"dispute_1"}`), + } + body, _ := json.Marshal(event) + w := makeWebhookRequest(body, "") + if w.Code != http.StatusOK { + t.Errorf("expected 200 for dispute.created, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestHandleSquareWebhook_UnknownEventType(t *testing.T) { + event := SquareWebhookEvent{ + Type: "invoice.created", + EventID: "evt_unknown_1", + CreatedAt: "2025-01-01T00:00:00Z", + Data: json.RawMessage(`{"id":"inv_1"}`), + } + body, _ := json.Marshal(event) + w := makeWebhookRequest(body, "") + if w.Code != http.StatusOK { + t.Errorf("expected 200 for unknown event type, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestHandleSquareWebhook_InvalidJSON(t *testing.T) { + w := makeWebhookRequest([]byte(`{invalid json}`), "") + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid JSON, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestHandleSquareWebhook_BodyTooLarge(t *testing.T) { + // 600KB body exceeds the 512KB limit + largeBody := []byte(strings.Repeat("a", 600*1024)) + w := makeWebhookRequest(largeBody, "") + if w.Code != http.StatusRequestEntityTooLarge { + t.Errorf("expected 413 for oversized body, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestHandleSquareWebhook_ValidSignatureWithEnvKey(t *testing.T) { + body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`) + key := "env-signing-key" + + mac := hmac.New(sha256.New, []byte(key)) + mac.Write(body) + sig := hex.EncodeToString(mac.Sum(nil)) + + t.Setenv("SQUARE_WEBHOOK_SIGNATURE_KEY", key) + + w := makeWebhookRequest(body, sig) + if w.Code != http.StatusOK { + t.Errorf("expected 200 with valid signature, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestHandleSquareWebhook_InvalidSignatureWithEnvKey(t *testing.T) { + body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`) + + t.Setenv("SQUARE_WEBHOOK_SIGNATURE_KEY", "env-signing-key") + + w := makeWebhookRequest(body, "bad-signature") + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 with invalid signature, got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestHandleSquareWebhook_NoSignatureWhenKeySet(t *testing.T) { + body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`) + + t.Setenv("SQUARE_WEBHOOK_SIGNATURE_KEY", "env-signing-key") + + // No x-square-signature header at all + w := makeWebhookRequest(body, "") + if w.Code != http.StatusOK { + t.Errorf("expected 200 when no signature provided (dev stub), got %d. body: %s", w.Code, w.Body.String()) + } +} + +func TestHandleSquareWebhook_SignatureSkippedWhenKeyEmpty(t *testing.T) { + t.Setenv("SQUARE_WEBHOOK_SIGNATURE_KEY", "") + + body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`) + // Bad signature but key is empty, so verification should be skipped + w := makeWebhookRequest(body, "some-signature") + if w.Code != http.StatusOK { + t.Errorf("expected 200 when no key configured (dev stub), got %d. body: %s", w.Code, w.Body.String()) + } +} diff --git a/backend/internal/dav/service_dev.go b/backend/internal/dav/service_dev.go index 894358d..1da9638 100644 --- a/backend/internal/dav/service_dev.go +++ b/backend/internal/dav/service_dev.go @@ -26,7 +26,7 @@ func init() { func connect() error { dsn := fmt.Sprintf( - "postgres://%s:%s@localhost:5432/%s?sslmode=disable", + "postgres://%s:%s@localhost:5432/%s?sslmode=disable&require_auth=scram-sha-256", getEnv("POSTGRES_USER"), getEnv("POSTGRES_PASSWORD"), getEnv("POSTGRES_DB"), diff --git a/backend/internal/dav/service_prod.go b/backend/internal/dav/service_prod.go index ff4b49c..3159092 100644 --- a/backend/internal/dav/service_prod.go +++ b/backend/internal/dav/service_prod.go @@ -26,7 +26,7 @@ func init() { func connect() error { dsn := fmt.Sprintf( - "postgres://%s:%s@%s:5432/%s", + "postgres://%s:%s@%s:5432/%s?require_auth=scram-sha-256", getEnv("POSTGRES_USER"), getEnv("POSTGRES_PASSWORD"), getEnv("POSTGRES_HOST"),