Skip to content

fix: audit phase 1 — payment path, webhooks, order transitions and gateway routing - #261

Open
Moritz1708 wants to merge 6 commits into
masterfrom
bug/260-audit-p1-payment-path-and-gateway-hardening
Open

Moritz1708 wants to merge 6 commits into
masterfrom
bug/260-audit-p1-payment-path-and-gateway-hardening

Conversation

@Moritz1708

Copy link
Copy Markdown
Owner

Summary

Phase 1 of the 2026-09-23 audit. Six commits, one per area:

  • Webhooks — deliveries were matched by event type only, so every subscriber of OrderStatusChanged received all customers' events. OrderStatusChangedIntegrationEvent now carries an optional CustomerId (additive), and only that customer's subscriptions get a delivery; events without it go to nobody. Target URLs are restricted to public HTTPS hosts on save (WebhookTargetPolicy) and every resolved address is checked again at connect time (WebhookTargetConnector), which also covers DNS rebinding and service-discovery rewrites. Redirects are not followed, no proxy is used, and failed deliveries store only the status code instead of the target's response body.
  • Ordering — Order transitions return Result and reject invalid moves (Confirm only from Created); rehydration stays unguarded so existing streams load. PaymentResultProcessor checks the order status before confirming the reservation: a captured payment for a cancelled order is refunded instead of turning the order Paid; a failed payment for a paid order is logged for review instead of cancelling it.
  • Payment — every Stripe call carries a deterministic idempotency key (os-pi-create-{orderId}, os-pi-capture/cancel-{intent}, os-refund-{intent}-{reference}), so a redelivery replays the original request instead of creating a second PaymentIntent. Only declines and invalid requests become Result failures; network errors, 5xx, 401/403/409/429 and idempotency errors propagate and are retried through Service Bus. A failed capture voids the authorization and keeps the intent id on the record. PaymentRecord transitions return Result. The webhook finds records by orderId metadata or intent id, answers 503 while the worker has not stored the record yet, rejects a missing signature with 400 (was 500), applies only valid transitions and logs contradictions as EventId 5001. Stripe client timeout 30 s (SDK default 80 s). Migration AddPaymentTransactionIdIndex (index only).
  • BFF — /bff/login accepts only a local returnUrl (open redirect); the client sends a relative path.
  • Gateway — anonymous POST /webhooks/stripe on the BFF (outside /api, so neither session policy nor CSRF applies) forwarding to an anonymous gateway route; the Payment API authenticates by Stripe signature. Adds the missing /api/v1/admin/coupons route (admin UI got 404).
  • Docs — docs/operations.md (Stripe webhook endpoint, 24 h DLQ replay rule for payment queues), CHANGELOG.md.

Type of change

  • feat — new feature
  • fix — bug fix
  • refactor — change without behavioural impact
  • docs / style / test / chore / ci / build / perf

Checklist

  • Build green (dotnet build OrderSphere.slnx) — 0 errors; only the pre-existing ASPIRE010 warning
  • Tests green and extended where applicable (dotnet test) — 1,267 passed, 130 new
  • No new compiler warnings
  • EF migrations included for schema changes (AddPaymentTransactionIdIndex, additive index)
  • Architecture conventions upheld (Result, layer dependencies, no cross-service project refs)
  • Documentation updated (docs/, README) where relevant
  • No secrets/connection strings committed

Breaking changes

No UI contract changes. Behavioural changes operators need to know:

  • Webhook subscribers now receive only events of their own customer. Existing subscriptions pointing at non-public hosts are rejected at delivery time.
  • The Stripe dashboard webhook endpoint must be https://<bff-host>/webhooks/stripe.
  • DLQ replay of payment-requests, order-confirmation-failed and refund-requested only within 24 h (Stripe idempotency-key retention); afterwards check the intent in the Stripe dashboard first.

Test plan

  • Unit/integration tests for each fix (webhook customer scope, SSRF policy + connect-time guard, LocalReturnUrl, Stripe route through the BFF, order/payment state machines, Stripe provider idempotency keys and error classification, webhook 400/503/200 paths).
  • End-to-end over Aspire: /bff/login?returnUrl=https://evil.example lands on /; admin coupon page loads; webhook subscription to https://127.0.0.1/ is rejected; two customers with OrderStatusChanged subscriptions only receive their own events.
  • stripe listen --forward-to https://localhost:<bff>/webhooks/stripe + a checkout with Stripe configured → webhook 200 and "reconciled" log. (stripe trigger intents carry no orderId and are acknowledged as ignored.)

References

Closes #260

🤖 Generated with Claude Code

Moritz1708 and others added 6 commits September 23, 2026 21:58
…ernal targets

Subscriptions were matched by event type only, so every subscriber of
OrderStatusChanged received all customers' events. Events now carry an
optional CustomerId (additive contract change) and only that customer's
subscriptions receive a delivery; events without it go to nobody.

Webhook URLs are restricted to public HTTPS hosts when saved, and every
resolved address is checked again at connect time (covers DNS rebinding and
service-discovery rewrites). Redirects are not followed, no proxy is used,
and a failed delivery stores only the status code, not the response body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed orders

Order.Confirm/MarkShipped/MarkDelivered/Cancel return Result and reject
invalid transitions; Confirm is only allowed from Created. Rehydration stays
unguarded so existing streams still load.

PaymentResultProcessor checks the order status before confirming the stock
reservation: a captured payment for a cancelled order is refunded instead of
turning the order Paid, a failed payment for a cancelled order only closes
the saga, and a failure reported for a paid order is logged for review
instead of cancelling it. OrderStatusChanged events carry the CustomerId.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every Stripe call carries a deterministic idempotency key (order- or
intent-scoped; refunds per refund reason), so a Service Bus redelivery
replays the original request instead of creating a second PaymentIntent.
Only declines and invalid requests become Result failures; transient faults
propagate and are retried by redelivery. The SDK timeout drops to 30 s so
one run stays inside the lock-renewal window.

A failed capture voids the authorization and keeps the intent id on the
record. PaymentRecord transitions return Result and reject invalid moves.

The Stripe webhook finds the record by orderId metadata or intent id, answers
503 while the worker has not stored it yet, rejects a missing signature with
400 instead of 500, applies only valid transitions, and logs contradictions
as EventId 5001. Adds a non-unique index on payments.TransactionId.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The login endpoint redirected to any returnUrl after sign-in, an open
redirect. It now accepts local paths only (same rule as ASP.NET Core's
IsLocalUrl) and falls back to "/". The client sends the current page as a
relative path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stripe could not reach the webhook: the BFF required a session and CSRF
token on /api/**, and the gateway required a JWT on /api/v1/payments/**.
The BFF now exposes an anonymous POST /webhooks/stripe (outside /api) that
forwards to an anonymous gateway route; the Payment API authenticates it by
Stripe signature.

Adds the missing /api/v1/admin/coupons gateway route (admin UI got 404).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Audit P1: payment path and gateway hardening

1 participant