From 5d9e8937ae9286c6c7da73e9293d52684fe53d82 Mon Sep 17 00:00:00 2001 From: Boris Besemer Date: Thu, 20 Aug 2026 10:21:18 +0200 Subject: [PATCH 1/2] fix(sdk): reject update on terminal checkout sessions The update handler never consulted the FSM, so a completed (or canceled) session could be rewritten by any caller: items, totals, customer, and fulfillment all changed while the session kept reporting completed, with no additional payment authorized or captured. Add an isTerminal() helper derived from the FSM transition table and guard update after the session lookup, before products.price runs, so terminal sessions cannot trigger merchant-side pricing. The rejected request returns the same 400 invalid_state error shape cancel uses. Fixes VULN-13971 (HackerOne #3948484, CWE-841). --- packages/sdk/src/checkout/fsm.ts | 4 + packages/sdk/src/checkout/handlers.ts | 12 +- packages/sdk/test/checkout.test.ts | 152 ++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/checkout/fsm.ts b/packages/sdk/src/checkout/fsm.ts index b90f36f..83c879a 100644 --- a/packages/sdk/src/checkout/fsm.ts +++ b/packages/sdk/src/checkout/fsm.ts @@ -7,6 +7,10 @@ const ALLOWED: Record = { canceled: [], }; +export function isTerminal(status: CheckoutSessionStatus): boolean { + return ALLOWED[status].length === 0; +} + export function canTransition( from: CheckoutSessionStatus, to: CheckoutSessionStatus, diff --git a/packages/sdk/src/checkout/handlers.ts b/packages/sdk/src/checkout/handlers.ts index 88498d8..f805d05 100644 --- a/packages/sdk/src/checkout/handlers.ts +++ b/packages/sdk/src/checkout/handlers.ts @@ -1,6 +1,6 @@ import type { Tracer } from "@opentelemetry/api"; import { ACPError, isACPError } from "./errors.ts"; -import { canTransition } from "./fsm.ts"; +import { canTransition, isTerminal } from "./fsm.ts"; import { withIdempotency } from "./idempotency.ts"; import { HEADERS, parseHeaders } from "./lib/headers.ts"; import { err, ok } from "./lib/http.ts"; @@ -208,6 +208,16 @@ export function createHandlers( status: 404, }); + // Terminal sessions are immutable records of what was (not) paid for + if (isTerminal(s.status)) + throw new ACPError({ + code: "invalid_state", + message: `Cannot update a session in state "${s.status}"`, + param: "status", + type: "invalid_request_error", + status: 400, + }); + // Merge updates const items = body.items ?? s.items.map(({ id, quantity }) => ({ id, quantity })); diff --git a/packages/sdk/test/checkout.test.ts b/packages/sdk/test/checkout.test.ts index 7fe07c3..aa9f94e 100644 --- a/packages/sdk/test/checkout.test.ts +++ b/packages/sdk/test/checkout.test.ts @@ -320,6 +320,158 @@ describe("Checkout Integration", () => { }); }); + describe("FSM enforcement on update (terminal states)", () => { + async function createSession() { + const createReq = createRequest("http://test/checkout_sessions", { + method: "POST", + body: { items: [{ id: "prod-1", quantity: 1 }] }, + }); + const createRes = await handlers.create(createReq, { + items: [{ id: "prod-1", quantity: 1 }], + }); + return await createRes.json(); + } + + function updateAttack(sessionId: string) { + const body = { + items: [{ id: "prod-laptop", quantity: 100 }], + customer: { + billing_address: { + email: "attacker@example.com", + name: "Attacker", + line1: "1 Evil St", + city: "Nowhere", + postal_code: "00000", + country: "US", + }, + }, + }; + const req = createRequest(`http://test/checkout_sessions/${sessionId}`, { + method: "POST", + body, + }); + return handlers.update(req, sessionId, body); + } + + it("should reject update on a completed session without re-pricing", async () => { + // Wrap products with a spy so we can assert price() is never reached + let priceCalls = 0; + const spiedProducts = { + price: async (input: Parameters[0]) => { + priceCalls++; + return products.price(input); + }, + }; + handlers = createHandlers( + { products: spiedProducts, payments }, + { store }, + ); + + const session = await createSession(); + priceCalls = 0; + + const completeReq = createRequest( + `http://test/checkout_sessions/${session.id}/complete`, + { + method: "POST", + body: { payment: { delegated_token: "tok_123" } }, + }, + ); + await handlers.complete(completeReq, session.id, { + payment: { delegated_token: "tok_123" }, + }); + + const updateRes = await updateAttack(session.id); + + expect(updateRes.status).toBe(400); + const error = await updateRes.json(); + expect(error.error).toMatchObject({ + code: "invalid_state", + param: "status", + type: "invalid_request_error", + }); + + // Pricing must never run for a terminal session + expect(priceCalls).toBe(0); + + // No additional payment activity + expect((payments as any)._calls.authorize).toBe(1); + expect((payments as any)._calls.capture).toBe(1); + + // Stored session is unchanged + const getReq = createRequest( + `http://test/checkout_sessions/${session.id}`, + ); + const getRes = await handlers.get(getReq, session.id); + const stored = await getRes.json(); + expect(stored.status).toBe("completed"); + expect(stored.items).toEqual(session.items); + expect(stored.totals).toEqual(session.totals); + expect(stored.customer).toEqual(session.customer); + }); + + it("should reject update on a canceled session", async () => { + const session = await createSession(); + + const cancelReq = createRequest( + `http://test/checkout_sessions/${session.id}/cancel`, + { method: "POST" }, + ); + await handlers.cancel(cancelReq, session.id); + + const updateRes = await updateAttack(session.id); + + expect(updateRes.status).toBe(400); + const error = await updateRes.json(); + expect(error.error).toMatchObject({ + code: "invalid_state", + param: "status", + type: "invalid_request_error", + }); + + // Stored session is unchanged + const getReq = createRequest( + `http://test/checkout_sessions/${session.id}`, + ); + const getRes = await handlers.get(getReq, session.id); + const stored = await getRes.json(); + expect(stored.status).toBe("canceled"); + expect(stored.items).toEqual(session.items); + expect(stored.totals).toEqual(session.totals); + }); + + it("should still allow update on a not_ready_for_payment session", async () => { + // Start not ready, then flip the quote to ready to exercise the status ladder + let ready = false; + const togglingProducts = { + price: async (input: Parameters[0]) => { + const quote = await products.price(input); + return { ...quote, ready }; + }, + }; + handlers = createHandlers( + { products: togglingProducts, payments }, + { store }, + ); + + const session = await createSession(); + expect(session.status).toBe("not_ready_for_payment"); + + ready = true; + const body = { items: [{ id: "prod-1", quantity: 3 }] }; + const updateReq = createRequest( + `http://test/checkout_sessions/${session.id}`, + { method: "POST", body }, + ); + const updateRes = await handlers.update(updateReq, session.id, body); + + expect(updateRes.status).toBe(200); + const updated = await updateRes.json(); + expect(updated.status).toBe("ready_for_payment"); + expect(updated.totals.grand_total.amount).toBe(3000); + }); + }); + describe("Cancel flow", () => { it("should cancel a session", async () => { // Create session From d4e9643fced4ccbc953368ea69414a3507046161 Mon Sep 17 00:00:00 2001 From: Boris Besemer Date: Tue, 1 Sep 2026 11:07:46 +0200 Subject: [PATCH 2/2] chore(sdk): bump version to 0.0.0-alpha.10 --- packages/sdk/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 7366a26..ecc9527 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "acp-handler", - "version": "0.0.0-alpha.9", + "version": "0.0.0-alpha.10", "description": "Vercel handler for Agentic Commerce Protocol (ACP) - Build checkout APIs that AI agents like ChatGPT can use to complete purchases", "license": "MIT", "repository": {