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:
co-authored by
Claude Opus 5
parent
004a3945c2
commit
fdf833565f
@@ -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' },
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user