parity(api,apps): security hotfix stage 1 - close unauthenticated write paths

Authorised by rulings D7 and T1-T3 in docs/parity/DECISIONS.md. This is the
CLAUDE.md section 3 carve-out: matching the reference here would keep a money
write path open to anonymous callers.

Closed, each behind the rid the legacy vocabulary already had:
  GET   accounting/customers/:id/balance    -> wallet.list
  POST  accounting/customers/:id/reconcile  -> wallet.charge
  POST  accounting/repayments/gold          -> document.create
  POST  accounting/repayments/rial          -> document.create
  POST  accounting/entries/:id/confirm      -> document.list.confirm
  PATCH orders/:id/status                   -> order.list.sts.submit

Four of the accounting routes WRITE to the ledger. The order-status route was
worse in kind: its DTO takes a client-supplied actorAdminUserId, so an
anonymous caller could move any order and pin it on any admin. It had no
caller - the admin panel posts to admin/orders/status.

GET /customers kept its public status (both apps' login still matches the
phone number in the browser) but now selects only id/mobile/isActive/
isArchived. It previously returned every active customer's full name, national
code, both debts, credit limit and the URLs of their identity-document scans.
CustomerSummary narrowed in BOTH user apps in the same commit, per the
v1/v2 parity rule.

Not changed: /uploads stays public per ruling T2. The filename check requested
alongside it passed unchanged - md5(time + name + uuid), no directory listing.

Tests: apps/api/scripts/auth-smoke.sh asserts 401 without a token, 403 with a
low-privilege token (ut 88, 5 unrelated rids) and pass-through for a super
admin on every closed route, plus that the public routes still answer 200 and
that GET /customers exposes only the four login fields. 23 passed, 0 failed
against a live server. Typecheck clean on api, user-app and user-app-v2.

