Files
clinicpro/docs/security/AUDIT-2026-08-07.md
T
hamed 47323daa27 feat: add RichTextEditor component for rich text editing in articles
feat: create SanitizeBlogBodiesCommand to clean existing blog bodies according to current HTML sanitization policies

test: add AppointmentTreatmentSessionLinkTest to ensure appointment booking functionality works correctly with treatment session links
2026-08-08 11:40:17 +03:30

864 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# گزارش آدیت امنیتی 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/<uuid>/treatment-protocol | 200 |
sec services:false | PUT /api/v1/service-item/<uuid>/treatment-protocol | 422 | ERR_VALIDATION_001
sec services:false | DELETE /api/v1/service-item/<uuid>/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 با بک‌اسلش در `<Link>` و `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"` اجباری روی
هر `<a>`.
- `src/Blog/Service/BlogBodySanitizer.php` — سرویس نازک روی همان sanitizer.
- هر چهار نقطهٔ ورودِ بدنه: ساخت و ویرایش در `BlogController` و در `RepresentationBlogController`.
**۵. دو تصمیم طراحی:**
- **ذخیره، نه نمایش.** بدنه چند مصرف‌کننده دارد — پنل ادمین، سایت عمومی `nobat724_front` و فید. اگر
پاک‌سازی در لایهٔ نمایش بود، هر مصرف‌کنندهٔ تازه دوباره آسیب‌پذیر شروع می‌کرد.
- **`drop_elements` نه `block_elements`.** اولین پیاده‌سازی `block` بود و تست قرمز شد:
`block` تگ را برمی‌دارد ولی متنِ داخلش را نگه می‌دارد، پس `<script>alert(1)</script>` به متنِ
`alert(1)` تبدیل می‌شد. برای این عناصر خودِ محتوا هم باید برود.
**۶. پاک‌سازی پیش از سنجشِ خالی‌بودن:** بدنه‌ای که چیزی جز markup ناامن ندارد، بعد از پاک‌سازی خالی
می‌شود و باید همان ۴۲۲ «الزامی است» را بگیرد، نه اینکه خالی ذخیره شود.
**۷. تست:** `tests/Blog/BlogBodySanitizerTest.php` — پنج تست: حذف `<script>`، حذف `onclick` و
`onerror` و `javascript:`، بقای متنِ غنیِ سالم به‌همراه `noopener`، پاک‌سازی مسیر ویرایش، و ۴۲۲
برای بدنهٔ کاملاً ناامن. کل `tests/Blog/`: ۵۱ تست سبز.
---
### 6. 🟦 LOW — `APP_SECRET` واقعی در `.env.test`
```bash
git ls-files | grep -E '^\.env' | xargs grep -nE 'APP_SECRET=.+'
# → .env.test:3:APP_SECRET='$ecretf0rt3st'
# بقیهٔ فایل‌ها فقط placeholder دارند: CHANGE_ME…, APP_SECRET=…, APP_SECRET=...
```
`.env.dev` که یافتهٔ ۳ گزارش قبلی بود، تمیز است — آن رفع پابرجاست.
**رفع:** مقدار به `not-a-secret-test-env-only` تغییر کرد. آشکارا غیرعملیاتی بودنِ مقدار خودش
جلوی این را می‌گیرد که کسی این فایل را منبع یک secret واقعی بپندارد.
---
### 7. ⬜ INFO — پسورد sandbox درگاه ملت
**۱. فایل:** `src/Payment/Gateway/MellatGateway.php:23`
`private const SANDBOX_PASSWORD = '17384843'`. همان یافتهٔ ۵ گزارش قبلی.
**۲. ماهیت:** اعتبارنامهٔ نمایشیِ `banktest.ir` است — عمومی و منتشرشده در مستندات خودشان، پس
افشای secret نیست. ولی رشتهٔ پسوردمانند در `src/` هم اسکنر را روشن می‌کند و هم جایگزینی با ترمینال
تستِ دیگر را نیازمند ویرایش کد می‌کند.
**۳. رفع اعمال‌شده:** هر سه مقدار به constructor منتقل شدند و از env می‌آیند:
```yaml
# config/services.yaml
$sandboxTerminalId: '%env(default::MELLAT_SANDBOX_TERMINAL_ID)%'
$sandboxUsername: '%env(default::MELLAT_SANDBOX_USERNAME)%'
$sandboxPassword: '%env(default::MELLAT_SANDBOX_PASSWORD)%'
```
شناسهٔ ترمینال و نام کاربری پیش‌فرضِ درون‌کد دارند (شناسه‌اند، نه اعتبارنامه)، ولی **پسورد
عمداً پیش‌فرضِ درون‌کد ندارد**: نبودنِ env یعنی sandbox پیکربندی نشده، و آن بهتر از نگه‌داشتن رشتهٔ
پسوردمانند در `src/` است. مقدار نمایشی در `.env` با توضیح صریح نشست.
**۴. تأیید:** `tests/Payment/` — ۱۴ تست سبز.
---
### 8. 🟨 MEDIUM — ده روت `GET` که مجوزِ رجیستری‌شان را enforce نمی‌کنند
**۱. کشف چطور شد:** حین ساختِ تور ایمنیِ ساختاری (پیشنهاد ۵ همین گزارش). تست یک منشی می‌سازد که
**هر** مجوزِ `PermissionCatalog` برایش خاموش است و هر روتِ `GET` بدون path parameter را با او
می‌زند. ۲۹ روت پاسخ موفق دادند؛ پس از triage روی محتوای پاسخ، ۱۹ تای‌شان موجه بودند (دادهٔ مرجعِ
ثابت، اندپوینت عمومی، یا دادهٔ خودِ کاربر) و ۱۰ تا نه:
| روت | مجوزی که باید enforce شود |
|---|---|
| `/api/v1/appointments/user` | `appointments.view` |
| `/api/v1/my/appointments` | `appointments.view` |
| `/api/v1/my/appointments/today-stats` | `appointments.view` |
| `/api/v1/my/billing/payments` | `payments.view` |
| `/api/v1/my/billing/payments/summary` | `payments.view` |
| `/api/v1/billing/claims` | `payments.view` |
| `/api/v1/billing/claims/by-patient` | `payments.view` |
| `/api/v1/billing/reports/insurance-debt` | `payments.view` |
| `/api/v1/doctor-services` | `services.view` |
| `/api/v1/insurances` | `insurances.view` |
**۲. ریسک:** همان الگوی یافتهٔ ۱، در مقیاس بزرگ‌تر: منشی‌ای که توگلِ «نوبت‌ها» یا «پرداخت‌ها»
برایش بسته است، با درخواست مستقیم به API همان داده را می‌گیرد. توگل فقط دکمه را در پنل پنهان
می‌کند.
**۳. چرا در DB تست خالی به نظر می‌رسد:** tenantِ تستی داده ندارد، پس پاسخ `{"data":[]}` است. این
دلیل امن‌بودن نیست — در tenant واقعی دادهٔ واقعی برمی‌گردد.
**۴. وضعیت: باز، ولی مهارشده.** رفع نشد چون هر سه کنترلرِ درگیر
(`BillingController`، `MyAppointmentsController`، `DoctorServiceController`) **هیچ** checker
مجوزی تزریق‌شده ندارند؛ بستنشان بدون دانستنِ نیازِ واقعیِ پنل ریسکِ شکستنِ صفحه دارد — همان‌طور که
`docs/api/` برای فهرست منابع مستند کرده که `appointments.view` عمداً درش را باز می‌کند. این
تصمیم باید با دیدنِ مصرفِ واقعیِ پنل گرفته شود، نه در یک پاس امنیتی.
به‌جایش در `ApiLeastPrivilegeTest::KNOWN_GAPS` ثبت شدند. نقش آن فهرست مثل baseline است: تست اجازه
می‌دهد همین ده تا ۲۰۰ بدهند، ولی **بزرگ‌ترشدنش** را نمی‌پذیرد. یک تست دوم هم هست که اگر گَپی بسته
شد، قرمز می‌شود تا ردیفش از فهرست حذف شود — وگرنه baseline برای همیشه می‌ماند و کسی نمی‌فهمد بدهی
تسویه شده.
---
## آنچه سالم بود (تست شد، یافته نیست)
**۱. جداسازی tenant در دامنهٔ Treatment — کاملاً سالم.** مهاجم `multirole` (کلینیک ۱) روی
uuidهای واقعی کلینیک ۳:
```
A→B item | GET /api/v1/service-item/<uuid>/treatment-protocol | 404 | ERR_NOT_FOUND_001
A→B item | PUT /api/v1/service-item/<uuid>/treatment-protocol | 404 | ERR_NOT_FOUND_001
A→B item | DELETE /api/v1/service-item/<uuid>/treatment-protocol | 404 | ERR_NOT_FOUND_001
A→B case | GET /api/v1/treatment-case/<uuid> | 404 | ERR_NOT_FOUND_001
A→B case | PATCH /api/v1/treatment-case/<uuid> | 404 | ERR_NOT_FOUND_001
A→B plan | GET /api/v1/treatment-case/<uuid>/plan | 404 | ERR_NOT_FOUND_001
A→B session | GET /api/v1/treatment-session/<uuid> | 404 | ERR_NOT_FOUND_001
A→B slots | GET /api/v1/treatment-session/<uuid>/slot-suggestions| 404 | ERR_NOT_FOUND_001
A→own item | GET /api/v1/service-item/<uuid>/treatment-protocol | 200 | (شاهد مثبت)
```
هیچ نشتی. پیام خطا هم یکدست است — تفاوت «یافت نشد» و «دسترسی ندارید» به‌عنوان enumeration oracle
قابل استفاده نیست، چون هر دو حالت `404 / ERR_NOT_FOUND_001` می‌دهند.
**۲. `StaffRouteGuardSubscriber` در برابر مسیر نرمال‌نشده — سالم.** با توکن کاربرِ «فقط پرسنل»:
```
allowlist /dashboard/staff/treatment-sessions | 200 |
plain /api/v1/patients | 403 | ERR_FORBIDDEN_001
double// /api//v1/patients | 403 | ERR_FORBIDDEN_001
dotdot /api/v1/dashboard/staff/../../patients| 403 | ERR_FORBIDDEN_001
encoded /api/v1/%2e%2e/v1/patients | 403 | ERR_FORBIDDEN_001
uppercase /API/v1/patients | 404 | ERR_NOT_FOUND_001
semicolon /api/v1/dashboard/staff;/../patients | 404 | ERR_NOT_FOUND_001
trailing /api/v1/patients/ | 403 | ERR_FORBIDDEN_001
```
`getPathInfo()` سیمفونی پیش از رسیدن به گارد نرمال‌سازی می‌کند، پس ترفندهای traversal کار نمی‌کنند.
**۳. sweep کامل روت‌ها با توکن پرسنل — سالم.** هر ۱۲۸ روتِ `GET` بدون path parameter (از ۴۹۳ روت
`/api/`، که ۲۲۲ تای‌شان GET هستند) با توکن پرسنل زده شد. ۹ روت بیرون از allowlist پاسخ ۲۰۰ دادند:
```
/api/v1/blogs · /api/v1/blogs/tags · /api/v1/clinics · /api/v1/doctors
/api/v1/altcha/challenge · /api/v1/altcha/config
/api/v1/specialties · /api/v1/specialties/doctor-counts · /api/v1/tags
```
هر ۹ تا با **درخواست بدون توکن** هم ۲۰۰ می‌دهند — یعنی عمداً عمومی‌اند و نشتِ نقش پرسنل نیستند.
دو تای آن‌ها (`altcha/*`) همان leadهای درایور در بخش «routes without an explicit guard» بودند.
**۴. پیش‌فرضِ مجوزها fail-closed است.** `SecretaryPermissionChecker::can()` با
`?? false` تمام می‌شود. منبعی که بعد از ساختِ ردیف به رجیستری اضافه شده،
`PermissionCatalog::merge()` پیش‌فرضِ نقش را برایش می‌گذارد — پس ردیف‌های قدیمیِ بدون کلید
`treatment` عمداً `treatment.view = true` می‌گیرند، نه اینکه گیت دور بخورد. رفتار مستند و عمدی است.
**۵. یافته‌های آدیت قبلی — همه پابرجا:**
| یافتهٔ ۲۰۲۶-۰۷-۱۹ | وضعیت امروز | شاهد |
|---|---|---|
| نبود CSP روی `/admin` | ✅ پابرجا | هدر کامل زیر |
| `APP_SECRET` در `.env.dev` | ✅ پابرجا | `.env.dev` صفر assignment |
| فلگ‌های session cookie | ✅ پابرجا | `cookie_secure: true`, `samesite: lax`, `httponly: true` |
| وابستگی npm | ⚠️ بدتر شد | high 1→2، moderate 61→62 |
```
content-security-policy: default-src 'self'; script-src 'self'; worker-src 'self' blob:;
style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.tile.openstreetmap.org
https://unpkg.com; font-src 'self' data:; connect-src 'self' https://nominatim.openstreetmap.org
https://*.tile.openstreetmap.org; frame-ancestors 'none'; base-uri 'self'; object-src 'none'
permissions-policy: geolocation=(), microphone=(), camera=()
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
x-frame-options: DENY
```
**۶. پیکربندی چارچوب — سالم.** `composer audit` صفر advisory. `token_ttl: 900` با
`clock_skew: 5`. کلیدهای JWT خارج از git. CORS با regex از `ALLOWED_FRONTEND_HOSTS` و
`allow_credentials: false`. rate limiter روی `send_code` (۵ در ساعت) و `login` (۱۰ در دقیقه).
---
## leadهای درایور که پس از بازرسی رد شدند
درایور ۴۴ lead داد: 🟧 High ۲۹، 🟨 Medium ۴، 🟦 Low ۱۱، Critical صفر. آنچه رد شد:
| lead | چرا رد شد |
|---|---|
| `ClinicResourceRepository.php:188,213` — «QueryBuilder predicate built by interpolation» | `$field` از `match (true)` روی **نوع PHP** می‌آید (`Doctor``'doctor'`، `ClinicStaff``'staff'`)، نه از ورودی کاربر. دو مقدار ممکن، هر دو ثابت. |
| `PatientRecordRepository.php:173-179` — همان الگو | زیرکوئری‌ها ثابت‌اند و پارامترها با `setParameter` بایند می‌شوند. |
| `PurgeDoctorsCommand.php:80` — «Raw SQL with interpolated variable» | `$t` از `self::TABLES` می‌آید، ثابتِ کلاس. دستور کنسول است و از HTTP قابل فراخوانی نیست. |
| `PurgeUnclaimedDoctorsCommand.php:120` | `$where` داخل خودِ کلاس ساخته می‌شود و `$params` جدا بایند است. کنسول. |
| ۱۵ lead «Filesystem mutation / File access from a variable» در آپلود عکس | مسیرها از `FileUploadService` می‌آیند که پیش از move با `FileValidatorService` (magic-bytes) اعتبارسنجی می‌کند. الگوی `rename($tmpPath, …)` عمدی است. |
| `AccountSettingsPage.test.tsx:40` — «Hardcoded credential» | فایل تست فرانت‌اند، مقدار ساختگی. |
| `seed_testdata.php:26` و `SeedScenariosCommand.php:66``QaTest@1234` | پسورد داده‌های تست، عمدی و مستند. |
| ۶ lead «Non-cryptographic RNG» در `SeedDemoDataCommand` | تولید دادهٔ نمایشی. جای `random_int` نیست. |
| `DoctorImportService.php:88` — «Weak hash» | `md5` برای ساختِ شناسهٔ یکتای ≤۱۸ کاراکتری، نه برای امنیت. |
| `.env.*.example` — «secret-shaped values» | فقط placeholder (`CHANGE_ME…`, `…`, `...`). |
| `templates/payment/result.html.twig:140``\|raw` | خروجی `json_encode` است، نه HTML خام. |
| `altcha/challenge` و `altcha/config` بدون `#[IsGranted]` | عمداً عمومی — کپچا پیش از لاگین لازم است. با درخواست anon تأیید شد. |
---
## محدودیت پوشش (صادقانه)
- **پرسنلِ tenant دوم تست نشد.** فقط یک `ClinicStaff` در DB هست و مالِ کلینیک ۳ است. سناریوی
«پرسنل کلینیک A روی جلسهٔ کلینیک B» اجرا **نشد**. برای اجرایش باید یک پرسنل در کلینیک ۱ ساخته
شود.
- **دستکاری وضعیت جلسه تست نشد** — `finish` بدون `start`، `reopen` روی ناحیهٔ بسته، `complete`
دوباره. وظیفهٔ ۵-د پرامپت بود و اجرا نشد.
- **sweep فقط روی ۱۲۸ روتِ `GET` بدون parameter** اجرا شد. ۹۴ روت `GET` پارامتردار و همهٔ روت‌های
`POST`/`PATCH`/`DELETE` در sweep نبودند؛ زیرمجموعه‌شان در تست IDOR دستی پوشش داده شد.
- **`switch-context` با کاربر چندنقشی تست نشد.** کاربر `multirole` ساخته و تأیید شد، ولی سناریوی
«بعد از سوییچ به پرسنل هنوز به مسیر منشی می‌رسد؟» اجرا نشد.
- **`phpstan` سبز نیست** — ۱۷ خطا. **هیچ‌کدام** از تغییرات این آدیت نیستند (فایل‌ها:
`MyAppointmentsController`, `AuthController`, `BillingController`, `ClinicServiceController`,
`ServiceItem`, `DoctorClaimService`, `InventoryService`, `PatientService`,
`RecordNumberGenerator`, `ResourceController`, `SecretaryService`, `HealthController`). قبل از
این آدیت هم همین ۱۷ تا بودند.
- **تست تزریق (`inject`) روی اندپوینت‌های جدید اجرا نشد.** درایور آن را به‌صورت هدفمند می‌خواهد و
در این پاس فقط روی سطح قدیمی اجرا شده بود.
---
## تغییرات اعمال‌شده
```
src/Treatment/Controller/TreatmentProtocolController.php گیت services.view / services.update
src/Blog/Service/BlogBodySanitizer.php سرویس پاک‌سازی بدنهٔ مقاله (جدید)
src/Blog/Controller/BlogController.php پاک‌سازی در ساخت و ویرایش
src/Blog/Controller/RepresentationBlogController.php پاک‌سازی در ساخت و ویرایش
src/Payment/Gateway/MellatGateway.php اعتبارنامهٔ sandbox از env
config/packages/html_sanitizer.yaml سیاست blog.body (جدید)
config/services.yaml سه env تازهٔ MELLAT_SANDBOX_*
package.json react-router@8، override lodash-es
assets/admin/** ۸۱ فایل: react-router-dom → react-router
.env / .env.test مقادیر نمایشی با توضیح صریح
tests/Secretary/SecretaryResourceEnforcementTest.php ۴ تست رگرسیون پروتکل درمان
tests/Blog/BlogBodySanitizerTest.php ۵ تست XSS (جدید)
tests/Shared/ApiLeastPrivilegeTest.php تور ایمنی ساختاری + baseline (جدید)
docs/api/treatment.md مجوز هر سه اندپوینت
.claude/skills/qa-clinicpro/driver.mjs ماتریس نقش‌های واقعی
../.claude/skills/symfony-security-audit/driver.mjs ماتریس نقش‌ها + پرسوناهای staff و multirole
```
**تست‌ها:** `ddev exec php bin/phpunit` → ۱۵۵۵ تست، ۴۸۳۲ assertion، سبز (۱۹ PHPUnit notice، همه
از قبل موجود).
**یک درسِ جانبی از خودِ تست ساختاری:** اولین نسخه‌اش کلِ سوییت را قرمز کرد — نه خودش، بلکه
`TreatmentCaseOpenerTest` که ۲۰۰ تست بعدتر اجرا می‌شود. علتش این بود که این تست ~۱۳۰ درخواست
پشت‌سرهم می‌زند و هر درخواست کرنل را دوباره بالا می‌آورد، پس `$this->em` کهنه می‌شد و همان نمونه به
تست بعدی ارث می‌رسید. با `resetManager()` در `tearDown` بسته شد — دقیقاً همان دامی که
`ApiTestCase::setUp` قبلاً برای «EntityManager is closed» بسته بود.
**`phpstan`:** ۱۷ خطا، همان ۱۷ تای پیش از این آدیت. هیچ‌کدام در فایل‌های لمس‌شدهٔ این جلسه نیستند.
**دادهٔ DB:** پروب `DELETE` روی سرویسِ کلینیک ۱ اجرا شد؛ آن سرویس پروتکل نداشت، پس چیزی حذف نشد.
تنها پروتکلِ موجود (`service_item_id=15`، کلینیک ۳) دست‌نخورده است.
---
## پیشنهادها
1. ~~**بستنِ ده گَپِ یافتهٔ ۸**~~ — انجام شد ۲۰۲۶-۰۸-۰۸. جزئیات پایین.
2. **مهاجرت CKEditor** به پکیج umbrella `ckeditor5` v45+ — تنها راهِ بستنِ یافتهٔ ۴.
3. ~~**یک `ClinicStaff` در tenant دوم**~~ — انجام شد، ولی در fixture نه در DB زنده.
4. ~~**گسترشِ `ApiLeastPrivilegeTest` به روت‌های نوشتنی**~~ — انجام شد ۲۰۲۶-۰۸-۰۸.
5. **پاک‌سازیِ ۱۷ خطای `phpstan`** — بدهیِ قدیمی، بی‌ربط به امنیت، ولی مانعِ «سبز یعنی سبز» است.
6. **بستنِ یافتهٔ ۱۰** — گِیت را در ۳۴ روت به بالای واکشی ببر. ~۱۵ تای‌شان object-scoped
هستند و پیش‌چکِ منبع‌سطح می‌خواهند، پس تسک جدا لازم است.
---
# پیگیری ۲۰۲۶-۰۸-۰۸
جلسهٔ بعدی، با هدفِ بستنِ یافتهٔ ۸ و پیشنهادهای ۳ و ۴. دو یافتهٔ تازه حین کار پیدا شد.
## یافتهٔ ۸ — بسته شد، ولی سه ردیفش مثبت کاذب بود
فهرست ده‌تایی از نو با کد سنجیده شد. سه ردیف اصلاً گَپ نبودند:
| روت | چرا مثبت کاذب بود |
|---|---|
| `/api/v1/appointments/user` | `findByUser($user)` یعنی `a.user = خودِ کاربر`. نوبت‌های خودِ فرد به‌عنوان بیمار است، نه دادهٔ محیط. مصرف‌کننده‌اش داشبورد بیمار در `nobat724_front` است؛ گِیت‌زدنش رگرسیون بود. |
| `/api/v1/doctor-services` | `findActive()` بدون هیچ فیلترِ محیط — کاتالوگ سراسری، هم‌ردهٔ `specialties` و `tags`. |
| `/api/v1/insurances` | همان. گِیت‌زدنش یک مجوز را با نبودِ مجوزِ دیگری می‌شکست: منشیِ دارای `patients.create` فرمِ ثبت بیمار را با کمبوی خالیِ بیمه می‌گرفت. |
درسِ روشی: خروجیِ پویشِ رفتاری lead است نه یافته — حتی وقتی پویش را خودِ آدیت نوشته باشد. سه
ردیف بالا با «۲۰۰ داد پس نشت است» ثبت شده بودند، بی‌آنکه کوئریِ پشتشان خوانده شود.
هفت ردیف باقی‌مانده گَپ واقعی بودند و گِیت گرفتند. ولی چون هر سه کنترلر بی‌گِیت بودند و پویشِ
قبلی فقط `GET` بدون path parameter را می‌دید، **کلِ روت‌های نوشتنی‌شان هم باز بود**:
```
sec (بدون هیچ مجوزی) | POST /api/v1/billing/invoices | صورتحساب می‌ساخت
sec (بدون هیچ مجوزی) | POST /api/v1/billing/claims/{uuid}/pay | مطالبه را «پرداخت‌شده» می‌کرد
sec (بدون هیچ مجوزی) | POST /api/v1/my/appointment | نوبت ثبت می‌کرد
sec (بدون هیچ مجوزی) | GET /api/v1/my/appointment/patient-lookup | نام و کد ملی هر بیمار را می‌گرفت
```
پس محدوده به همهٔ روت‌های آن سه کنترلر باز شد: ۱۲ روت `BillingController` و ۵ روت
`MyAppointmentsController`.
**نگاشت مجوزها:**
- Billing خواندنی → `payments.view` · ساخت → `payments.create` · `finalize` و `transitionClaim``payments.update`
- `my/appointments`, `today-stats`, `my/clinic-doctors``appointments.view`
- `POST /my/appointment` و `patient-lookup``appointments.create`
`patient-lookup` عمداً `appointments.create` گرفت نه `patients.view`: کامنت خودِ متد می‌گوید
عمداً از گیتِ پروندهٔ بیمار جدا شده تا ثبت نوبت مستقل از اشتراک کار کند. با `patients.view`
همان قابلیت می‌شکست.
**مکانیزم:** `App\Shared\Controller\PermissionGateTrait` از دلِ `ResourcePermissionTrait` بیرون
کشیده شد؛ trait قبلی حالا رویش سوار است و فقط منبعِ پیش‌فرض و استثنای نوبت‌دهی‌اش را نگه داشته.
هیچ voter یا checker تازه‌ای ساخته نشد.
**یک تغییر رفتار که باید بدانی:** منشیِ **بدون رابطهٔ فعال** روی `/my/appointments` دیگر لیست
خالی نمی‌گیرد، `403` می‌گیرد. `SecretaryAccessChecker::can()` برای او `false` می‌دهد — همان
fail-closed مستندِ `SecretaryPermissionChecker`. «خالیِ خاموش» با «اجازه نداری» یکی نیست.
`SecretaryAppointmentScopeTest::testSecretaryWithNoAssignmentIsDenied` همین را تثبیت می‌کند.
## یافتهٔ ۹ 🟧 HIGH — `AppointmentPlanController` بدون گِیت
**۱. فایل:** `src/Appointment/Plan/Controller/AppointmentPlanController.php`
**۲. شرح:** دوقلوی یافتهٔ ۱. همان `#[IsGranted('IS_AUTHENTICATED_FULLY')]` سطح‌کلاس، و همان
`requireItem()` — کپیِ کلمه‌به‌کلمهٔ تابعی که آدیت دیروز در `TreatmentProtocolController`
ناکافی خواند. آدیت قلِ اول را بست و دوقلویش را ندید، چون `GET .../segments` پارامتر دارد و
پویشِ `GET` روت‌های پارامتردار را رد می‌کرد.
**۳. ریسک:** منشیِ `services:false` بخش‌بندیِ هر سرویسِ محیط را می‌خواند و با `PUT` کاملاً
بازنویسی می‌کرد. بخش‌بندی طولِ نوبت و منابعِ لازم را تعیین می‌کند، پس بازنویسی‌اش یعنی
به‌هم‌ریختنِ برنامهٔ همهٔ نوبت‌های آن سرویس.
**۴. رفع:** `show``services.view`، `replace``services.update`، `preview`
`services.view` **یا** `appointments.view`. «یا» عمدی است: `preview` ورودیِ فرمِ ثبت نوبت است،
پس قرینهٔ `denyUnlessGrantedForBooking`.
**۵. مستندات:** `docs/api/appointment-plan.md` بخش «مجوزها».
## یافتهٔ ۱۰ 🟦 LOW — گِیت بعد از واکشی در ۳۴ روت نوشتنی
**۱. کشف چطور شد:** حین نوشتنِ پویشِ روت‌های نوشتنی. قاعدهٔ اولیه «منشیِ بی‌مجوز فقط `403`»
بود؛ ۷۵ روت قرمز شد. پس از خواندنِ کد معلوم شد بیشترشان گِیت **دارند**، ولی بعد از
`findByUuid()` می‌نشیند، پس uuidِ ناموجود اول `404` می‌گیرد.
**۲. ریسک:** enumeration oracle. کاربرِ بی‌مجوز از تفاوت `403`/`404` می‌فهمد کدام uuid در این
محیط وجود دارد. همان چیزی که یافتهٔ ۱ عمداً با «گیت پیش از `requireItem`» بست، ولی هیچ چیزی
در بقیهٔ کد اجبارش نمی‌کرد.
**۳. چرا رفع نشد:** حدود ۱۵ تای این چک‌ها **object-scoped** هستند، نه resource-scoped —
مثلاً `ClinicController::update` چکش `permChecker->can($user, $clinic, …)` است و بدون `$clinic`
اصلاً قابل صدا زدن نیست. بالا بردنشان یعنی افزودنِ یک پیش‌چکِ منبع‌سطحِ تازه به هر کدام، نه
جابه‌جا کردن دو خط. این طراحی است، نه reorder، و در محدودهٔ این جلسه نبود.
**۴. فهرست کامل** — گروه‌بندی بر اساس کنترلر:
- `AppointmentController``updateStatus`, `confirm`, `update`, `serviceReschedule`
- `AppointmentSettingsController``createSchedule`, `updateSchedule`, `deleteSchedule`,
`createOverride`, `updateOverride`, `deleteOverride`, `createHoliday`, `updateHoliday`,
`deleteHoliday`
- `ClinicController``update`, `detachDoctor`, `createAddress`, `updateAddress`, `deleteAddress`
- `ClinicDoctorPermissionController``updatePermissions`
- `ClinicInvitationController``inviteDoctor`, `resendInvitation`, `changeInvitationStatus`,
`deleteInvitation`
- `DoctorController``update`, `delete`, `updateAddress`, `deleteAddress`
- `ResourceBlockController``create`, `delete`
- `RepresentationController``update`
- `SecretaryController``create`, `update`, `deactivate`, `syncClinicDoctors`
سه روتِ `AppointmentSettingsController` و `SecretaryController::create` پارامتر مسیری ندارند و
هدفشان از بدنه می‌آید؛ رفتارشان همان است.
**۵. مهار:** `ApiLeastPrivilegeTest::testNoApiWriteRouteSkipsItsPermissionGate` قاعده‌اش دو
سطحی است — روتِ **بدون** path parameter باید `403` بدهد، روتِ پارامتردار `403` یا `404`. پس
روت‌های این فهرست عبور می‌کنند ولی هر روتِ نوشتنیِ تازه‌ای که `2xx` یا `422` بدهد قرمز می‌شود.
## پویش روت‌های نوشتنی — نتیجه
۷۵ روتِ `POST`/`PUT`/`PATCH`/`DELETE` با منشیِ بدونِ هیچ مجوزی زده شد. پس از triage:
- **۳۴** عمداً باز — لاگین و ثبت‌نام و OTP، کال‌بک درگاه، امتیاز و کامنت بیمار، پروفایل و
کیف‌پول خودِ کاربر، و کلِ جریان رزرو عمومی. همه با دلیل در `ALLOWED_WRITE`.
- **۲** عمداً `409` — حذف سرویس و بخش سرویس اصلاً ممکن نیست، چون نوبت و فاکتور به سرویس ارجاع
دارند.
- **۳۴** یافتهٔ ۱۰ بالا.
- **۵** گَپِ واقعی که همین جلسه بسته شد — روت‌های نوشتنیِ Billing و MyAppointments و
`service_segments_replace`.
## پرسنل tenant دوم — پوشش بسته شد
`tests/Staff/StaffCrossTenantTest.php` هر دو محیط را در fixture می‌سازد. چهار تست: شاهد مثبت
(پرسنل روی جلسهٔ محیط خودش `200`)، جلسهٔ محیط دیگر روی `GET`/`start`/`finish`، ناحیهٔ جلسهٔ
محیط دیگر روی `start`/`complete`/`skip`/`reopen`، و تستِ مرزی که uuidِ ناموجود و uuidِ محیطِ
دیگر باید **دقیقاً یک پاسخ** بدهند. همه `404 / ERR_NOT_FOUND_001` — بدون نشت و بدون oracle.
برخلاف پیشنهاد ۳ گزارش، پرسنل در DB زنده ساخته **نشد**: دادهٔ دستی با اولین re-seed می‌رود و
هیچ‌وقت خودکار اجرا نمی‌شود.
## یافتهٔ ۱۱ 🟧 HIGH — `$branches` تزریق‌نشده: اتصال نوبت به جلسهٔ درمان همیشه ۵۰۰
**۱. فایل:** `src/Appointment/Controller/MyAppointmentsController.php:384`
**۲. شرح:** کنترلر `$this->branches->pair($user)` را صدا می‌زد ولی `AddressResolver` هرگز در
constructor نبود. هر `POST /api/v1/my/appointment` که `treatment_session_uuid` داشت روی
«Undefined property» می‌افتاد. یعنی اتصال نوبت به جلسهٔ درمان از پنل **هیچ‌وقت کار نکرده**.
**۳. چرا کسی ندید:** این شاخه هیچ تستی نداشت. `phpstan` دقیقاً همین را گزارش می‌کرد، ولی بین
۱۶ خطای بی‌اثرِ دیگر گم شده بود — «۱۷ خطا» عددی ثابت شده بود که همه ازش رد می‌شدند. درسش این
است که baselineِ نخوانده، باگ زنده را پنهان می‌کند.
**۴. رفع:** تزریق `AddressResolver $branches` — همان سرویسی که `AppointmentPlanController` و
`TreatmentProtocolController` با همین نام استفاده می‌کنند.
**۵. تست:** `tests/Appointment/AppointmentTreatmentSessionLinkTest.php` — سه سناریو: بدون
اتصال (۲۰۱)، uuidِ ناموجود (۴۰۴ با `treatment_session_uuid` در فیلد خطا، نه ۵۰۰)، و رشتهٔ
خالی (۲۰۱).
## یافتهٔ ۱۲ 🟦 LOW — رگرسیونِ ظاهریِ ناشی از رفعِ یافتهٔ ۵
**۱. شرح:** سیاست `html_sanitizer.yaml` برای `<table>` هیچ attributeی مجاز نکرده بود. از آنجا
که پاک‌سازی در **لحظهٔ ذخیره** است، هر مقاله‌ای که از پنل ویرایش می‌شد حاشیه و فاصلهٔ جدولش را
از دست می‌داد. رگرسیون از ۲۰۲۶-۰۸-۰۷ فعال بود و کسی ندیده بود، چون هنوز کسی مقالهٔ جدول‌دار را
ویرایش نکرده بود.
**۲. رفع:** `border`, `cellpadding`, `cellspacing` مجاز شدند — هر سه عددی و غیرقابل‌اجرا.
`style` عمداً ممنوع ماند و ظاهرِ از دست رفته (`border-collapse`, `width`) در `.blog-body`
داخل `assets/admin/styles.css` بازسازی شد؛ یعنی presentation به لایهٔ درستش رفت.
**۳. تست:** `BlogBodySanitizerTest::testTableKeepsInertLayoutAttributesButLosesStyle`.
**۴. کارِ باقی‌مانده در repo دیگر:** `nobat724_front` همان بدنه را رندر می‌کند و قاعدهٔ CSS
معادل را ندارد. جدولِ مقاله‌ها آنجا بدون `border-collapse` نمایش داده می‌شود.
## یافتهٔ ۴ — بسته شد: مهاجرت CKEditor
`@ckeditor/ckeditor5-build-classic@44.3.0` (deprecated، ۶۲ advisory) با پکیج umbrella
`ckeditor5@48.4.0` جایگزین شد. `@ckeditor/ckeditor5-react@11.2.0` از قبل نصب بود و
peer dependency اش `ckeditor5 >= 46` است، پس bump دیگری لازم نشد.
مهاجرت کم‌ریسک بود چون سطح مصرف کوچک است: دو صفحه، یک `ClassicEditor`، ده دکمهٔ نوار ابزار.
پیکربندی در کامپوننت مشترک `assets/admin/components/RichTextEditor.tsx` متمرکز شد تا مهاجرت
بعدی یک فایل باشد و نوار ابزارِ دو صفحه از هم واگرا نشود.
در پکیج umbrella، برخلاف buildِ آماده، فهرست پلاگین‌ها باید صریح باشد. عمداً دقیقاً همان
پلاگین‌های دکمه‌های قبلی آورده شد و نه بیشتر: هر پلاگین اضافه یعنی markup تازه‌ای که
`html_sanitizer.yaml` مجازش نکرده و هنگام ذخیره حذف می‌شود.
```
npm audit --omit=dev # قبل: 61 (moderate=3, low=58) → بعد: 0
npx tsc --noEmit # بدون خطا
ddev exec yarn dev # webpack compiled successfully — 54 فایل
npx vitest --run # ۸۰۱ تست فرانت‌اند سبز
```
## یافتهٔ ۱۰ — نیمه‌رفع: ۲۲ روت از ۳۴ بسته شد
چکِ اصلیِ این روت‌ها شیء‌محور است و بالا نمی‌رود — `ClinicController::update` به `$clinic`ِ همان
رکورد نیاز دارد. ولی **سهمِ منشی** از آن چک همیشه همان توگلِ رجیستری است، پس یک پیش‌چکِ
فقط-منشی اکیداً ضعیف‌تر است: هر کسی را که رد کند، چکِ پایین‌تر هم رد می‌کرد. یعنی هیچ مسیرِ
مجازی بسته نمی‌شود و فقط ۴۰۴ به ۴۰۳ تبدیل می‌شود.
`ClinicDoctorAccessChecker` عمداً در پیش‌چک نیست: پزشکِ عضو ممکن است روی رکوردِ کلینیکِ دیگری
که مالکش است اقدام کند، و آنجا محیطِ فعال با محیطِ رکورد یکی نیست. آن حالت را فقط چکِ
شیء‌محورِ پایین می‌تواند درست بسنجد.
بسته شد (۲۲): `AppointmentController` × ۴ · `AppointmentSettingsController` × ۹ ·
`ClinicController::update`, `detachDoctor` · `ClinicDoctorPermissionController` ×۱ ·
`ClinicInvitationController` × ۴ · `ResourceBlockController` × ۲.
باز ماند (۱۲) — هیچ‌کدام منبعی در `PermissionCatalog` ندارند و چکشان مالکیتِ خودِ رکورد است:
- `ClinicController``createAddress`, `updateAddress`, `deleteAddress`. `addresses` فقط
`view` دارد؛ نوشتنِ آدرس صریحاً owner-or-admin است و اصلاً قابل واگذاری نیست.
- `DoctorController``update`, `delete`, `updateAddress`, `deleteAddress`.
- `RepresentationController::update`.
- `SecretaryController``create`, `update`, `deactivate`, `syncClinicDoctors`.
پوششِ نشتِ باقی‌مانده کم‌ارزش است: uuidِ پزشک از فهرست عمومی پزشکان در دسترس است. برای منشی و
نماینده ارزشش بیشتر است ولی همچنان Low. بستنشان یا کلیدِ تازه در رجیستری می‌خواهد — که UI
مجوزها و دو Entity و تایپ‌های فرانت را درگیر می‌کند — یا یک مکانیزم موازیِ نقش‌محور، که یافتهٔ ۱
توصیه کرد نسازیم.
`ApiLeastPrivilegeTest::testHoistedGatesAnswer403BeforeTheLookup` این ۲۲ تا را قفل می‌کند: اگر
کسی خطِ پیش‌چک را بردارد، پاسخ به ۴۰۴ برمی‌گردد و تست قرمز می‌شود.
## پاک‌سازی `phpstan` — و آنچه زیرش پنهان بود
هر ۱۷ خطای بیرون از baseline بسته شد و `phpstan` حالا `No errors` می‌دهد. baseline از ۴۴ به
۴۳ ردیف رسید. جنس خطاها:
- **۱ باگ زنده** — یافتهٔ ۱۱ بالا.
- **۳ ناهم‌خوانی نوع** — `BillingController` فیلترهای `from`/`to` را string می‌فرستاد و
`InvoiceService` عددِ صحیح می‌خواست. تبدیل در مرزِ ورودی نشست، نه در repository.
- **۶ property تزریق‌شده و بلااستفاده** در پنج سرویس — حذف شدند.
- **۲ فراخوانیِ `getEntityManager()` از بیرون** در `SecretaryService` — با `EntityManagerInterface`
تزریق‌شده جایگزین شد.
- **۲ ignore pattern کهنه** که دیگر با هیچ خطایی مطابقت نداشتند.
- **۳ مقایسهٔ همیشه-درست** — ساده شدند.
دو مورد عمداً با `@phpstan-ignore-next-line` ماندند: `ServiceItem::getConsumables()` و
`getStaffMembers()`. `phpstan` فقط constructor را می‌بیند و می‌گوید property همیشه مقدار دارد؛
Doctrine اما بدون constructor هیدریت می‌کند. گاردِ `??=` عمدی است.
## پاک‌سازی مقاله‌های قدیمی
`app:blog:sanitize-bodies` ساخته شد و روی هر ۴۲۷ مقاله اجرا شد. دستور است نه migration، چون
سیاست ممکن است دوباره سفت شود و آن‌وقت باید همین گذر تکرار شود.
عددِ خام گمراه‌کننده بود: «۴۲۶ مقاله تغییر می‌کند» در نگاه اول یعنی ۴۲۶ مقالهٔ آلوده. تفکیکِ
جنسِ تغییر نشان داد:
| جنس تغییر | تعداد | یعنی چه |
|---|---|---|
| سخت‌سازی | ۳۹۸ | افزودن `rel="noopener noreferrer"` یا decode شدن `&nbsp;` |
| حذف attribute | ۲۸ | فقط `style` روی ۲۲ جدول و ۱ `div`؛ بقیه ترمیمِ HTML شکسته |
| بدون تغییر | ۱ | — |
**هیچ مقاله‌ای markup اجرایی نداشت** — نه `<script>`، نه `onerror`، نه `javascript:`. یعنی
یافتهٔ ۵ یک ریسک بالقوه را بست، نه یک نشتِ فعال را.
پس از اجرا، ۴۲۵ مقاله `rel="noopener noreferrer"` دارند و هیچ‌کدام `style=` ندارند. اجرای دوم
صفر تغییر گزارش می‌دهد.
## ناپایداریِ سوییت — ریشه‌اش پیدا و بسته شد
گزارش ۲۰۲۶-۰۸-۰۷ نوشته بود یک تست، ۲۰۰ تست بعدتر تستی بی‌ربط را می‌شکند، و آن را با
`resetManager()` در `tearDown` همان تست مهار کرده بود. مهار بود نه رفع: با اضافه‌شدنِ
تست‌های این جلسه، خطا سه بار روی سه تستِ متفاوت ظاهر شد — `ClinicRecordAccessTest`،
`ServiceRescheduleTest`، `CommentListNPlusOneTest` — و هر سه در اجرای تکی سبز بودند.
ریشه، بسته‌شدنِ manager نبود. تستی که موجودیتی را `persist()` می‌کند و بی‌`flush()` تمام
می‌شود، همان unit of work را برای تست بعدی به ارث می‌گذارد؛ آنجا اولین `flush()` با
«A new entity was found through the relationship …» می‌شکند. `setUp` فقط وقتی ریست می‌کرد
که manager **بسته** باشد، و این حالت manager را باز ولی آلوده می‌گذارد.
رفع: یک `$this->em->clear()` در `ApiTestCase::setUp`. عمداً `clear()` نه `resetManager()`
همان نمونه می‌ماند، پس هیچ ارجاعی به managerِ مرده نمی‌رسد و فقط identity map خالی می‌شود.
دو اجرای کاملِ پیاپی سبز شد.
## تغییرات این جلسه
```
src/Shared/Controller/PermissionGateTrait.php گِیت مشترک + پیش‌چکِ منشی (جدید)
src/Resource/Controller/ResourcePermissionTrait.php روی گِیت مشترک سوار شد
src/Billing/Controller/BillingController.php ۱۲ روت → payments.* + تبدیل نوعِ فیلترها
src/Appointment/Controller/MyAppointmentsController.php ۵ روت → appointments.* + تزریق AddressResolver
src/Appointment/Plan/Controller/AppointmentPlanController.php ۳ روت → services.* (یافتهٔ ۹)
src/Appointment/Controller/AppointmentController.php ۴ پیش‌چک (یافتهٔ ۱۰)
src/Appointment/Controller/AppointmentSettingsController.php ۹ پیش‌چک
src/Clinic/Controller/ClinicController.php ۲ پیش‌چک
src/Clinic/Controller/ClinicDoctorPermissionController.php ۱ پیش‌چک
src/ClinicInvitation/Controller/ClinicInvitationController.php ۴ پیش‌چک
src/Appointment/Availability/Controller/ResourceBlockController.php ۲ پیش‌چک
src/Blog/Command/SanitizeBlogBodiesCommand.php پاک‌سازی مقاله‌های قدیمی (جدید)
config/packages/html_sanitizer.yaml سه attributeِ ظاهریِ جدول مجاز شد
assets/admin/components/RichTextEditor.tsx ادیتور مشترک روی ckeditor5@48 (جدید)
assets/admin/pages/BlogFormPage.tsx استفاده از ادیتور مشترک
assets/admin/pages/RepresentationBlogFormPage.tsx استفاده از ادیتور مشترک
assets/admin/styles.css .blog-body — ظاهر جدولِ مقاله
package.json ckeditor5@48؛ build-classic حذف شد
phpstan-baseline.neon دو ردیفِ کهنه رفت، ۴۴ → ۴۳
پنج سرویس (Patient/Inventory/Secretary/ClinicService/…) propertyهای بلااستفاده حذف شد
tests/Shared/ApiLeastPrivilegeTest.php پویش نوشتنی + ALLOWED_WRITE + قفلِ GATE_BEFORE_LOOKUP
tests/Staff/StaffCrossTenantTest.php IDOR بین‌محیطیِ پرسنل (جدید)
tests/Appointment/AppointmentTreatmentSessionLinkTest.php رگرسیونِ یافتهٔ ۱۱ (جدید)
tests/Blog/BlogBodySanitizerTest.php attributeهای جدول
tests/Secretary/SecretaryAppointmentScopeTest.php منشیِ بی‌رابطه: خالی → ۴۰۳
tests/ApiTestCase.php clear() در setUp — رفعِ ناپایداریِ سوییت
docs/api/billing.md · appointment.md · appointment-plan.md · blog.md · insurance.md · doctor-service.md
```
---
*پایان گزارش — درایور `symfony-security-audit` + پروب دستی با JWT واقعی هر نقش + بازرسی کد. هر
یافته بازتولید شده است.*