feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks. - Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content. - Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
This commit is contained in:
@@ -0,0 +1,520 @@
|
||||
# آدیت امنیتی دلتایی — سطح حملهٔ ساختهشده بعد از ۲۰۲۶-۰۷-۱۹
|
||||
|
||||
## زمینه
|
||||
|
||||
آخرین آدیت امنیتی `clinicpro` در `docs/security/AUDIT-2026-07-19.md` ثبت شده. از آن تاریخ تا
|
||||
امروز (۲۰۲۶-۰۸-۰۷) روی این repo **۳۵۴ کامیت** زده شده و تمرکز غالب آنها دقیقاً روی سطحی است
|
||||
که آدیت قبلی ندیده بود:
|
||||
|
||||
- رجیستری واحد مجوزها (`PermissionCatalog`) و اندپوینت `GET /api/v1/permission-catalog`
|
||||
- بازنویسی گیتهای منشی و پزشکِ مهمانِ کلینیک
|
||||
- دامنهٔ کاملاً جدید `Treatment` — پروندهٔ درمان، پروتکل، اجرای جلسه توسط پرسنل
|
||||
- نقش/پنل `staff` با گاردِ متمرکز `StaffRouteGuardSubscriber`
|
||||
- `PatientRecordScopeResolver` برای محدودکردن دید پروندهها
|
||||
|
||||
آدیت قبلی خودش در بخش «محدودیت پوشش» نوشته بود که ماتریس authz ناقص مانده، چون فقط کاربر
|
||||
`admin` و `doctor` در DB بود. آن محدودیت حالا برطرفشدنی است.
|
||||
|
||||
این پرامپت **آدیت کامل از صفر نیست**. عمداً دلتایی است: یافتههای قبلی فقط regression میشوند،
|
||||
و بودجهٔ اصلی صرف کدی میشود که هرگز آدیت نشده.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
**مشکل:** بزرگترین سطح حملهٔ فعلی پروژه — authorization چندنقشی و دامنهٔ `Treatment` — هیچوقت
|
||||
تست امنیتی نشده. یک رجیستری مجوز که در UI رندر میشود ولی در backend enforce نشود، یعنی هر
|
||||
منشی/پزشکِ مهمان میتواند با یک درخواست مستقیم به API از مجوزش فرار کند.
|
||||
|
||||
**هدف:** پیدا کردن و رفع آسیبپذیریهای این سطح، بهعلاوهٔ تأیید اینکه یافتههای آدیت قبلی
|
||||
برنگشتهاند.
|
||||
|
||||
**سیاست رفع (تصمیم کاربر):**
|
||||
|
||||
- 🟥 Critical و 🟧 High: **همان جلسه خودکار رفع شود** + تست رگرسیون نوشته شود.
|
||||
- 🟨 Medium و پایینتر: **اول گزارش، بعد تأیید کاربر، بعد رفع.** بدون تأیید دست نزن.
|
||||
|
||||
**خارج از محدوده (تصمیم کاربر):** مهاجرت CKEditor از `@ckeditor/ckeditor5-build-classic` به
|
||||
پکیج umbrella `ckeditor5` v45+. فقط بهعنوان «risk پذیرفتهشده» در گزارش ثبت شود، پیاده نشود.
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ **موفق:** گزارش `docs/security/AUDIT-2026-08-07.md` تولید شده و برای **هر** یافته یک بازتولید
|
||||
اجراشده دارد (دستور + خروجی واقعی). هر ردیف `PermissionCatalog::RESOURCES` یک تست دارد که
|
||||
ثابت میکند خاموشبودن آن مجوز، درخواستِ متناظر API را با **403** رد میکند — نه اینکه فقط
|
||||
دکمه را در UI پنهان کند.
|
||||
- ❌ **خطا:** درخواست به هر اندپوینت `/api/v1/treatment-*` با توکن کاربری از tenant دیگر →
|
||||
**403 یا 404** با envelope خطای `BaseController` و کد از `ErrorCodes`؛ هرگز 200 با دادهٔ
|
||||
tenant دیگر و هرگز 500 با stack trace.
|
||||
- ⚠️ **مرزی:** کاربر چندنقشی (مثلاً هم `ROLE_STAFF` هم `ROLE_SECRETARY`) پس از
|
||||
`POST /api/v1/auth/switch-context` دقیقاً دسترسی همان context فعال را دارد، نه اجتماع دو
|
||||
نقش. همچنین کاربری که **فقط** `ROLE_STAFF` است، روی هر مسیر خارج از allowlist ــ از جمله
|
||||
مسیرهایی که بعد از نوشتن گارد اضافه شدهاند ــ 403 میگیرد.
|
||||
- ✅ تستها سبزند: `ddev exec php bin/phpunit` و `ddev exec php vendor/bin/phpstan analyse`.
|
||||
- ✅ اگر رفتار یا قرارداد هر endpoint عوض شد، فایل متناظر در `docs/api/` همان جلسه بهروز شد.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `../.claude/skills/symfony-security-audit/driver.mjs` | درایور آدیت؛ **در روت workspace است، نه در `clinicpro/`** |
|
||||
| `.claude/skills/qa-clinicpro/driver.mjs` | ساخت/بررسی اکانت نقشها (`roles`) |
|
||||
| `docs/security/AUDIT-2026-07-19.md` | یافتههای قبلی برای regression |
|
||||
| `docs/security-audit.md` | آدیت ۲۰۲۶-۰۶-۰۹ (قدیمیتر) |
|
||||
| `src/Shared/Security/PermissionCatalog.php` | منبع واحد منابع/اکشنهای مجوزدهی |
|
||||
| `src/Shared/Controller/PermissionCatalogController.php` | `GET /api/v1/permission-catalog` |
|
||||
| `src/Secretary/Security/SecretaryPermissionChecker.php` | enforce مجوز منشی |
|
||||
| `src/Secretary/Security/SecretaryAccessChecker.php` | حل tenant منشی |
|
||||
| `src/Clinic/Security/ClinicDoctorPermissionChecker.php` | enforce مجوز پزشکِ مهمان |
|
||||
| `src/Clinic/Security/ClinicDoctorAccessChecker.php` | حل tenant پزشکِ مهمان |
|
||||
| `src/Staff/Security/StaffRouteGuardSubscriber.php` | allowlist مسیرهای پرسنل |
|
||||
| `src/Staff/Security/StaffPermissions.php` | مجوزهای پرسنل |
|
||||
| `src/Patient/Security/PatientRecordScopeResolver.php` | محدودسازی دید پروندهٔ بیمار |
|
||||
| `src/Treatment/Controller/TreatmentCaseController.php` | پروندهٔ درمان |
|
||||
| `src/Treatment/Controller/TreatmentProtocolController.php` | پروتکل سرویس |
|
||||
| `src/Treatment/Controller/SessionExecutionController.php` | اجرای جلسه توسط پرسنل |
|
||||
| `src/Resource/Controller/ResourcePermissionTrait.php` | گیت منابع/دستگاهها |
|
||||
| `src/Shared/Tenant/TenantFilter.php` | فیلتر Doctrine جداسازی محیط |
|
||||
| `src/Shared/Tenant/TenantOwnershipChecker.php` | بررسی مالکیت محیط |
|
||||
| `src/Shared/Tenant/GlobalTables.php` | entityهای عمداً غیر-tenant |
|
||||
| `config/packages/security.yaml` | firewall و مسیرهای public |
|
||||
| `assets/admin/` | پنل ادمین؛ مجوزهای UI |
|
||||
| `docs/architecture/tenancy.md` | «چه تضمین میدهد و چه نمیدهد» |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
### ۱. عدم تقارن گیت در دامنهٔ Treatment — قویترین lead
|
||||
|
||||
`TreatmentCaseController` کلاسش فقط با احراز هویت گارد شده و مجوز را متدبهمتد میسنجد:
|
||||
|
||||
```php
|
||||
// src/Treatment/Controller/TreatmentCaseController.php:31
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class TreatmentCaseController extends BaseController
|
||||
{
|
||||
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_show', methods: ['GET'])]
|
||||
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
$this->denyUnlessGranted($user, 'view');
|
||||
|
||||
$case = $this->requireCase($user, $uuid);
|
||||
// ...
|
||||
}
|
||||
|
||||
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_update', methods: ['PATCH'])]
|
||||
public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyUnlessGranted($user, 'update');
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
ولی `TreatmentProtocolController` **هیچ** `denyUnlessGranted` ندارد — نه روی خواندن، نه روی
|
||||
`PUT`، نه روی `DELETE`:
|
||||
|
||||
```php
|
||||
// src/Treatment/Controller/TreatmentProtocolController.php:24
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class TreatmentProtocolController extends BaseController
|
||||
{
|
||||
#[Route('/api/v1/service-item/{uuid}/treatment-protocol', methods: ['PUT'])]
|
||||
public function replace(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
|
||||
}
|
||||
|
||||
$protocol = $this->writer->replace($user, $this->requireItem($user, $uuid), $data);
|
||||
|
||||
return $this->success($protocol->toArray());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
یعنی تنها دفاع، `requireItem($user, $uuid)` است. اگر آن فقط مالکیت tenant را بسنجد و نه مجوز
|
||||
اکشن، هر کاربرِ داخل همان کلینیک — از جمله منشیای که مجوز `services` ندارد — میتواند پروتکل
|
||||
درمان یک سرویس را بازنویسی یا حذف کند. **باید بازتولید شود، نه فرض.**
|
||||
|
||||
### ۲. گاردِ پرسنل مبتنی بر allowlist مسیر
|
||||
|
||||
```php
|
||||
// src/Staff/Security/StaffRouteGuardSubscriber.php
|
||||
private const ALLOWED_PREFIXES = [
|
||||
'/api/v1/dashboard/staff',
|
||||
'/api/v1/auth/switch-context',
|
||||
'/api/v1/user/change-password',
|
||||
];
|
||||
|
||||
private const OVERRIDING_ROLES = [
|
||||
'ROLE_ADMIN', 'ROLE_CLINIC', 'ROLE_DOCTOR', 'ROLE_SECRETARY', 'ROLE_REPRESENTATION',
|
||||
];
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
// ...
|
||||
$path = $event->getRequest()->getPathInfo();
|
||||
if (!str_starts_with($path, '/api/')) {
|
||||
return;
|
||||
}
|
||||
// ...
|
||||
foreach (self::ALLOWED_PREFIXES as $prefix) {
|
||||
if (str_starts_with($path, $prefix)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی پرسنل به این بخش مجاز نیست', 403);
|
||||
}
|
||||
```
|
||||
|
||||
دو ریسک ساختاری که باید تست شوند:
|
||||
|
||||
- گارد روی `getPathInfo()` و `str_starts_with` کار میکند. مسیر نرمالنشده
|
||||
(`/api/v1/../v1/patients`، دابلاسلش، درصد-انکود) ممکن است هم از شرط `/api/` رد شود هم از
|
||||
allowlist بیفتد یا برعکس، از روتر عبور کند ولی از گارد نه.
|
||||
- `OVERRIDING_ROLES` گارد را برای کاربر چندنقشی **کاملاً** کنار میگذارد. اگر کاربری هم پرسنل
|
||||
و هم منشی باشد و context فعالش پرسنل باشد، این گارد اجرا نمیشود.
|
||||
|
||||
### ۳. یافتههای باز از ۲۰۲۶-۰۷-۱۹
|
||||
|
||||
| # | یافته | شدت | وضعیت ثبتشده |
|
||||
|---|-------|-----|----------------|
|
||||
| 2 | `lodash` code injection via `_.template` | 🟧 High | نیمهرفع |
|
||||
| 2b | ۶۱ moderate در CKEditor build-classic (deprecated) | 🟨 Medium | باز — خارج از محدودهٔ این پرامپت |
|
||||
| 5 | پسورد sandbox درگاه ملت هاردکد در `MellatGateway.php:23` | ⬜ Info | باز |
|
||||
|
||||
### ۴. وضعیت اکانتهای تست
|
||||
|
||||
`TEST_USERS.md` بیاعتبار است و `create_test_users.php` که به آن ارجاع میدهد در repo نیست.
|
||||
|
||||
**وضعیت واقعی DB لوکال، سنجیدهشده در ۲۰۲۶-۰۸-۰۷** — نه از حافظه، خروجی
|
||||
`driver.mjs roles` و `ddev mysql`:
|
||||
|
||||
```
|
||||
admin 09120671756 ROLE_USER,ROLE_ADMIN ✓ login موفق
|
||||
representation 09124000001 ROLE_USER,ROLE_REPRESENTATION ✓ login موفق
|
||||
doctor 09390039833 ROLE_USER,ROLE_CLINIC ⚠ نقشش عوض شده — دیگر DOCTOR نیست
|
||||
clinic 09127000000 — ✗ کاربر در DB نیست
|
||||
secretary 09123456778 — ✗ کاربر در DB نیست
|
||||
```
|
||||
|
||||
DB از زمان آدیت قبلی دوباره seed شده. کاربران قابل استفاده که واقعاً وجود دارند:
|
||||
|
||||
```
|
||||
id=47 09128726723 ROLE_USER,ROLE_STAFF ← کاربرِ «فقط پرسنل»، موجود است
|
||||
id=11 0912000201 ROLE_USER,ROLE_DOCTOR,ROLE_CLINIC ← کاربر چندنقشی، موجود است
|
||||
id=24 0912000301 ROLE_USER,ROLE_CLINIC
|
||||
id=5 0912000109 ROLE_USER,ROLE_SECRETARY
|
||||
id=15 0912000209 ROLE_USER,ROLE_SECRETARY
|
||||
id=28 0912000309 ROLE_USER,ROLE_SECRETARY
|
||||
```
|
||||
|
||||
پس کاربر پرسنل و کاربر چندنقشی **ساخته نمیشوند** — فقط پسوردشان باید معلوم/ست شود.
|
||||
پسورد سری `0912000xxx` نامعلوم است؛ اول تلاش، بعد در صورت نیاز ست کردن هش.
|
||||
|
||||
---
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. آمادهسازی ماتریس نقشها
|
||||
|
||||
اول وضعیت واقعی اکانتها را بسنج، بعد کمبود را بساز:
|
||||
|
||||
```bash
|
||||
ddev start
|
||||
node .claude/skills/qa-clinicpro/driver.mjs roles
|
||||
```
|
||||
|
||||
کاربر «فقط پرسنل» (`09128726723`) و کاربر چندنقشی (`0912000201`) از قبل در DB هستند — ساخته
|
||||
نمیشوند. فقط پسوردشان باید معلوم شود. اگر پسورد نامعلوم بود، هش را ست کن:
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console security:hash-password 'QaTest@1234'
|
||||
ddev mysql -e "UPDATE users SET password_hash='<hash>' WHERE mobile_number='09128726723';"
|
||||
```
|
||||
|
||||
این تغییر فقط روی DB لوکال است و در گزارش ثبت میشود.
|
||||
|
||||
علاوه بر آن، برای تست IDOR لازم است:
|
||||
|
||||
- **tenant دوم** (کلینیک B) با حداقل یک پروندهٔ درمان، یک سرویس با پروتکل، و یک بیمار.
|
||||
اول بگرد ببین از قبل هست؛ فقط اگر نبود بساز.
|
||||
|
||||
خروجی این وظیفه یک جدول creds در ابتدای گزارش است.
|
||||
|
||||
**نحوه تست:** برای هر کاربر ساختهشده، `POST /api/v1/auth/login` باید 200 و توکن بدهد؛ در
|
||||
گزارش، `roles` هر توکن decodeشده ثبت شود.
|
||||
|
||||
---
|
||||
|
||||
### ۲. اجرای درایور بهعنوان baseline
|
||||
|
||||
**با درایور شروع کن، نه با grep دستی.** اسکیل `symfony-security-audit` را طبق
|
||||
`.claude/skills/symfony-security-audit/SKILL.md` اجرا کن (white-box: deps/sinks/guards/secrets/config —
|
||||
black-box: authz/headers/cors/injection).
|
||||
|
||||
خروجی خام درایور **یافته نیست، lead است**. هر lead باید با خواندن کد تأیید یا رد شود، و
|
||||
leadهای ردشده با دلیل در بخش «رد شد» گزارش بیایند — دقیقاً همان قانونی که آدیت ۲۰۲۶-۰۷-۱۹
|
||||
رعایت کرده بود.
|
||||
|
||||
**نحوه تست:** خروجی درایور با تعداد lead به تفکیک شدت در گزارش ثبت شود.
|
||||
|
||||
---
|
||||
|
||||
### ۳. regression یافتههای آدیت قبلی
|
||||
|
||||
برای هر یافتهٔ `docs/security/AUDIT-2026-07-19.md` که «رفع شد» علامت خورده، بازتولیدِ همان
|
||||
گزارش را دوباره اجرا کن و ثابت کن هنوز بسته است:
|
||||
|
||||
```bash
|
||||
# CSP روی SPA ادمین
|
||||
curl -sI https://clinic-pro.ddev.site/admin | grep -i content-security-policy
|
||||
|
||||
# APP_SECRET در فایل env تحت git
|
||||
git ls-files | grep -E '^\.env' | xargs grep -nE 'APP_SECRET='
|
||||
|
||||
# فلگهای session cookie
|
||||
ddev exec php bin/console debug:config framework session
|
||||
|
||||
# وابستگیهای npm
|
||||
npm audit --json | python3 -c "import json,sys; m=json.load(sys.stdin)['metadata']['vulnerabilities']; print(m)"
|
||||
```
|
||||
|
||||
هر کدام برگشته بود، **رگرسیون** است و شدتش یک درجه بالاتر ثبت میشود — چون قبلاً رفع شده بوده
|
||||
و دوباره شکسته.
|
||||
|
||||
**نحوه تست:** جدول «یافتهٔ قبلی / وضعیت امروز / خروجی بازتولید» در گزارش.
|
||||
|
||||
---
|
||||
|
||||
### ۴. ماتریس enforcement مجوزها — هستهٔ این آدیت
|
||||
|
||||
`PermissionCatalog::RESOURCES` منبع واحد است. برای **هر** جفت `(resource, action)` در آن، این
|
||||
سه سؤال جواب داده شود:
|
||||
|
||||
1. کدام اندپوینت(ها) این مجوز را نمایندگی میکنند؟
|
||||
2. آیا backend واقعاً enforce میکند، یا فقط UI دکمه را پنهان میکند؟
|
||||
3. آیا منشی و پزشکِ مهمان **هر دو** enforce میشوند، یا فقط یکی؟
|
||||
|
||||
روش: با اکانت منشی، مجوز X را در DB خاموش کن، بعد اندپوینت متناظر را مستقیم صدا بزن.
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST https://clinic-pro.ddev.site/api/v1/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"mobile":"09123456778","password":"QaTest@1234"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["token"])')
|
||||
|
||||
# مجوز treatment.update خاموش است → باید 403 بدهد
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X PATCH \
|
||||
https://clinic-pro.ddev.site/api/v1/treatment-case/<uuid> \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"status":"closed"}'
|
||||
```
|
||||
|
||||
هر جفتی که **200** بدهد یک یافتهٔ 🟧 High است (bypass مجوز)، مگر اینکه با خواندن کد ثابت شود
|
||||
عمداً باز است — که آنوقت باید در `docs/api/` مستند باشد.
|
||||
|
||||
خروجی: جدول کامل `resource × action × role × HTTP status` در گزارش.
|
||||
|
||||
**نحوه تست:** خودِ جدول تست است. هر ردیف باید دستور و کد وضعیت واقعی داشته باشد.
|
||||
|
||||
---
|
||||
|
||||
### ۵. آدیت دامنهٔ Treatment — IDOR و عدم تقارن گیت
|
||||
|
||||
سه کنترلر `src/Treatment/Controller/*` کامل خوانده شوند. مشخصاً:
|
||||
|
||||
الف) **`TreatmentProtocolController` بدون `denyUnlessGranted`** (بخش «وضعیت فعلی ۱»). بررسی کن
|
||||
`requireItem()` دقیقاً چه میسنجد — فقط tenant، یا مجوز اکشن هم؟
|
||||
|
||||
```bash
|
||||
# با توکن منشیِ بدون مجوز services
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE \
|
||||
https://clinic-pro.ddev.site/api/v1/service-item/<uuid>/treatment-protocol \
|
||||
-H "Authorization: Bearer $SECRETARY_TOKEN"
|
||||
# انتظار: 403 — اگر 200/204 داد، یافتهٔ High
|
||||
```
|
||||
|
||||
ب) **IDOR بین tenant.** با توکن کلینیک A، uuid منابع کلینیک B را صدا بزن. برای هر مسیر:
|
||||
|
||||
```
|
||||
GET /api/v1/treatment-case/{uuid}
|
||||
PATCH /api/v1/treatment-case/{uuid}
|
||||
GET /api/v1/treatment-case/{uuid}/plan
|
||||
GET /api/v1/treatment-session/{uuid}
|
||||
GET /api/v1/treatment-session/{uuid}/slot-suggestions
|
||||
GET /api/v1/service-item/{uuid}/treatment-protocol
|
||||
PUT /api/v1/service-item/{uuid}/treatment-protocol
|
||||
DELETE /api/v1/service-item/{uuid}/treatment-protocol
|
||||
POST /api/v1/dashboard/staff/treatment-session/{uuid}/start
|
||||
POST /api/v1/dashboard/staff/treatment-session/{uuid}/finish
|
||||
POST /api/v1/dashboard/staff/session-area/{uuid}/start
|
||||
POST /api/v1/dashboard/staff/session-area/{uuid}/complete
|
||||
POST /api/v1/dashboard/staff/session-area/{uuid}/skip
|
||||
POST /api/v1/dashboard/staff/session-area/{uuid}/reopen
|
||||
```
|
||||
|
||||
انتظار: 403 یا 404. هر 200 یا 500 یافته است.
|
||||
|
||||
ج) **پرسنلِ کلینیک A روی جلسهٔ کلینیک B.** `SessionExecutionController` با `ROLE_STAFF` گارد
|
||||
شده، ولی نقش ≠ مالکیت. تست کن که `start`/`finish` روی جلسهٔ tenant دیگر رد میشود.
|
||||
|
||||
د) **دستکاری وضعیت.** `finish` روی جلسهای که `start` نشده، `reopen` روی ناحیهٔ جلسهٔ بسته،
|
||||
`complete` دو بار پشت سر هم. اگر منجر به وضعیت ناسازگار یا 500 شود، ثبت شود.
|
||||
|
||||
**نحوه تست:** هر خط بالا با `curl` و کد وضعیت واقعی. برای هر یافتهٔ تأییدشده، یک تست PHPUnit
|
||||
در `tests/` که آن مسیر را با کاربر بیگانه میزند و 403 انتظار دارد.
|
||||
|
||||
---
|
||||
|
||||
### ۶. آدیت فرار از tenant
|
||||
|
||||
`docs/architecture/tenancy.md` صریح میگوید `TenantFilter` یک تور ایمنی است، نه authorization،
|
||||
و روی سه چیز اعمال **نمیشود**: SQL خام، `getReference()`، و فرزندان aggregate.
|
||||
|
||||
```bash
|
||||
# SQL خام
|
||||
grep -rnE "createNativeQuery|->getConnection\(\)|executeQuery\(|executeStatement\(" src/ --include=*.php
|
||||
|
||||
# getReference
|
||||
grep -rn "getReference(" src/ --include=*.php
|
||||
|
||||
# entityهای بدون طبقهبندی tenant
|
||||
ddev exec php bin/phpunit --filter TenantSchemaCoverageTest
|
||||
```
|
||||
|
||||
هر hit را بخوان و جواب بده: ورودی کاربر مستقیم وارد کوئری میشود؟ tenant دستی چک شده؟
|
||||
|
||||
**نحوه تست:** `TenantSchemaCoverageTest` سبز باشد. برای هر SQL خامی که ورودی کاربر میگیرد،
|
||||
یک تست تزریق با payload واقعی (`' OR '1'='1`, `1; DROP`) و تأیید اینکه پارامتریسازی شده.
|
||||
|
||||
---
|
||||
|
||||
### ۷. آدیت گاردِ پرسنل
|
||||
|
||||
الف) **نرمالسازی مسیر** (بخش «وضعیت فعلی ۲»):
|
||||
|
||||
```bash
|
||||
for P in \
|
||||
'/api/v1/patients' \
|
||||
'/api//v1/patients' \
|
||||
'/api/v1/dashboard/staff/../../patients' \
|
||||
'/api/v1/%2e%2e/v1/patients' \
|
||||
'/API/v1/patients' ; do
|
||||
printf '%s -> ' "$P"
|
||||
curl -s -o /dev/null -w '%{http_code}\n' --path-as-is \
|
||||
"https://clinic-pro.ddev.site$P" -H "Authorization: Bearer $STAFF_TOKEN"
|
||||
done
|
||||
```
|
||||
|
||||
هر چیزی جز 403/404 روی این مسیرها یافتهٔ 🟧 High است.
|
||||
|
||||
ب) **کشف مسیرهای تازهای که گارد نمیبیند.** فهرست کامل روتها را بگیر و همه را با توکن پرسنل
|
||||
بزن:
|
||||
|
||||
```bash
|
||||
ddev exec php bin/console debug:router --format=json > /tmp/routes.json
|
||||
```
|
||||
|
||||
اندازهٔ واقعی (سنجیدهشده در ۲۰۲۶-۰۸-۰۷): **۴۹۳** روت زیر `/api/`، از این تعداد **۲۲۲** روت
|
||||
`GET` و **۱۲۸** روت `GET` بدون path parameter.
|
||||
|
||||
**سقف این وظیفه همان ۱۲۸ روتِ بدون پارامتر است** — چون روت پارامتردار به uuid معتبر نیاز دارد و
|
||||
404 آن با 403 قابل تفکیک نیست. روتهای پارامتردار در وظیفهٔ ۵ (IDOR) با uuid واقعی پوشش داده
|
||||
میشوند. اگر به هر دلیل کمتر از ۱۲۸ روت زده شد، تعداد و دلیلش در گزارش بیاید — سکوت ممنوع.
|
||||
|
||||
برای هر روت درخواست بزن و کد وضعیت را ثبت کن. هر 200 خارج از allowlist یافته است.
|
||||
|
||||
ج) **کاربر چندنقشی.** با کاربر `staff + secretary`: بعد از `switch-context` به پرسنل، آیا هنوز
|
||||
به مسیرهای منشی دسترسی دارد؟ اگر بله، تصمیم بگیر این طراحی است یا نشت — و در گزارش با دلیل
|
||||
بنویس. اگر نشت است، گارد باید به **context فعال** نگاه کند نه صرفاً به مجموعهٔ نقشها.
|
||||
|
||||
**نحوه تست:** جدول `مسیر → کد وضعیت` برای هر سه بخش.
|
||||
|
||||
---
|
||||
|
||||
### ۸. آدیت پنل ادمین
|
||||
|
||||
مجوزهای UI نباید تنها لایهٔ دفاع باشند. برای هر جایی که `assets/admin/` بر اساس مجوز چیزی را
|
||||
پنهان میکند، تأیید کن endpoint متناظر هم بسته است — نتیجهٔ وظیفهٔ ۴ همین را میدهد؛ اینجا فقط
|
||||
نگاشت UI به endpoint ثبت شود.
|
||||
|
||||
علاوه بر آن:
|
||||
|
||||
- توکن JWT در `localStorage['clinicpro-auth']` است. تأیید کن هیچ مسیر جدیدی HTML کاربرساخته را
|
||||
بدون sanitize رندر نمیکند (`dangerouslySetInnerHTML`).
|
||||
- CSP روی `/admin` هنوز فعال است (وظیفهٔ ۳) و صفحات جدید (treatment، staff، permissions) خطای
|
||||
CSP در کنسول نمیدهند.
|
||||
|
||||
```bash
|
||||
grep -rn "dangerouslySetInnerHTML" assets/admin/
|
||||
```
|
||||
|
||||
**نحوه تست:** لود هر صفحهٔ جدید پنل و ثبت خطاهای کنسول؛ اگر درایور `qa-clinicpro` این را
|
||||
میدهد، از همان استفاده کن.
|
||||
|
||||
---
|
||||
|
||||
### ۹. رفع
|
||||
|
||||
طبق سیاست تعیینشده:
|
||||
|
||||
- **Critical/High:** همان جلسه رفع + تست رگرسیون در `tests/`. هر رفع کوچک و جدا باشد، نه یک دیف
|
||||
بزرگ.
|
||||
- **Medium و پایینتر:** فهرست پیشنهاد با دیف پیشنهادی به کاربر نشان بده و **منتظر تأیید بمان**.
|
||||
|
||||
قواعد پروژه هنگام رفع:
|
||||
|
||||
- گیت جدید در همان لایهای که بقیه هستند — `denyUnlessGranted` در کنترلر یا checker موجود، نه
|
||||
یک مکانیزم موازی جدید.
|
||||
- منطق در Service، کوئری در Repository، کنترلر نازک. وابستگی با constructor injection.
|
||||
- خطا با `AppException(ErrorCodes::ERR_XXX, null, $status)` و پیام فارسی از
|
||||
`src/Shared/Constant/ErrorCodes.php`. کد جدید لازم شد، همانجا اضافه شود.
|
||||
- اگر Entity عوض شد: `doctrine:migrations:diff` سپس `migrate`.
|
||||
|
||||
**نحوه تست:**
|
||||
|
||||
```bash
|
||||
ddev exec php bin/phpunit
|
||||
ddev exec php vendor/bin/phpstan analyse
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ۱۰. گزارش
|
||||
|
||||
فایل `docs/security/AUDIT-2026-08-07.md` با همان ساختار گزارش ۲۰۲۶-۰۷-۱۹:
|
||||
|
||||
1. خلاصهٔ وضعیت — جدول `# / یافته / شدت / وضعیت`
|
||||
2. جدول creds نقشها که آدیت با آن اجرا شد
|
||||
3. برای هر یافته: فایل و خط، ریسک، شرح، **بازتولید با خروجی واقعی**، رفع اعمالشده، علت انتخاب
|
||||
راهحل
|
||||
4. جدول کامل ماتریس مجوز (وظیفهٔ ۴)
|
||||
5. «آنچه سالم بود» — چیزهایی که تست شدند و مشکلی نداشتند
|
||||
6. «leadهایی که رد شدند» با دلیل
|
||||
7. «محدودیت پوشش» — صادقانه، چه چیزی تست نشد و چرا
|
||||
8. «risk پذیرفتهشده» — مهاجرت CKEditor، با ارجاع به تصمیم امروز
|
||||
|
||||
---
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **قانون گزارش:** هیچ یافتهای بدون بازتولیدِ اجراشده ثبت نمیشود. خروجی درایور lead است، نه
|
||||
یافته. این قانون از گزارش قبلی میآید و باید حفظ شود.
|
||||
- **دلتا یعنی دلتا.** آدیت کامل OWASP از صفر تکرار نشود. اگر یک ناحیه در ۲۰۲۶-۰۷-۱۹ سبز بود و
|
||||
کدش عوض نشده، فقط اشاره شود که regression شد؛ عمیق نشو.
|
||||
- **نقش ≠ مالکیت.** `#[IsGranted('ROLE_STAFF')]` فقط میگوید کاربر پرسنل است، نه اینکه این جلسه
|
||||
مال اوست. هر جا فقط نقش چک شده و مالکیت نه، یک lead است.
|
||||
- **`TenantFilter` را authorization فرض نکن.** روی SQL خام، `getReference()` و فرزندان aggregate
|
||||
اعمال نمیشود. `docs/architecture/tenancy.md` مرجع است.
|
||||
- **پیام خطا نشت ندهد.** روی منبع tenant دیگر، تفاوت پیام «یافت نشد» و «دسترسی ندارید» خودش
|
||||
یک enumeration oracle است. رفتار فعلی را ثبت کن؛ اگر ناسازگار بود، یکدست کن.
|
||||
- **رفع نباید رفتار مجاز را بشکند.** قبل از هر گیت جدید، مسیر مجازِ همان اندپوینت با نقش درست
|
||||
تست شود که هنوز 200 میدهد.
|
||||
- **`docs/api/`** — هر تغییر در قرارداد، وضعیت یا مجوز یک endpoint، همان جلسه در فایل متناظر
|
||||
ثبت شود. این قانون ایستادهٔ پروژه است.
|
||||
- **`nobat724_front`** کلاینت همین API است. اگر گیتی روی endpointی اضافه شد که سایت عمومی صرف
|
||||
میکند، در گزارش هشدار بده — build کلاینت خطا نمیدهد.
|
||||
- **بدون DoS.** آدیت روی محیط لوکال ddev اجرا میشود. تست rate limit با چند درخواست شمارشی، نه
|
||||
با سیل ترافیک.
|
||||
- **secret واقعی در گزارش ننویس.** مقدار را ماسک کن و فقط فایل و خط را بده.
|
||||
@@ -33,12 +33,17 @@ const PORT = Number(process.env.CDP_PORT ?? 9444);
|
||||
* provisioned by SKILL.md § Phase 0 and report `✗` from `driver.mjs roles`
|
||||
* until they are. TEST_USERS.md is stale — its accounts do not exist.
|
||||
*/
|
||||
// Re-verified 2026-08-07 by real logins: the DB was reseeded, so the old clinic
|
||||
// and secretary numbers are gone and 09390039833 is now ROLE_CLINIC, not doctor.
|
||||
// `staff` was seeded with its mobile as the password rather than QaTest@1234.
|
||||
const ROLES = {
|
||||
admin: ['09120671756', 'QaTest@1234'],
|
||||
clinic: ['09127000000', 'QaTest@1234'],
|
||||
secretary: ['09123456778', 'QaTest@1234'],
|
||||
doctor: ['09390039833', 'QaTest@1234'],
|
||||
clinic: ['09390039833', 'QaTest@1234'],
|
||||
secretary: ['0912000109', 'QaTest@1234'],
|
||||
doctor: ['0912000101', 'QaTest@1234'],
|
||||
representation: ['09124000001', 'QaTest@1234'],
|
||||
staff: ['09128726723', '09128726723'],
|
||||
multirole: ['0912000201', 'QaTest@1234'],
|
||||
|
||||
// Provisioned by Phase 0. Reserved QA range 0912900000x, password QaTest@1234.
|
||||
doctor_solo: ['09129000001', 'QaTest@1234'], // own office, no clinic
|
||||
|
||||
Reference in New Issue
Block a user