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
@@ -166,6 +166,67 @@ Run it for the sales expert too.
|
||||
|
||||
---
|
||||
|
||||
## T1 — Customer authentication: a separate JWT strategy (option **الف**)
|
||||
|
||||
Approved. Conditions:
|
||||
|
||||
1. **Separate secrets** — customer tokens are signed with `JWT_CUSTOMER_SECRET`,
|
||||
admin tokens with `JWT_SECRET`.
|
||||
2. **A `typ` claim is checked in both guards** — `typ: 'admin'` vs `typ: 'customer'`.
|
||||
3. **Mandatory cross-token tests:** a customer token on any admin route → 401;
|
||||
an admin token on a customer route → rejected, unless deliberately allowed.
|
||||
4. **Token lifetime and logout must be compared against the reference and
|
||||
recorded here** (see below).
|
||||
5. **No IDOR** — customer routes that return personal data derive the customer
|
||||
from the **token**, never from an id in the request.
|
||||
|
||||
### Reference comparison — token lifetime and logout
|
||||
|
||||
| Aspect | Reference | Target (current) | Decision |
|
||||
|---|---|---|---|
|
||||
| Customer token | `api_token = Str::random(60)`, a random opaque string stored on the customer row (`ApiMobileController.php:60-62`) | none | new customer JWT |
|
||||
| Expiry | **none** — the reference token never expires; it is reused on every login if already set | admin JWT `JWT_EXPIRES_IN`, default `12h` | customer JWT gets an explicit expiry (deliberate improvement over the reference) |
|
||||
| Logout | the reference has **no logout endpoint**; the app drops the token client-side | apps clear `localStorage` | keep client-side clear; note that a stolen token stays valid until expiry |
|
||||
| Rotation | the reference issues a new token **only if `api_token` is null** — logging in twice returns the same token forever | n/a | issue a fresh token on every verify |
|
||||
|
||||
The last three rows are **deliberate improvements over the reference**, approved
|
||||
by the user in the same message that approved D6. Recorded so the matrix does not
|
||||
later flag them as parity gaps.
|
||||
|
||||
## T2 — Uploaded files stay public
|
||||
|
||||
**Ruling:** `/uploads` is not touched. No allow-list, no signed URLs, and the
|
||||
"private folder" stage is dropped.
|
||||
|
||||
**Recorded verbatim at the user's request:**
|
||||
> «فایلهای آپلودی شامل مدارک عمداً عمومی هستند — تصمیم مالک پروژه»
|
||||
> (Uploaded files, including identity documents, are deliberately public — the
|
||||
> project owner's decision.)
|
||||
|
||||
### The light check the user asked for — result: ✅ already correct
|
||||
|
||||
| Check | Result | Evidence |
|
||||
|---|---|---|
|
||||
| Filenames unguessable? | **Yes.** `md5(Date.now() + originalname + randomUUID())` + the original extension | `src/modules/admin-uploads/admin-uploads.service.ts:53` |
|
||||
| Sequential or original name on disk? | **No.** `originalName` is kept only as a DB column, never as the filename | `admin-uploads.service.ts:62` |
|
||||
| Directory listing? | **No.** `PublicUploadsController` only resolves a single `:filename`, and `resolveStoredUpload()` rejects `.`/`..` and anything resolving outside the upload dir | `public-uploads.controller.ts:17`, `upload-path.ts:34-49` |
|
||||
|
||||
Nothing to change. No existing files were moved.
|
||||
|
||||
## T3 — Two-stage hotfix
|
||||
|
||||
Approved, with the condition that `GET /customers` be checked first.
|
||||
|
||||
**Check result:** the apps **are** dependent on it. Its only consumer is
|
||||
`LoginView.vue:131` in both apps, and `CustomerSummary` is used nowhere else
|
||||
(verified by grep). The fields actually read are `mobile`, `isActive`,
|
||||
`isArchived`, then `session.login(id, mobile)` — four fields.
|
||||
|
||||
So per the ruling: stage 1 strips the sensitive fields and keeps the route; stage
|
||||
2 removes the route when login moves to OTP.
|
||||
|
||||
---
|
||||
|
||||
# Urgent investigation results
|
||||
|
||||
## `percent` — full trace (D9)
|
||||
|
||||
Reference in New Issue
Block a user