Repository navigation
Close the webhook-first, contact and poll-ownership gaps - #3
alecritson merged 5 commits into
Conversation
- A customer who confirms a payment intent with the gateway directly, without calling pay(), leaves the session Open. The success listener only completed PaymentProcessing sessions, so the gateway placed the order with no OrderPlacing listeners, no contact email stamp, no payment method and no confirmation handover. The listener now pins an Open session against the live cart the gateway charged for (flagged meta.completed_without_pay_step, logged) and runs complete() like any other completion. The money is already taken, so the order is adopted rather than refused. - storeContact wrote a guest email onto PaymentProcessing and terminal sessions. The guest branch now applies the same gate as the driver's operableCart(): 409 frozen, 410 gone. - If the webhook completed the order before the guest's next processing poll, CartSession::current() had already minted a fresh cart and the poll 403'd. pay() now records the pinned session in the browser session, and ensureOwnership() accepts that for PaymentProcessing and Completed sessions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Write meta.completed_without_pay_step with a JSON-path update so a concurrent meta write (intent renewal, payment handle) is not lost. - Never adopt an Open hold-mode session from the success listener: it stays Open on purpose until the confirm page captures at reconcile. - storePaymentIntent refuses terminal and expired sessions. - Document that an OrderPlacing listener must not authorise a payment. - Tests: replayed success completes once, expired Open session is still adopted, chosen payment method survives the pin, hold-mode sessions are left Open, expired-session contact change is 410, payment intent on a completed session is 409. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tents - MySQL's JSON_SET on a NULL meta column returns NULL, so the JSON-path write dropped meta.completed_without_pay_step for sessions that never stored an intent. Re-read the row under lockForUpdate() and write the merged meta instead; still safe against concurrent meta writes. - storePaymentIntent refuses a PaymentProcessing session (409 frozen): replacing the charged intent would leave reconciliation polling an unconfirmed one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alecritson
left a comment
There was a problem hiding this comment.
I've left 6 comments: Pinned-session ownership grant on Completed sessions never expires; Guest contact gate duplicates ensureOperable() and disagrees on status codes; Webhook-first customers never get the pinned-session ownership grant; Listener's session-selection and lost-race branches are untested; Pin snapshot is taken outside the lock and bypasses state transitions; and 1 more.
| * processing poll would 403 instead of reaching the confirmation. | ||
| */ | ||
| if (($session->status instanceof PaymentProcessing || $session->status instanceof Completed) | ||
| && in_array($session->uuid, (array) session()->get(self::PINNED_SESSIONS_KEY, []), true)) { |
There was a problem hiding this comment.
Pinned-session ownership grant on Completed sessions never expires
Low · Security
ensureOwnership() now lets any request carrying the browser session through for a Completed checkout session whose UUID is in lunar.checkout.pinned. The grant has no time limit. It only drops off after five newer pins or when the session is invalidated.
Before this change, a completed guest session stopped being reachable once the cart rotated. Now, on a shared or kiosk browser, the next person can open the processing/confirmation routes for an earlier guest's order and see their email, address and order details for as long as the Laravel session lives.
Fix: store a timestamp with each pinned UUID and only honour it for a short window, e.g. ['uuid' => ..., 'at' => now()->timestamp] checked against roughly 1 hour. Or limit the bypass to the processing route instead of every ensureOwnership() caller.
There was a problem hiding this comment.
Fixed in 99b8225. Each pinned UUID is now stored with the time it was recorded, and ensureOwnership() only honours it for PINNED_SESSION_TTL (1 hour). Test: lets a pinned-session ownership grant lapse after an hour.
| * order, so it is frozen (409); a terminal or expired session | ||
| * is gone (410). | ||
| */ | ||
| if ($session->status instanceof PaymentProcessing) { |
There was a problem hiding this comment.
Guest contact gate duplicates ensureOperable() and disagrees on status codes
Low · Full review
storeContact() writes its own gate: PaymentProcessing → 409, and non-Open or expired → 410, so Completed gets 410. storePaymentIntent() uses ensureOperable() plus a 409 check, and its new test expects 409 for a Completed session. Two endpoints now answer a completed session with different status codes, and the hand-written copy can drift from ensureOperable() and the driver's operableCart().
Fix: call ensureOperable() in the guest branch, adding the PaymentProcessing → 409 check if needed. Alternatively, pull the frozen-or-gone gate into one helper used by storeContact, storePaymentIntent and operableCart(), so terminal sessions get the same status code everywhere.
There was a problem hiding this comment.
Fixed in 99b8225. storeContact (guest branch) and storePaymentIntent now share one ensureWritable() gate that matches operableCart(): PaymentProcessing gets 409 frozen, and terminal or expired sessions get 410. The completed-session intent test now expects 410. ensureOperable() (the bare 409 used by storeElement and friends) is unchanged.
| } | ||
|
|
||
| $checkoutDriver->assertReadyForPayment($session, $data['fingerprint']); | ||
| $this->rememberPinnedSession($session); |
There was a problem hiding this comment.
Webhook-first customers never get the pinned-session ownership grant
Medium · Full review
rememberPinnedSession() is only called from pay(). In the webhook-first flow this PR adds, the customer confirms with Stripe.js and never calls pay(), so their browser session never gets the UUID.
If the webhook arrives before the Stripe.js return_url redirect or the first processing poll, the listener completes the session and CartSession::current() mints a fresh cart. ensureOwnership() then falls through to the cart check and returns 403. That is the same race the PR fixes for the pay() path, and the PR description lists "no confirmation handover" as one of the symptoms being fixed.
Fix: also call rememberPinnedSession($session) in storePaymentIntent() after the intent is created, since the browser holding the intent is the one that can confirm it. Add a test where the listener completes an Open session and the same browser then polls processing.
There was a problem hiding this comment.
Good catch, fixed in 99b8225. storePaymentIntent now records the grant once the intent is created, since that browser can confirm with the gateway directly. New test: lets the browser that created the intent reach the confirmation after a webhook-first completion. It creates an intent through the route, has the gateway place the order, checks the listener completes the session, and then the same browser's poll redirects to success with the handover. It fails without the change.
| ->where('cart_reference', (string) $cartId) | ||
| ->where('status', PaymentProcessing::$name) | ||
| ->whereIn('status', [PaymentProcessing::$name, Open::$name]) | ||
| ->orderByRaw('case when status = ? then 0 else 1 end', [PaymentProcessing::$name]) |
There was a problem hiding this comment.
Listener's session-selection and lost-race branches are untested
Low · Tests & coverage
Two new branches in CompleteSessionOnPaymentSuccess have no tests:
- Session selection: the
orderByRawthat prefers aPaymentProcessingsession over anOpenone for the same cart. No test creates both and checks which session is completed and which is left alone. - Lost race: the
$pinned !== 1path inpinForGatewayPayment(), where another process moved the session first. Add a test that moves the session toPaymentProcessingorCompletedbetween the outer query and the pin (e.g. via a driver double or by callingpinForGatewayPaymenton a stale model). Check thatcomplete()runs only when appropriate and that no warning is logged.
There was a problem hiding this comment.
Lost race: covered in 99b8225 by swapping the driver for a proxy whose snapshot() moves the session under the listener. One test has pay() win (session ends Completed, no flag, no warning logged) and one has another process cancel it (left Cancelled, no completion, no warning).
Session selection: dropped instead of tested. active_cart_reference is unique across live sessions, so a cart can never have an Open and a PaymentProcessing session at once. The test I wrote for it hit that constraint. The orderByRaw was dead code, so the query now just carries a comment explaining why it can only match one row.
| */ | ||
| private function pinForGatewayPayment(CheckoutSession $session): bool | ||
| { | ||
| $live = $this->driver->snapshot($session); |
There was a problem hiding this comment.
Pin snapshot is taken outside the lock and bypasses state transitions
Low · Full review
pinForGatewayPayment() calls $this->driver->snapshot($session) before it opens the transaction and takes the row lock. It then sets the status with a raw query-builder update().
- The amounts and fingerprint come from a cart read made before the lock, so a concurrent cart change can end up stored on the pinned session.
- The raw update skips model events and any state-transition hooks or
StateChangedlisteners that a normalOpen → PaymentProcessingtransition fires.
If the pay boundary also uses a raw update, document that this matches it. Otherwise, move the snapshot inside the transaction and go through the same transition code the pay boundary uses.
There was a problem hiding this comment.
Fixed in 99b8225. The snapshot is now taken inside the transaction, after the row lock. The raw guarded update is deliberate: it's the same one-statement Open → PaymentProcessing update the pay boundary uses in LunarCheckoutDriver::assertReadyForPayment() (session states move through these guards, not model transitions), and the comment now says so. complete() also re-verifies the fingerprint against the cart under the cart-row lock.
| app(CheckoutDriver::class)->complete($session->refresh()); | ||
|
|
||
| $this->get(route('lunar.checkout.processing', $session->uuid)) | ||
| ->assertRedirect(); |
There was a problem hiding this comment.
Poll-after-webhook test only asserts that some redirect happened
Low · Tests & coverage
->assertRedirect() passes for any 3xx, including a redirect back to the cart or an error page. The point of the test is that the pinning browser reaches the confirmation handover, so it should assert the target, e.g. ->assertRedirect(route('lunar.checkout.confirmation', ...)) or whatever the handover route is. Otherwise a regression that redirects somewhere else would go unnoticed.
There was a problem hiding this comment.
Fixed in 99b8225. It now asserts the redirect goes to the session's success URL and that lunar.checkout.completed.order_reference holds the session's order, so a redirect anywhere else, or one without the handover, fails.
- Pinned-session ownership grants carry a timestamp and lapse after an hour, so a shared browser does not keep an earlier guest's completed order reachable. - storePaymentIntent records the grant too: a customer who confirms with the gateway directly never calls pay(), and their processing poll must still reach the confirmation if the webhook wins. - One ensureWritable() gate for storeContact and storePaymentIntent, matching operableCart(): 409 frozen, 410 gone. - The listener takes its snapshot under the row lock, and documents that the raw guarded update mirrors assertReadyForPayment(). - Drop the session-preference ordering: active_cart_reference is unique, so a cart never has more than one live session. - Tests: poll asserts the success redirect and handover; the grant lapses after an hour; a webhook-first browser reaches the confirmation; the listener completes without flagging when pay() wins the race, and leaves a session another process ended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Security audit follow-up (Edwardes EDW2-205). Three gaps:
Webhook-first placement on an unpinned session
A customer who has a payment intent can confirm it with Stripe.js without ever calling
pay(). The webhook'sauthorize()places the order, butCompleteSessionOnPaymentSuccessonly completedPaymentProcessingsessions. The session stayedOpen, so none ofcomplete()ran: noOrderPlacinglisteners (PO reference, account reference), no contact email stamp, nometa.payment_method, no confirmation handover.By the time the webhook arrives the money is taken, so refusing the order would leave a paid customer with nothing. The listener now pins an
Opensession for that cart the way the pay boundary does, against the live cart the gateway just charged for. It flags the sessionmeta.completed_without_pay_stepand logs a warning, then runscomplete()like any other webhook-first completion. Expiry is deliberately not checked, because the payment already happened. Failed payments leave the session alone.storeContacthad no operability gateThe signed-in branch goes through the driver's
operableCart(). The guest branch wrotecustomer_emailstraight onto the session, includingPaymentProcessingand terminal sessions. That email is what completion stamps on the order. The guest branch now applies the same gate:CheckoutSessionConflictException('frozen')(409) andCheckoutSessionNotOperableException(410).Processing poll 403 after a webhook completion
Once the gateway places the order,
CartSession::current()mints the browser a fresh cart. If the webhook beat the guest's nextprocessingpoll,ensureOwnership()saw a different cart and returned 403, and the guest never got the confirmation handover.pay()now records the pinned session's UUID in the browser session (the last 5 are kept), andensureOwnership()accepts that forPaymentProcessingandCompletedsessions. It checks this before callingcurrent(). A browser that never pinned the session still gets 403.Review follow-ups
lockForUpdate()and writes the mergedmeta. A JSON-path write was tried first, but MySQL'sJSON_SETon a NULL column returns NULL and dropped the flag.storePaymentIntentrefuses terminal and expired sessions (ensureOperable), and refusesPaymentProcessingsessions with 409 frozen, because replacing the charged intent would strand reconciliation.OrderPlacinglistener must not authorise a payment itself (re-entrancy).OrderDetailsElementTestPO/notes, and the collect point stamp) fail identically onmain: they come from JSON key ordering and are unrelated.Tests
WebhookFirstPlacementTest: anOpensession completes and adopts the gateway's order, withOrderPlacingdispatched once; a failed payment leaves the sessionOpen.ContactInformationTest: guest email change refused with 409 when frozen and 410 when terminal.PayBoundaryTest: the pinning browser can poll after a webhook completion; a browser that never pinned still gets 403.Each new test failed before its fix. Package suite: 304 passed, 1 skipped. phpstan and pint are clean.
🤖 Generated with Claude Code