diff --git a/backend/handlers/webhooks/square.go b/backend/handlers/webhooks/square.go index 4b7858b..4dfe2cd 100644 --- a/backend/handlers/webhooks/square.go +++ b/backend/handlers/webhooks/square.go @@ -145,10 +145,26 @@ func verifySquareSignature(body []byte, signature, signingKey, notificationURL s return hmac.Equal([]byte(signature), []byte(expected)) } +// handlePaymentUpdated logs only the Square object id — never the raw payload, +// which contains PII (buyer email, card brand/last4, cardholder name, billing +// address, amounts). The envelope's event_id is logged at the dispatch site. +// On unmarshal failure log just the byte length (no content). func handlePaymentUpdated(data json.RawMessage) { - log.Printf("[SQUARE-WEBHOOK] payment.updated: %s", string(data)) + var obj struct{ ID string `json:"id"` } + if err := json.Unmarshal(data, &obj); err != nil { + log.Printf("[SQUARE-WEBHOOK] payment.updated received (payload length=%d)", len(data)) + return + } + log.Printf("[SQUARE-WEBHOOK] payment.updated received (data.id=%s)", obj.ID) } +// handleRefundUpdated logs only the Square object id — never the raw payload, +// which contains PII. See handlePaymentUpdated. func handleRefundUpdated(data json.RawMessage) { - log.Printf("[SQUARE-WEBHOOK] refund.updated: %s", string(data)) + var obj struct{ ID string `json:"id"` } + if err := json.Unmarshal(data, &obj); err != nil { + log.Printf("[SQUARE-WEBHOOK] refund.updated received (payload length=%d)", len(data)) + return + } + log.Printf("[SQUARE-WEBHOOK] refund.updated received (data.id=%s)", obj.ID) } diff --git a/backend/handlers/webhooks/webhooks_test.go b/backend/handlers/webhooks/webhooks_test.go index 2474950..d925d6a 100644 --- a/backend/handlers/webhooks/webhooks_test.go +++ b/backend/handlers/webhooks/webhooks_test.go @@ -10,6 +10,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "log" "net/http" "net/http/httptest" "strings" @@ -264,6 +265,41 @@ func TestHandleSquareWebhook_RejectedWhenKeyEmpty(t *testing.T) { } } +func TestHandleSquareWebhook_NoRawPayloadInLogs(t *testing.T) { + event := SquareWebhookEvent{ + Type: "payment.updated", + EventID: "evt_pii_1", + CreatedAt: "2025-01-01T00:00:00Z", + Data: json.RawMessage(`{"id":"payment_pii_1","buyer_email_address":"secret@example.com", + "card_details":{"card":{"brand":"VISA","last_4":"1234","cardholder_name":"Jane Doe"}}}`), + } + body, _ := json.Marshal(event) + sig := webhookTestEnv(t, body) + + var buf bytes.Buffer + oldOutput := log.Writer() + log.SetOutput(&buf) + defer log.SetOutput(oldOutput) + + w := makeWebhookRequest(body, sig, context.Background()) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String()) + } + + out := buf.String() + for _, pii := range []string{"secret@example.com", "VISA", "1234", "Jane Doe", "payment_pii_1_full"} { + if strings.Contains(out, pii) { + t.Errorf("log output leaked PII %q:\n%s", pii, out) + } + } + if !strings.Contains(out, "data.id=payment_pii_1") { + t.Errorf("expected log to include object id, got:\n%s", out) + } + if !strings.Contains(out, "Received event: payment.updated") { + t.Errorf("expected log to reference event type, got:\n%s", out) + } +} + // ============================================================================= // Dedup — event_id replay protection // =============================================================================