From 20afcd4416f780e8402c4ea9cd305453dc173a8f Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Thu, 8 Oct 2026 22:58:21 +0200 Subject: [PATCH] fix(order): write only customer fields on an order update PUT /orders/:uuid/cart passed the body through sanitize.contentAPI.input, which keeps every public writable attribute, to the order update. The shop forwards the browser's body unchanged, so a browser could set paymentAuthorised on its own order, and the order lifecycle then generated and mailed the invoice. The handler now parses the body with parseCustomerUpdate and writes only the fields it returns, plus the customer it finds or creates from the invoice address. A body with any other field is answered 400 with the error list, and the warning it logs names the fields, not their values. A delivery method that does not exist is answered 400. Once the payment is authorised, the order is locked for the customer: the update, add-product and remove-product answer 409. Refs https://git.librete.ch/libretech/mp/issues/71 --- src/api/order/controllers/order.ts | 47 +++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/src/api/order/controllers/order.ts b/src/api/order/controllers/order.ts index d0d2830..1cc7cec 100644 --- a/src/api/order/controllers/order.ts +++ b/src/api/order/controllers/order.ts @@ -1,9 +1,15 @@ import { factories } from "@strapi/strapi"; import { sanitize } from "@strapi/utils"; +import type { ID } from "@strapi/database/dist/types"; import { Order } from "../../../../types"; import { paypalApi } from "../../../services/PayPalApi"; import { orderDefaultParams } from "../services/order"; import { calculateTotalProductPrice } from "../../product/services/product"; +import { parseCustomerUpdate } from "../../../checkout/customer-update"; +import type { CustomerUpdate } from "../../../checkout/customer-update"; +import { isLockedForCustomer } from "../../../checkout/order-state"; + +const ORDER_LOCKED = "Order can no longer be changed"; /** * The order controller is a funnel for the client to interact with the order service. @@ -96,31 +102,42 @@ export default factories.createCoreController("api::order.order", ({ strapi }) = return await sanitize.contentAPI.output(order, strapi.getModel("api::order.order")); }, + /** + * The customer's checkout steps: contact details, terms, addresses and delivery method. Only these fields are written; + * payment state, totals, numbers and documents are written by the payment flow and the server alone. + */ updateByUuid: async (ctx) => { const { uuid } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order")); const order = await strapi.db.query("api::order.order").findOne({ where: { uuid } }); if (!order) return ctx.notFound("Order not found"); + if (isLockedForCustomer(order)) return ctx.conflict(ORDER_LOCKED); - const sanitizedBody = await sanitize.contentAPI.input(ctx.request.body, strapi.getModel("api::order.order")); + const update = parseCustomerUpdate(ctx.request.body, new Date()); + if (update.ok === false) { + strapi.log.warn("app:w:order-controller: Rejected order update", { order: order.id, fields: update.fields }); + return ctx.badRequest("Invalid order update", { errors: update.errors }); + } + const data: CustomerUpdate & { customer?: ID } = { ...update.data }; + + if (data.delivery !== undefined) { + const delivery = await strapi.db.query("api::delivery.delivery").findOne({ where: { id: data.delivery }, select: ["id"] }); + if (!delivery) { + strapi.log.warn("app:w:order-controller: Rejected order update", { order: order.id, fields: ["data.delivery"] }); + return ctx.badRequest("Invalid order update", { errors: ["data.delivery: no delivery method has this id"] }); + } + } // Handle customer creation/update if structured addresses are provided - // @ts-expect-error - if (sanitizedBody?.data?.invoiceAddressStructured) { + if (data.invoiceAddressStructured) { + const address = data.invoiceAddressStructured; const customerAddress = - // @ts-expect-error - sanitizedBody.data.invoiceAddress || - // @ts-expect-error - `${sanitizedBody.data.invoiceAddressStructured.givenName} ${sanitizedBody.data.invoiceAddressStructured.familyName}\n${sanitizedBody.data.invoiceAddressStructured.streetAddress}\n${sanitizedBody.data.invoiceAddressStructured.postalCode} ${sanitizedBody.data.invoiceAddressStructured.addressLevel2}`; - // @ts-expect-error - sanitizedBody.data.customer = await strapi - .service("api::customer.customer") - // @ts-expect-error - .findOrCreateCustomer(customerAddress, sanitizedBody.data.invoiceAddressStructured); + data.invoiceAddress || + `${address.givenName} ${address.familyName}\n${address.streetAddress}\n${address.postalCode} ${address.addressLevel2}`; + data.customer = await strapi.service("api::customer.customer").findOrCreateCustomer(customerAddress, address); } const orderUnsafe = await strapi.service("api::order.order").update(order.id, { - // @ts-expect-error - data: sanitizedBody?.data, + data, ...orderDefaultParams }); @@ -131,6 +148,7 @@ export default factories.createCoreController("api::order.order", ({ strapi }) = const { uuid, productId } = await sanitize.contentAPI.query(ctx.params, strapi.getModel("api::order.order")); const order = (await strapi.service("api::order.order").findOneByUuid(uuid)) as Order; if (!order) return ctx.notFound("Order not found"); + if (isLockedForCustomer(order)) return ctx.conflict(ORDER_LOCKED); const product = parseInt(productId as string); const count = parseInt(ctx.query.count as string) || 1; @@ -162,6 +180,7 @@ export default factories.createCoreController("api::order.order", ({ strapi }) = const product = parseInt(productId as string); if (!order) return ctx.notFound("Order not found"); + if (isLockedForCustomer(order)) return ctx.conflict(ORDER_LOCKED); strapi.log.info("app:v:order-controller: - Removing product from cart", { totalBefore: order.total }); const updatedCart = [...(order.cart || [])];