# گزارش آدیت امنیتی ClinicPro — ۲۰۲۶-۰۸-۰۷ **نوع:** آدیت **دلتایی** روی سطح حملهٔ ساخته‌شده بعد از `AUDIT-2026-07-19.md` — ۳۵۴ کامیت، عمدتاً رجیستری مجوزها، دامنهٔ جدید `Treatment`، نقش/پنل `staff` و جداسازی محیط. **روش:** درایور `symfony-security-audit` (white-box: deps/sinks/guards/secrets/config — black-box: authz/headers/cors/inject) + پروب‌های دستی با JWT واقعی هر نقش روی اپ در حال اجرا + بازرسی کد. **تارگت:** `clinicpro/` روی ddev — Symfony 7.4، PHP 8.3.31، Doctrine ORM 3.6، LexikJWT، MariaDB 11.8، React 19 admin. > **قانون گزارش:** هیچ یافته‌ای بدون بازتولیدِ اجراشده ثبت نشده. خروجی درایور lead است نه یافته؛ > leadهایی که پس از خواندن کد «مثبت کاذب» بودند، در بخش «رد شد» با دلیل آمده‌اند. --- ## خلاصهٔ وضعیت | # | یافته | شدت | وضعیت | |---|-------|-----|-------| | 1 | `TreatmentProtocolController` هیچ گِیت مجوزی نداشت — منشیِ `services:false` می‌توانست پروتکل درمان را بخواند، بازنویسی و حذف کند | 🟧 High | ✅ رفع شد | | 2 | `react-router` — ۵ advisory از جمله XSS و open redirect | 🟧 High | ✅ رفع شد (مهاجرت به v8) | | 3 | `lodash-es` — code injection در `_.template` + دو prototype pollution | 🟧 High | ✅ رفع شد (override به 4.18.1) | | 4 | ۶۲ moderate در `@ckeditor/ckeditor5-build-classic` (deprecated) | 🟨 Medium | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ (مهاجرت به `ckeditor5@48`) | | 5 | `dangerouslySetInnerHTML` روی بدنهٔ بلاگ در `BlogReviewPage` | 🟨 Medium | ✅ رفع شد (sanitize هنگام ذخیره) | | 6 | `APP_SECRET` واقعی در `.env.test` تحت git | 🟦 Low | ✅ رفع شد | | 7 | پسورد sandbox درگاه ملت هاردکد | ⬜ Info | ✅ رفع شد (به env منتقل شد) | | 8 | ۱۰ روت `GET` که مجوزِ رجیستری‌شان را enforce نمی‌کنند | 🟨 Medium | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ | | 9 | `AppointmentPlanController` هیچ گِیت مجوزی نداشت — دوقلوی یافتهٔ ۱ | 🟧 High | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ | | 10 | ۳۴ روت نوشتنی که گِیتشان **بعد از** واکشی رکورد است | 🟦 Low | ◐ نیمه‌رفع — ۲۲ روت بسته شد، ۱۲ روت باز | | 11 | `MyAppointmentsController::$branches` تزریق نشده بود — اتصال نوبت به جلسهٔ درمان همیشه ۵۰۰ می‌داد | 🟧 High | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ | | 12 | سیاست پاک‌سازی، `style` جدول را می‌برد — رگرسیونِ ظاهریِ ناشی از رفعِ یافتهٔ ۵ | 🟦 Low | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ | سیاست اولیه «فقط Critical/High رفع شود» بود؛ کاربر بعداً رفعِ همهٔ یافته‌های باز را خواست، پس یافته‌های ۲، ۳، ۵، ۶ و ۷ هم بسته شدند. یافتهٔ ۸ حین همین کار کشف شد. یافته‌های ۹ تا ۱۲ در جلسه‌های ۲۰۲۶-۰۸-۰۸ کشف شدند، حین بستنِ یافته‌های ۸ و ۴. شرحشان در بخش «پیگیری ۲۰۲۶-۰۸-۰۸» انتهای همین سند است. ```bash npm audit --omit=dev # ۲۰۲۶-۰۸-۰۷ قبل: high=2 moderate=62 · بعد: high=0 moderate=3 # ۲۰۲۶-۰۸-۰۸ پس از مهاجرت CKEditor: ۰ آسیب‌پذیری ddev exec php bin/phpunit # ۱۵۶۸ تست سبز (بود ۱۵۵۵) ddev exec php vendor/bin/phpstan # No errors (بود ۱۷ خطا بیرون از baseline) ``` --- ## ماتریس نقش‌هایی که آدیت با آن اجرا شد آدیت ۲۰۲۶-۰۷-۱۹ در بخش «محدودیت پوشش» نوشته بود ماتریس authz ناقص مانده چون فقط `admin` و `doctor` در DB بودند. آن محدودیت اینجا برطرف شد. DB از آن زمان دوباره seed شده، پس جدول قبلی بی‌اعتبار بود و از نو ساخته شد: ``` admin 09120671756 QaTest@1234 ROLE_USER,ROLE_ADMIN clinic 09390039833 QaTest@1234 ROLE_USER,ROLE_CLINIC (مالک کلینیک ۳) secretary 0912000109 QaTest@1234 ROLE_USER,ROLE_SECRETARY doctor 0912000101 QaTest@1234 ROLE_USER,ROLE_DOCTOR representation 09124000001 QaTest@1234 ROLE_USER,ROLE_REPRESENTATION staff 09128726723 09128726723 ROLE_USER,ROLE_STAFF (پرسنل کلینیک ۳) multirole 0912000201 QaTest@1234 ROLE_USER,ROLE_DOCTOR,ROLE_CLINIC (مالک کلینیک ۱) ``` اکانت‌های `09127000000` و `09123456778` که در گزارش قبلی بودند دیگر در DB وجود ندارند، و `09390039833` که «doctor» ثبت شده بود حالا `ROLE_CLINIC` است. ماتریس در هر دو درایور (`symfony-security-audit` و `qa-clinicpro`) اصلاح و همگام شد. هیچ کاربری ساخته نشد و هیچ پسوردی عوض نشد — همه از قبل موجود بودند. **جفت tenant برای تست IDOR:** مهاجم = `multirole` (مالک کلینیک ۱)، قربانی = کلینیک ۳ که تنها tenant دارای دادهٔ `Treatment` است (۴ پرونده، ۳ جلسه، ۱ پروتکل). --- ## یافته‌ها ### 1. 🟧 HIGH — پروتکل درمان بدون هیچ گِیت مجوزی **۱. فایل:** `src/Treatment/Controller/TreatmentProtocolController.php` (خطوط ۳۷، ۵۷، ۷۲ نسخهٔ قبل) **۲. ریسک:** هر کاربرِ احرازشده داخل یک tenant — از جمله منشی‌ای که توگل «سرویس‌ها» برایش کاملاً بسته است — می‌توانست پروتکل درمانِ هر سرویس همان tenant را بخواند، کل آن را بازنویسی کند، یا حذفش کند. حذف پروتکل یعنی سرویس چندجلسه‌ای به تک‌جلسه‌ای برمی‌گردد: برنامهٔ درمان بیمار، فاصلهٔ جلسات و فهرست پرسنل مجاز از بین می‌رود. **۳. شرح:** `TreatmentCaseController` گیت درست دارد و هر متدش `denyUnlessGranted($user, 'treatment', $action)` صدا می‌زند. `TreatmentProtocolController` — که در همان دامنه و همان کامیت‌ها ساخته شده — این کار را **نمی‌کرد**. تنها دفاعش `requireItem()` بود که فقط مالکیتِ tenant را می‌سنجد: ```php private function requireItem(User $user, string $uuid): ServiceItem { $item = $this->items->findByUuid($uuid); [$entityType, $entityId] = $this->branches->pair($user); if ($item === null || $item->getSection()->getEntityType() !== $entityType || $item->getSection()->getEntityId() !== $entityId ) { throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404); } return $item; } ``` مالکیت ≠ مجوز. عبور از این تابع فقط ثابت می‌کند سرویس مالِ همین محیط است، نه اینکه این کاربر اجازهٔ دست‌زدن به آن را دارد. **۴. کشف چطور شد:** ناهمگونی بین دو کنترلرِ یک دامنه در بازرسی کد دیده شد، بعد با پروب واقعی تأیید شد. درایور آن را نداده بود — `guards` فقط route بدون `#[IsGranted]` را می‌بیند و این کنترلر `#[IsGranted('IS_AUTHENTICATED_FULLY')]` سطح-کلاس داشت. **۵. بازتولید (قبل از رفع):** منشیِ کلینیک ۱ (`0912000209`) که در DB `services: {view:false, create:false, update:false, delete:false}` دارد، روی سرویسِ **همان کلینیک**: ``` sec services:false | GET /api/v1/service-item//treatment-protocol | 200 | sec services:false | PUT /api/v1/service-item//treatment-protocol | 422 | ERR_VALIDATION_001 sec services:false | DELETE /api/v1/service-item//treatment-protocol | 200 | sec services:false | GET /api/v1/service-items | 403 | ERR_FORBIDDEN_001 ``` خط آخر شاهدِ ماجراست: همان مجوز روی فهرست سرویس‌ها **کار می‌کند** و ۴۰۳ می‌دهد. یعنی مشکل پیکربندی مجوزِ این منشی نیست، نبودِ گیت در این کنترلر است. `422` روی `PUT` مهم‌تر از `200` است: کد خطا `ERR_VALIDATION_001` از داخل `TreatmentProtocolWriter` می‌آید، یعنی درخواست از لایهٔ authorization **عبور کرده** و فقط سر اعتبارسنجیِ فیلد افتاده. با یک بدنهٔ معتبر، نوشتن انجام می‌شد. **۶. رفع اعمال‌شده:** همان الگوی `ServiceCatalogController` و `ClinicServiceController` — ```php private function denyUnlessGranted(User $user, string $action): void { $this->secretaryAccess->denyUnlessGranted($user, 'services', $action); $this->clinicDoctorAccess->denyUnlessGranted($user, 'services', $action); } ``` و صدا زدنش در ابتدای هر سه متد: `show` → `view`، `replace` → `update`، `delete` → `update`. **۷. علت انتخاب راه‌حل:** - **منبع `services` نه `treatment`:** پروتکل یک خاصیتِ `ServiceItem` است. صفحه‌اش هم داخل کاتالوگ سرویس‌هاست، نه در پروندهٔ درمان. اگر `treatment` می‌گرفت، منشی‌ای که فقط اجازهٔ دیدن دورهٔ درمان دارد می‌توانست تعریفِ سرویس را عوض کند. - **`delete` با `update` نه `delete`:** سرویس حذف نمی‌شود؛ فقط سوییچِ «طول درمان» روی همان سرویس خاموش می‌شود. اگر `services.delete` می‌گرفت، مدیری که اجازهٔ ویرایش سرویس دارد ولی اجازهٔ حذفش را ندارد نمی‌توانست سوییچی را که خودش روشن کرده خاموش کند. - **گیت پیش از `requireItem`:** ترتیب عمدی است. اگر بعد از آن بود، uuid ناموجود ۴۰۴ می‌داد و همین تفاوت ۴۰۳/۴۰۴ به کاربرِ بدون مجوز می‌گفت کدام uuidها در این tenant وجود دارند. - **مکانیزم موازی نساختیم:** همان دو checker موجود تزریق شدند، نه یک voter تازه. **۸. تأیید رفع — هر سه سناریوی معیار پذیرش:** ``` ❌ sec services:false | GET | 403 | ERR_FORBIDDEN_001 ❌ sec services:false | PUT | 403 | ERR_FORBIDDEN_001 ❌ sec services:false | DELETE | 403 | ERR_FORBIDDEN_001 ✅ owner (clinic A) | GET | 200 | ✅ owner (clinic B) | GET | 200 | ⚠️ A→B cross-tenant | GET | 404 | ERR_NOT_FOUND_001 ``` مسیر مجاز نشکست و تفکیک tenant همچنان ۴۰۴ می‌دهد نه ۴۰۳. **۹. تست رگرسیون:** چهار تست در `tests/Secretary/SecretaryResourceEnforcementTest.php` — `testTreatmentProtocolReadDeniedByDefault`، `testTreatmentProtocolReadAllowedWhenServicesGranted`، `testTreatmentProtocolWriteNeedsServicesUpdate`، `testTreatmentProtocolWriteAllowedWhenServicesUpdateGranted`. کل فایل: ۳۳ تست، ۴۸ assertion، سبز. **۱۰. مستندات:** `docs/api/treatment.md` — هر سه اندپوینت با مجوز جدید و دلیل انتخابش. --- ### 2. 🟧 HIGH — `react-router` با پنج advisory باز **۱. فایل:** `package.json` → `react-router` (range آسیب‌پذیر: `6.0.0 - 8.2.0`) **۲. ریسک:** XSS، open redirect و DoS در لایهٔ روتینگِ پنل ادمین. **۳. شرح:** این یافته در آدیت ۲۰۲۶-۰۷-۱۹ **نبود** — آنجا فقط یک High (lodash) گزارش شده بود. پنج advisory: - `GHSA-wrjc-x8rr-h8h6` — open redirect با بک‌اسلش در `` و `useNavigate` (دور زدن CVE-2025-68470) - `GHSA-h8fp-f39c-q6mh` — `RSCErrorHandler` بدون اعتبارسنجی protocol (XSS) - `GHSA-337j-9hxr-rhxg` — تزریق constructor دلخواه در `deserializeErrors()` - `GHSA-chx6-hx7r-mcp5` — DoS احرازنشده با route matching ناکارآمد - `GHSA-qwww-vcr4-c8h2` — دور زدن CSRF در حالت RSC **۴. بازتولید:** ```bash npm audit --omit=dev --json | python3 -c "import json,sys; print(json.load(sys.stdin)['metadata']['vulnerabilities'])" # → {'info': 0, 'low': 0, 'moderate': 62, 'high': 2, 'critical': 0, 'total': 64} ``` **۵. رفع اعمال‌شده:** مهاجرت از `react-router-dom@7.17.0` به `react-router@8.3.0`. نسخهٔ امن فقط `> 8.2.0` است و در v8 پکیج `react-router-dom` دیگر منتشر نمی‌شود (آخرین نسخه‌اش `7.18.2` است) — یعنی bump ساده ممکن نبود و مهاجرت اجباری بود: ```bash npm pkg delete dependencies.react-router-dom npm pkg set dependencies.react-router="^8.3.0" # ۸۱ فایل: from 'react-router-dom' → from 'react-router' ``` **۶. چرا کم‌ریسک بود:** کل APIهای مصرف‌شده در پنل ۱۱ تاست و همه در v8 دست‌نخورده‌اند — `BrowserRouter`, `MemoryRouter`, `Routes`, `Route`, `Link`, `NavLink`, `Navigate`, `Outlet`, `useLocation`, `useNavigate`, `useParams`, `useSearchParams`. هیچ API حذف‌شده‌ای در پروژه استفاده نمی‌شد، پس مهاجرت فقط تغییرِ نامِ ماژول بود نه بازنویسیِ روتینگ. **۷. تأیید:** ``` npx tsc --noEmit # بدون خطا ddev exec yarn dev # webpack compiled successfully — 54 فایل npx vitest --run # 802 تست فرانت‌اند ``` هفت تستِ فرانت‌اند در اجرای موازیِ اول timeout دادند؛ با `--no-file-parallelism` هر ۵۴ تستِ همان فایل‌ها سبز شد. یعنی گرسنگی منابع بود نه رگرسیونِ روتینگ. --- ### 3. 🟧 HIGH — `lodash-es` هنوز آسیب‌پذیر **۱. فایل:** `package.json` → `lodash-es` (`<=4.17.23`) **۲. شرح:** در آدیت ۲۰۲۶-۰۷-۱۹ «نیمه‌رفع» ثبت شده بود. هنوز باز است، و حالا دو advisory دیگر هم اضافه شده: prototype pollution در `_.unset` و `_.omit`. **۳. بازتولید:** همان `npm audit` بالا — `high=2` که یکی react-router است و یکی lodash-es. **۴. منشأ:** ردیابی شد — کاملاً transitive و فقط از یک جا می‌آید: ``` clinicpro └─┬ @ckeditor/ckeditor5-build-classic@44.3.0 └── lodash-es@4.17.21 (در ده‌ها زیرپکیج dedupe شده) ``` **۵. رفع اعمال‌شده:** چون هیچ dependency مستقیمی نیست، ارتقای مستقیم ممکن نبود. نسخهٔ امن `4.18.1` است (range آسیب‌پذیر `<=4.17.23`)، پس با override اعمال شد: ```json "overrides": { "lodash": "^4.17.21", "lodash-es": "^4.18.1" } ``` **۶. چرا override و نه ارتقای CKEditor:** بستنِ این یافته از راه CKEditor یعنی همان مهاجرتی که در یافتهٔ ۴ عمداً خارج از محدوده گذاشته شد. override همان مشکل را بدون لمس‌کردن ادیتور می‌بندد. `lodash-es` در بازهٔ 4.17→4.18 شکستِ API ندارد و CKEditor فقط از توابع پایه‌اش استفاده می‌کند. **۷. تأیید:** `npm audit --omit=dev` دیگر `lodash-es` را گزارش نمی‌کند؛ build و ۸۰۲ تست فرانت‌اند سبز. --- ### 4. 🟨 MEDIUM — CKEditor (risk پذیرفته‌شده) `@ckeditor/ckeditor5-build-classic@44.3.0` آخرین نسخهٔ آن پکیج است و **deprecated**؛ ۶۲ moderate دارد (۶۱ در گزارش قبلی، حالا ۶۲). رفع واقعی یعنی مهاجرت به پکیج umbrella `ckeditor5` v45+. **تصمیم ۲۰۲۶-۰۸-۰۷:** عمداً خارج از محدودهٔ این آدیت. دلیل: refactor ادیتور بلاگ ریسک شکستن دارد و ربطی به کد سه هفتهٔ اخیر ندارد. این XSSها moderate و admin-only هستند. تسک جدا لازم است. --- ### 5. 🟨 MEDIUM — `dangerouslySetInnerHTML` روی بدنهٔ بلاگ **۱. فایل:** `assets/admin/pages/BlogReviewPage.tsx:205` ```tsx dangerouslySetInnerHTML={{ __html: full.body }} ``` **۲. ریسک:** HTML ذخیره‌شدهٔ پست بلاگ بدون sanitize رندر می‌شود. زنجیرهٔ واقعیِ سوءاستفاده همان CKEditor بالاست: نویسنده‌ای که HTML مخرب paste کند، آن را روی صفحهٔ بازبینی به ادمین می‌رساند. **۳. بازتولید:** `grep -rn "dangerouslySetInnerHTML" assets/admin/` → تنها یک hit، همین خط. **۴. رفع اعمال‌شده:** پاک‌سازی HTML در **لحظهٔ ذخیره** با `symfony/html-sanitizer`. - `config/packages/html_sanitizer.yaml` — سیاست `blog.body`: فهرست سفیدِ عناصری که CKEditor واقعاً تولید می‌کند، طرح‌های مجاز فقط `http`/`https`/`mailto`، و `rel="noopener noreferrer"` اجباری روی هر ``. - `src/Blog/Service/BlogBodySanitizer.php` — سرویس نازک روی همان sanitizer. - هر چهار نقطهٔ ورودِ بدنه: ساخت و ویرایش در `BlogController` و در `RepresentationBlogController`. **۵. دو تصمیم طراحی:** - **ذخیره، نه نمایش.** بدنه چند مصرف‌کننده دارد — پنل ادمین، سایت عمومی `nobat724_front` و فید. اگر پاک‌سازی در لایهٔ نمایش بود، هر مصرف‌کنندهٔ تازه دوباره آسیب‌پذیر شروع می‌کرد. - **`drop_elements` نه `block_elements`.** اولین پیاده‌سازی `block` بود و تست قرمز شد: `block` تگ را برمی‌دارد ولی متنِ داخلش را نگه می‌دارد، پس `` به متنِ `alert(1)` تبدیل می‌شد. برای این عناصر خودِ محتوا هم باید برود. **۶. پاک‌سازی پیش از سنجشِ خالی‌بودن:** بدنه‌ای که چیزی جز markup ناامن ندارد، بعد از پاک‌سازی خالی می‌شود و باید همان ۴۲۲ «الزامی است» را بگیرد، نه اینکه خالی ذخیره شود. **۷. تست:** `tests/Blog/BlogBodySanitizerTest.php` — پنج تست: حذف `