parity(api,apps): close SEC-02 - orders take the customer from the token
The user declined to defer this to Phase 2, correctly: a public POST /orders with customerId in the body let anyone place an order on any customer's account, and an order lands on that customer's gold debt and ledger. POST /orders is now behind CustomerAuth. OrdersService.createOrder takes the customer id as a separate argument that the controller fills from the token; the body's customerId is ignored and a mismatch is logged. The field stays optional in the DTO and in shared-types only so both apps keep validating while they still send it -- they drop it in Phase 2. The admin builder is unaffected: it legitimately places orders for a chosen customer and passes that id explicitly, behind the admin guard. GET /orders/:id is scoped to the owner. A foreign or nonexistent id both return 404, never 403, so ids stay unenumerable. Operators read through the new GET /orders/admin/:id behind rid order.list, declared before the :id route because Nest matches in declaration order. Both apps' CartView now gates on session.isLoggedIn rather than a remembered customerId, which is no longer a credential. OTP audit (requested): no master code, no bypass, no hardcoded code anywhere; one code path issues a token and only after bcrypt.compare. No SMS provider is wired up -- in development the code is logged once, guarded by NODE_ENV !== 'production'. Fixed while auditing (SEC-11): deliver() persisted the real message text, code included, into SmsMessage.body, which the admin SMS grid lists and full-text searches. Any operator with the sms rid could read live codes, defeating the hashing of codeHash. The stored row is now a redacted audit line. Also logged MON-08: wagePercent/discountPercent/flatRialCharge are still taken from the client on order creation. The reference is worse -- it takes rash_weight from the client too -- so this is already a partial improvement; logged rather than changed, per D4. Tests: auth-smoke.sh now 53 passed, 0 failed, including a spoofed body customerId being ignored in favour of the token. Typecheck clean on api, user-app, user-app-v2 and shared-types. [ref: henza_server ApiMobileController.php:533,536-545,615-632] Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4efe543d62
commit
024e3dd045
@@ -178,6 +178,66 @@ if [ -n "${CODE_LOG:-}" ] && [ -f "$CODE_LOG" ]; then
|
||||
sed -n 's/.*"mobile":"\([^"]*\)".*/\1/p')
|
||||
[ "$who" = "$CUST_PHONE" ] && ok "mobile = $who" \
|
||||
|| bad "mobile = $who (want $CUST_PHONE)"
|
||||
|
||||
# ------------------------------------------------ SEC-02: orders ---
|
||||
# POST /orders was public with customerId in the body, so anyone could
|
||||
# place an order on anyone's account — and an order lands on that
|
||||
# customer's gold debt and ledger.
|
||||
echo "Orders require a customer token:"
|
||||
c=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$API/orders" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"customerId":"x","items":[{"productVariantId":"x","quantity":1,"wagePercent":7}]}')
|
||||
[ "$c" = 401 ] && ok "POST orders without a token -> 401" \
|
||||
|| bad "POST orders without a token -> $c (want 401)"
|
||||
|
||||
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/any-id")
|
||||
[ "$c" = 401 ] && ok "GET orders/:id without a token -> 401" \
|
||||
|| bad "GET orders/:id without a token -> $c (want 401)"
|
||||
|
||||
# A variant to order, and a customer that is NOT the token's owner.
|
||||
VARIANT=$(curl -s "$API/catalog/products" |
|
||||
sed -n 's/.*"variants":\[{"id":"\([^"]*\)".*/\1/p' | head -1)
|
||||
if [ -n "$VARIANT" ]; then
|
||||
echo "The body's customerId must be ignored in favour of the token:"
|
||||
ORD=$(curl -s -X POST "$API/orders" -H "Authorization: Bearer $CTOK" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"customerId\":\"some-other-customer\",\"items\":[{\"productVariantId\":\"$VARIANT\",\"quantity\":1,\"wagePercent\":7}]}")
|
||||
OWNER=$(printf '%s' "$ORD" | sed -n 's/.*"customerId":"\([^"]*\)".*/\1/p')
|
||||
MY_ID=$(curl -s "$API/customer/me" -H "Authorization: Bearer $CTOK" |
|
||||
sed -n 's/^{"id":"\([^"]*\)".*/\1/p')
|
||||
[ -n "$OWNER" ] && [ "$OWNER" = "$MY_ID" ] \
|
||||
&& ok "order landed on the token's customer ($OWNER), not the body's" \
|
||||
|| bad "order landed on '$OWNER', expected '$MY_ID'"
|
||||
|
||||
# orderNumber is unique per order, so it identifies the row we just made
|
||||
# without having to parse nested item ids out of the response.
|
||||
ORDNUM=$(printf '%s' "$ORD" | sed -n 's/.*"orderNumber":"\([^"]*\)".*/\1/p')
|
||||
MYORD=$(curl -s "$API/customer/me/orders" -H "Authorization: Bearer $CTOK" |
|
||||
tr '{' '\n' | grep -F "\"orderNumber\":\"$ORDNUM\"" |
|
||||
sed -n 's/^"id":"\([^"]*\)".*/\1/p' | head -1)
|
||||
if [ -n "$MYORD" ]; then
|
||||
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/$MYORD" -H "Authorization: Bearer $CTOK")
|
||||
[ "$c" = 200 ] && ok "own order readable -> 200" || bad "own order -> $c (want 200)"
|
||||
fi
|
||||
|
||||
# Someone else's order and a nonexistent one must be indistinguishable,
|
||||
# or the status code itself confirms which ids exist.
|
||||
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/definitely-not-an-order" \
|
||||
-H "Authorization: Bearer $CTOK")
|
||||
[ "$c" = 404 ] && ok "unknown order id -> 404" || bad "unknown order id -> $c (want 404)"
|
||||
|
||||
echo "Admin order read is admin-only:"
|
||||
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/admin/any" -H "Authorization: Bearer $CTOK")
|
||||
[ "$c" = 401 ] && ok "customer token on orders/admin/:id -> 401" \
|
||||
|| bad "customer token on orders/admin/:id -> $c (want 401)"
|
||||
c=$(curl -s -o /dev/null -w '%{http_code}' "$API/orders/admin/any" -H "Authorization: Bearer $SUPER")
|
||||
case "$c" in
|
||||
401|403) bad "admin token on orders/admin/:id -> $c (guard rejected an admin)" ;;
|
||||
*) ok "admin token on orders/admin/:id -> $c (through the guard)" ;;
|
||||
esac
|
||||
else
|
||||
printf ' \033[33mSKIP\033[0m no product variant available to order\n'
|
||||
fi
|
||||
else
|
||||
bad "correct code -> $c (want 200)"
|
||||
fi
|
||||
|
||||
@@ -320,14 +320,21 @@ export class AdminOrdersService {
|
||||
}),
|
||||
);
|
||||
|
||||
const order = await this.orders.createOrder({
|
||||
customerId: payload.customer_id,
|
||||
items,
|
||||
// The admin builder serves the omde (major/*) flow, so orders it creates
|
||||
// belong to the wholesale channel — legacy wrote them to the `orders`
|
||||
// table, which MajorOrderController::index listed.
|
||||
channel: OrderChannel.WHOLESALE,
|
||||
});
|
||||
// The customer id is now a separate argument rather than a body field, so
|
||||
// the customer-facing route can take it from the caller's token (SEC-02).
|
||||
// This path is different: an operator legitimately places an order FOR a
|
||||
// customer, so it passes the one the builder selected. The route itself is
|
||||
// admin-guarded, which is what makes that safe.
|
||||
const order = await this.orders.createOrder(
|
||||
{
|
||||
items,
|
||||
// The admin builder serves the omde (major/*) flow, so orders it creates
|
||||
// belong to the wholesale channel — legacy wrote them to the `orders`
|
||||
// table, which MajorOrderController::index listed.
|
||||
channel: OrderChannel.WHOLESALE,
|
||||
},
|
||||
payload.customer_id,
|
||||
);
|
||||
|
||||
// legacy `sts`: advance the new order to the chosen delivery status.
|
||||
const target = this.statusFromLegacyCode(payload.sts);
|
||||
|
||||
@@ -213,21 +213,31 @@ export class CustomerAuthService {
|
||||
* (ApiMobileController.php:47).
|
||||
*/
|
||||
private async deliver(mobile: string, code: string) {
|
||||
const body =
|
||||
// The exact text the reference sent (ApiMobileController.php:47), kept for
|
||||
// whenever a gateway is wired up. It is built here and handed to the
|
||||
// gateway; it is deliberately NOT what gets persisted (see below).
|
||||
const smsText =
|
||||
`<#> code is ${code}\n` +
|
||||
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکشن هنزا گلد ${code} می باشد\n` +
|
||||
`aMo6iFyUQaY`;
|
||||
void smsText; // TODO: hand to the SMS provider once one exists (STUB-02)
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
this.logger.log(`[dev] OTP for ${mobile}: ${code}`);
|
||||
}
|
||||
|
||||
// Recorded the same way the admin panel's own SMS sends are.
|
||||
// An audit row, NOT the message body.
|
||||
//
|
||||
// SmsMessage.body is listed and full-text searchable on the admin panel's
|
||||
// SMS grid (admin-misc.service.ts:136 allows `body`, :135 searches it), so
|
||||
// persisting the real text would show every live OTP to any operator
|
||||
// holding the `sms` rid — and the whole point of hashing codeHash is that
|
||||
// the code exists nowhere in readable form. The code is redacted here.
|
||||
await this.prisma.smsMessage.create({
|
||||
data: {
|
||||
audience: MessageAudience.SINGLE_NUMBER,
|
||||
recipients: mobile,
|
||||
body,
|
||||
body: 'کد یکبار مصرف ورود (متن پیام ثبت نمیشود)',
|
||||
sentCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,8 +25,20 @@ export class CreateOrderItemInputDto {
|
||||
}
|
||||
|
||||
export class CreateOrderDto {
|
||||
/**
|
||||
* IGNORED BY THE SERVER. The customer is taken from the caller's token.
|
||||
*
|
||||
* It was required, and the route was public, so anyone could place an order
|
||||
* on any customer's account — which lands on their gold debt and their
|
||||
* ledger. Logged as SEC-02 in docs/parity/ISSUES.md.
|
||||
*
|
||||
* Kept as an optional field only so the two apps, which still send it, do not
|
||||
* fail validation mid-flight. OrdersService logs a mismatch and always uses
|
||||
* the token. Removed from the DTO and from both apps in Phase 2.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customerId!: string;
|
||||
customerId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -2,40 +2,59 @@ import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { OrdersService } from './orders.service';
|
||||
import { CreateOrderDto } from './dto/create-order.dto';
|
||||
import { UpdateOrderStatusDto } from './dto/update-order-status.dto';
|
||||
import { Public, RequirePermission } from '../auth/guards';
|
||||
import { RequirePermission } from '../auth/guards';
|
||||
import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards';
|
||||
import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy';
|
||||
|
||||
/**
|
||||
* Customer-facing order routes.
|
||||
* Order routes.
|
||||
*
|
||||
* POST / and GET /:id stay `@Public()` for now: the target has no customer
|
||||
* session yet, so the customer id travels in the request body. The reference
|
||||
* derived it from the `api-customer` guard instead
|
||||
* (henza_server ApiMobileController.php:533 reads `auth()->id()`), which is what
|
||||
* stage 2 of the security hotfix restores. Tracked as SEC-02 / B1-B2 in
|
||||
* docs/parity/ISSUES.md.
|
||||
*
|
||||
* PATCH /:id/status was public too, which was worse in kind: it let an
|
||||
* anonymous caller move any order's status AND attribute the change to any
|
||||
* admin via the DTO's actorAdminUserId. It is an admin operation in the
|
||||
* reference, it had no caller in this repo (the admin panel posts to
|
||||
* admin/orders/status instead), so it is now behind the order-status rid.
|
||||
* All three were `@Public()` until the security hotfix. The reference's
|
||||
* customer-facing order endpoints sat behind its `api-customer` guard and read
|
||||
* the customer from the token (`auth()->id()`,
|
||||
* henza_server ApiMobileController.php:533); the target had no customer session
|
||||
* at the time, so the id travelled in the body and the server trusted it.
|
||||
* That meant an anonymous caller could place an order on anyone's account —
|
||||
* and an order lands on that customer's gold debt and ledger. Now that the
|
||||
* customer token exists, the reference's arrangement is restored.
|
||||
*/
|
||||
@Controller('orders')
|
||||
export class OrdersController {
|
||||
constructor(private readonly orders: OrdersService) {}
|
||||
|
||||
@Public()
|
||||
/** The customer comes from the token; the body's `customerId` is ignored. */
|
||||
@CustomerAuth()
|
||||
@Post()
|
||||
create(@Body() dto: CreateOrderDto) {
|
||||
return this.orders.createOrder(dto);
|
||||
create(@Body() dto: CreateOrderDto, @CurrentCustomer() me: AuthenticatedCustomer) {
|
||||
return this.orders.createOrder(dto, me.id);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
/**
|
||||
* Unscoped read for operators.
|
||||
*
|
||||
* Declared BEFORE the `:id` route below: Nest matches in declaration order,
|
||||
* so a literal segment that could also be read as a parameter has to come
|
||||
* first or it is never reached.
|
||||
*/
|
||||
@Get('admin/:id')
|
||||
@RequirePermission('order.list')
|
||||
getAsAdmin(@Param('id') id: string) {
|
||||
return this.orders.getById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* A customer reading one of their own orders. Scoped by token; someone
|
||||
* else's order is a 404 rather than a 403, so ids stay unenumerable.
|
||||
*
|
||||
* Mounted under /orders/:id (not /customer/me/orders/:id) so the existing
|
||||
* client paths keep working; it is the scoping that matters, not the shape.
|
||||
*/
|
||||
@CustomerAuth()
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string, @CurrentCustomer() me: AuthenticatedCustomer) {
|
||||
return this.orders.getOwnedById(id, me.id);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePermission('order.list.sts.submit')
|
||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateOrderStatusDto) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
@@ -17,6 +17,8 @@ interface ComputedItem {
|
||||
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
private readonly logger = new Logger(OrdersService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly catalog: CatalogService,
|
||||
@@ -29,9 +31,24 @@ export class OrdersService {
|
||||
* mirrors legacy ApiMobileController::setOrder. Debt is NOT posted here —
|
||||
* only once the order reaches DELIVERED_TO_AGENT (see updateStatus).
|
||||
*/
|
||||
async createOrder(dto: CreateOrderDto) {
|
||||
const customer = await this.prisma.customer.findUnique({ where: { id: dto.customerId } });
|
||||
if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`);
|
||||
/**
|
||||
* @param customerId taken from the caller's token by the controller, NEVER
|
||||
* from the request body. The body's `customerId` is ignored; see the note
|
||||
* on CreateOrderDto.
|
||||
*/
|
||||
async createOrder(dto: CreateOrderDto, customerId: string) {
|
||||
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
|
||||
if (!customer) throw new NotFoundException(`Customer ${customerId} not found`);
|
||||
|
||||
// The apps still send customerId in the body (removed from them in Phase 2).
|
||||
// It is not used for anything; a mismatch means either a stale client or an
|
||||
// attempt to order on someone else's account, and both are worth seeing.
|
||||
if (dto.customerId && dto.customerId !== customerId) {
|
||||
this.logger.warn(
|
||||
`Order body carried customerId=${dto.customerId} but the token is ${customerId}; ` +
|
||||
`the token wins.`,
|
||||
);
|
||||
}
|
||||
|
||||
const computedItems: ComputedItem[] = await Promise.all(
|
||||
dto.items.map(async (item) => {
|
||||
@@ -78,7 +95,7 @@ export class OrdersService {
|
||||
const order = await tx.order.create({
|
||||
data: {
|
||||
orderNumber: this.generateOrderNumber(),
|
||||
customerId: dto.customerId,
|
||||
customerId,
|
||||
branchId: dto.branchId,
|
||||
channel: dto.channel ?? OrderChannel.RETAIL,
|
||||
status: initialStatus,
|
||||
@@ -138,6 +155,7 @@ export class OrdersService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Unscoped read. Only reachable from the admin-guarded route. */
|
||||
getById(orderId: string) {
|
||||
return this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
@@ -145,6 +163,22 @@ export class OrdersService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A customer reading one of their own orders.
|
||||
*
|
||||
* Someone else's order is reported as 404, not 403: a 403 would confirm the
|
||||
* id exists, which is the same enumeration leak the id-addressed customer
|
||||
* routes had.
|
||||
*/
|
||||
async getOwnedById(orderId: string, customerId: string) {
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, customerId },
|
||||
include: { items: true, statusEvents: true, ledgerEntries: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||
return order;
|
||||
}
|
||||
|
||||
private generateOrderNumber(): string {
|
||||
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||
return `ORD-${Date.now()}-${random}`;
|
||||
|
||||
Reference in New Issue
Block a user