feat: implement server-side validation for mobile numbers and national codes across multiple endpoints

This commit is contained in:
hamed
2026-06-20 12:28:36 +03:30
parent 6fc456522a
commit f9678026a8
12 changed files with 487 additions and 9 deletions
+41
View File
@@ -284,6 +284,47 @@ Delete a user.
> **نماینده (ROLE_REPRESENTATION):** افزودن پزشک و کلینیک برای نماینده از طریق endpointهای جدا انجام می‌شود — `POST /api/v1/representation/doctor` و `POST /api/v1/representation/clinic` (به `docs/api/representation.md` مراجعه کنید). در نسخه‌ی نماینده، `representation_id` پزشک خودکار روی نماینده‌ی کاربر جاری ست می‌شود. endpointهای `/api/v1/admin/*` همچنان فقط `ROLE_ADMIN` هستند.
### POST `/api/v1/admin/doctors`
ساخت پزشک جدید (در صورت نبودِ کاربر با این موبایل، یک User هم ساخته می‌شود).
**Permission:** `ROLE_ADMIN`
#### Request Body (`application/json`)
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `mobile` | string | ✅ | موبایل ورود؛ باید فرمت معتبر موبایل ایران داشته باشد (`^09\d{9}$`) — ارقام فارسی/عربی به انگلیسی نرمال می‌شوند |
| `name` | string | ✅ | نام پزشک |
#### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `VALIDATION` | 422 | `mobile` یا `name` خالی |
| `VALIDATION` | 422 | `mobile` فرمت معتبر موبایل ایران ندارد (`field: mobile`) |
---
### POST `/api/v1/admin/clinic`
ساخت کلینیک جدید (در صورت نبودِ کاربرِ صاحب با این موبایل، یک User هم ساخته می‌شود).
**Permission:** `ROLE_ADMIN`
#### Request Body (`application/json`)
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `owner_mobile` | string | ✅ | موبایل صاحب کلینیک؛ باید فرمت معتبر موبایل ایران داشته باشد (`^09\d{9}$`) |
| `name` | string | ✅ | نام کلینیک |
#### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `VALIDATION` | 422 | `owner_mobile` خالی |
| `VALIDATION` | 422 | `owner_mobile` فرمت معتبر موبایل ایران ندارد (`field: owner_mobile`) |
| `VALIDATION` | 422 | `name` کلینیک خالی |
---
### GET `/api/v1/admin/doctors`
List all doctors with pagination.
+4 -1
View File
@@ -59,7 +59,8 @@ Create a new representation.
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_CONFLICT_001` | 409 | Mobile number already in use |
| `ERR_VALIDATION_001` | 422 | Invalid input |
| `ERR_VALIDATION_001` | 422 | `mobile_number` فرمت معتبر موبایل ایران (`^09\d{9}$`) ندارد (`field: mobile_number`) |
| `ERR_VALIDATION_002` | 422 | `mobile_number` یا `full_name` خالی |
---
@@ -288,6 +289,7 @@ Get yearly earnings dashboard for a representation.
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_002` | 422 | موبایل یا نام خالی |
| `ERR_VALIDATION_001` | 422 | `mobile` فرمت معتبر موبایل ایران ندارد (`field: mobile`) |
| `ERR_CONFLICT_001` | 409 | این کاربر قبلاً پزشک است |
---
@@ -314,6 +316,7 @@ Get yearly earnings dashboard for a representation.
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_002` | 422 | موبایل یا نام خالی |
| `ERR_VALIDATION_001` | 422 | `owner_mobile` فرمت معتبر موبایل ایران ندارد (`field: owner_mobile`) |
---
+3
View File
@@ -125,6 +125,8 @@ Update a user profile.
### Request Body
Same fields as POST — all optional.
> **`national_code` server-side validation:** اگر `national_code` ارسال شود و **غیرخالی** باشد، با الگوریتم رقم کنترلیِ کد ملی ایران اعتبارسنجی می‌شود (ارقام فارسی/عربی به انگلیسی نرمال و به‌صورت لاتین ذخیره می‌شوند). مقدارِ نامعتبر با `422` رد می‌شود. ارسال `null` یا رشته‌ی خالی مجاز است (کد ملی اختیاری) و فیلد را پاک می‌کند.
### Response `200`
Updated profile object.
@@ -132,6 +134,7 @@ Updated profile object.
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_VALIDATION_001` | 422 | `national_code` نامعتبر (رقم کنترلی/طول غلط) (`field: national_code`) |
| `ERR_FORBIDDEN_001` | 403 | Not the profile owner |
| `ERR_NOT_FOUND_001` | 404 | Profile not found |
+82
View File
@@ -0,0 +1,82 @@
# گزارش باگ — ممیزی ۱۴۰۵/۰۳/۳۰ (2026-06-20)
ممیزی رفتاریِ کل APIها (۲۴۳ endpoint) با توکن واقعیِ هر ۶ نقش (admin/clinic/doctor/secretary/representation/user) روی `https://clinic-pro.ddev.site`. مقایسه با `docs/api/*`.
## خلاصه
| سطح | تعداد |
|-----|------|
| CRITICAL | 0 |
| HIGH | 1 |
| MEDIUM | 2 |
| LOW | 0 |
| **رفع‌شده در همین جلسه** | 1 (BUG-1) |
**نتیجه‌ی کلیِ امنیتی مثبت:** لایه‌ی authorization محکم است — همه‌ی `/api/v1/admin/*` برای نقش‌های غیرادمین `403` دادند؛ scope نماینده درست محدود است؛ IDOR روی پروفایل کاربر و ویرایش کلینیکِ غیرمالک `403/404` می‌گیرد؛ ورودی‌های مرزیِ لیست‌ها (page/limit بزرگ/منفی/غیرعددی، تاریخ نامعتبر) همگی graceful بودند (بدون ۵۰۰).
---
## باگ‌ها
### [HIGH] BUG-3 — ساخت نماینده با شماره موبایلِ نامعتبر (بدون اعتبارسنجی فرمت)
- **دامنه:** representation
- **endpoint:** `POST /api/v1/representation` (`ROLE_ADMIN`)
- **بازتولید:**
```bash
curl -sk -X POST .../api/v1/representation -H "Authorization: Bearer <ADMIN>" \
-H "Content-Type: application/json" -d '{"full_name":"qa","mobile_number":"not-a-mobile"}'
```
- **انتظار:** `422` (شماره موبایل نامعتبر).
- **واقعیت:** `201 Created` — یک نماینده ساخته شد و یک `User("not-a-mobile")` ایجاد شد (در پاسخ `mobile_number: null`).
- **اثر:** رکورد آشغال + کاربرِ بدون شماره‌ی لاگین معتبر؛ شماره موبایل کلیدِ ورود است، پس نماینده‌ای ساخته می‌شود که نمی‌تواند وارد شود/پیامک بگیرد.
- **ریشه:** `src/Representation/Controller/RepresentationController.php:86,89` — فقط `empty($mobile)` چک می‌شود، فرمت ایران (`^09\d{9}$`) چک نمی‌شود.
- **رفع پیشنهادی:** بعد از `empty` چک، `if (!preg_match('/^09\d{9}$/', $mobile)) return $this->error(ERR_VALIDATION_001, 'شماره موبایل نامعتبر', 422);` (همان regex فرانت).
- **وضعیت:** گزارش‌شده (رفع نشده — نیاز به تأیید).
### [MEDIUM] BUG-2 — کد ملی نامعتبر بدون اعتبارسنجی سمت سرور ذخیره می‌شود
- **دامنه:** user-profile
- **endpoint:** `PATCH /api/v1/user-profile/{uuid}`
- **بازتولید:**
```bash
curl -sk -X PATCH .../api/v1/user-profile/<own-uuid> -H "Authorization: Bearer <USER>" \
-H "Content-Type: application/json" -d '{"national_code":"1111111111"}'
```
- **انتظار:** `422` (رقم کنترلیِ کد ملی نامعتبر است).
- **واقعیت:** `200` و `national_code: "1111111111"` ذخیره شد.
- **اثر:** اعتبارسنجی کد ملی فقط در فرانت (`isValidIranNationalCode`) است؛ هر کلاینت/فرانتِ دورزده‌شده می‌تواند کد ملی نامعتبر ثبت کند.
- **ریشه:** `src/UserProfile/Controller/UserProfileController.php` → `hydrate()` فقط `setNationalCode($data['national_code'])` می‌کند، بدون validation.
- **رفع پیشنهادی:** اعتبارسنجی الگوریتم رقم کنترلیِ ایران سمت سرور قبل از `setNationalCode` (پورت همان منطق فرانت).
- **وضعیت:** گزارش‌شده.
### [MEDIUM] BUG-1 — ۵۰۰ روی ساخت بلاگ با تایپ اشتباهِ body ✅ رفع شد
- **دامنه:** blog
- **endpoint:** `POST /api/v1/blog` (`ROLE_ADMIN`)
- **بازتولید:** `-d '{"title":"valid","body":["x"]}'`
- **انتظار:** `422`.
- **واقعیت (قبل رفع):** `HTTP 500` / `ERR_INTERNAL_001`.
- **ریشه:** `src/Blog/Controller/BlogController.php:178` — `trim($data['body'] ?? '')` وقتی `body` آرایه است → `TypeError` در PHP 8.
- **رفع انجام‌شده:** چک `is_string` روی title/body (۴۲۲ صریح) + cast `(string)`. تست شد: body آرایه → `422`، بلاگ معتبر همچنان `201`.
- **یادداشت:** اسکن سراسری نشان داد بقیه‌ی controllerها از `(string)` cast استفاده می‌کنند؛ این تنها نقطه‌ی بدون cast بود.
---
## مواردی که تست شدند و **سالم** بودند (شواهد مثبت)
| تست | نتیجه |
|-----|------|
| `/api/v1/admin/*` (users, payments, settlements, doctors, clinics, sms/logs) با ۵ نقش غیرادمین | همه `403` ✓ (admin `200`) |
| `representation/{me,doctors,clinics,appointments}` | rep `200`، بقیه `403` ✓ |
| IDOR: user → PATCH پروفایلِ کاربر دیگر | `404` (canAccess ownership) ✓ |
| IDOR: doctor → PATCH کلینیکِ غیرمالک | `403` (`ERR_AUTH_006`) ✓ |
| لیست‌های paginated: `page=99999`, `limit=-1/0`, `page=abc` | همه `200` graceful ✓ |
| detail بلاگ‌ها (کلاس باگ proxy author) | همه `200` ✓ (قبلاً رفع شده بود) |
| POST با بدنه‌ی خالی `{}` (doctor/clinic/representation/blog/send-code) | همه `422` ✓ |
| `tsc --noEmit` + `yarn dev` (admin build) | بدون خطا ✓ |
---
## توصیه‌ها
1. **یک Trait/سرویس اعتبارسنجی مشترک سمت سرور** برای موبایل و کد ملی بساز (الان منطق فقط در فرانت تکرار شده). BUG-2 و BUG-3 هر دو از همین خلأ می‌آیند.
2. الگوی `trim((string) ($data[...] ?? ''))` را به‌عنوان قاعده در همه‌ی هندلرها رعایت کن (BUG-1).
3. این ممیزی روی محورهای پرریسک متمرکز بود؛ برای پوشش کامل ۲۴۳ endpoint، یک suite `phpunit` رفتاری (functional) ارزش سرمایه‌گذاری دارد (الان فقط `tests/bootstrap.php` هست).