[ref: henza_server/routes/admin.php:12,272; ApiMobileController.php:39-78]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-22 00:43:11 +03:30
co-authored by Claude Opus 5
parent 004a3945c2
commit fdf833565f
11 changed files with 356 additions and 23 deletions
@@ -2,34 +2,53 @@ import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { LedgerService } from './ledger.service';
import { PostGoldRepaymentDto } from './dto/post-gold-repayment.dto';
import { PostRialRepaymentDto } from './dto/post-rial-repayment.dto';
import { Public } from '../auth/guards';
import { RequirePermission } from '../auth/guards';
@Public() // pre-existing unauthenticated endpoint; see Phase 1 notes
/**
* Ledger operations — admin only.
*
* These were `@Public()` until the security hotfix. That was faithful to the
* reference, where the whole admin route file is unauthenticated (the
* `auth:api` group wrapper is commented out at henza_server/routes/admin.php:12
* and :272), but it contradicted decision D1 in docs/parity/DECISIONS.md, which
* confirms the target keeps its stricter auth. Four of these five routes WRITE
* to the ledger, so an anonymous caller could reconcile a balance or post and
* confirm a repayment.
*
* Rids follow the legacy vocabulary: reading a balance is a wallet read,
* repayments are financial documents, and confirming an entry is the existing
* document-confirm permission.
*/
@Controller('accounting')
export class AccountingController {
constructor(private readonly ledger: LedgerService) {}
@Get('customers/:customerId/balance')
@RequirePermission('wallet.list')
getBalance(@Param('customerId') customerId: string) {
return this.ledger.getBalance(customerId);
}
@Post('customers/:customerId/reconcile')
@RequirePermission('wallet.charge')
reconcile(@Param('customerId') customerId: string) {
return this.ledger.reconcileBalance(customerId);
}
@Post('repayments/gold')
@RequirePermission('document.create')
postGoldRepayment(@Body() dto: PostGoldRepaymentDto) {
return this.ledger.postGoldRepayment(dto);
}
@Post('repayments/rial')
@RequirePermission('document.create')
postRialRepayment(@Body() dto: PostRialRepaymentDto) {
return this.ledger.postRialRepayment(dto);
}
@Post('entries/:entryId/confirm')
@RequirePermission('document.list.confirm')
confirmEntry(@Param('entryId') entryId: string) {
return this.ledger.confirmEntry(entryId);
}
@@ -2,11 +2,31 @@ import { Controller, Get, Param } from '@nestjs/common';
import { CustomersService } from './customers.service';
import { Public } from '../auth/guards';
@Public() // pre-existing unauthenticated endpoint; see Phase 1 notes
/**
* Customer-facing reads.
*
* Still `@Public()` because the target has no customer session yet — every one
* of these is called with `session.customerId` straight from the browser
* (DashboardView / ProfileView / OrdersView / TransactionsView), so any caller
* who guesses or harvests an id can read that customer's data. That is a
* textbook IDOR and it is what stage 2 of the security hotfix closes, by
* deriving the customer from a `customer-jwt` token instead of the URL
* (decision T1 in docs/parity/DECISIONS.md). The reference did exactly that
* via its `api-customer` guard.
*
* What stage 1 already fixed: `list()` no longer returns the full customer
* record — see LOGIN_LOOKUP_SELECT in customers.service.ts (SEC-08).
*/
@Public() // pre-existing unauthenticated endpoint; closed in hotfix stage 2
@Controller('customers')
export class CustomersController {
constructor(private readonly customers: CustomersService) {}
/**
* Login lookup only. Returns id/mobile/isActive/isArchived and nothing else;
* both apps' LoginView downloads this and matches the phone in the browser.
* Removed entirely in stage 2 once OTP verify returns the one customer.
*/
@Get()
list() {
return this.customers.list();
@@ -23,6 +23,31 @@ const PUBLIC_CUSTOMER_SELECT = {
updatedAt: true,
} satisfies Prisma.CustomerSelect;
/**
* The only fields `GET /customers` may expose.
*
* That route is unauthenticated because both user apps' login screens download
* the list and match the phone number in the browser. With the full select
* above, an anonymous caller got the entire customer book: every national ID
* number, every debt and credit limit, and the URLs of every identity-document
* scan. Logged as SEC-08 in docs/parity/ISSUES.md.
*
* The reference never allowed this — its login was
* POST userAuth (SMS) -> POST userVerfiy, and the server returned exactly ONE
* customer after the code matched (henza_server ApiMobileController.php:39-78).
*
* Stage 2 of the security hotfix restores that flow and removes this route from
* the public surface entirely. Until then these four fields are what the login
* screens actually read (LoginView.vue:131-133 matches on mobile/isActive/
* isArchived, then calls session.login(id, mobile)) — nothing more.
*/
const LOGIN_LOOKUP_SELECT = {
id: true,
mobile: true,
isActive: true,
isArchived: true,
} satisfies Prisma.CustomerSelect;
@Injectable()
export class CustomersService {
constructor(private readonly prisma: PrismaService) {}
@@ -46,7 +71,7 @@ export class CustomersService {
list() {
return this.prisma.customer.findMany({
where: { isActive: true, isArchived: false },
select: PUBLIC_CUSTOMER_SELECT,
select: LOGIN_LOOKUP_SELECT,
orderBy: { createdAt: 'desc' },
});
}
@@ -2,24 +2,42 @@ 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 } from '../auth/guards';
import { Public, RequirePermission } from '../auth/guards';
@Public() // pre-existing unauthenticated endpoint; see Phase 1 notes
/**
* Customer-facing 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.
*/
@Controller('orders')
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Public()
@Post()
create(@Body() dto: CreateOrderDto) {
return this.orders.createOrder(dto);
}
@Public()
@Get(':id')
get(@Param('id') id: string) {
return this.orders.getById(id);
}
@Patch(':id/status')
@RequirePermission('order.list.sts.submit')
updateStatus(@Param('id') id: string, @Body() dto: UpdateOrderStatusDto) {
return this.orders.updateStatus(id, dto.status, dto.actorAdminUserId, dto.note);
}