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
864 lines
60 KiB
Markdown
864 lines
60 KiB
Markdown
# گزارش آدیت امنیتی 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 شدن ` ` |
|
||
| حذف 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 واقعی هر نقش + بازرسی کد. هر
|
||
یافته بازتولید شده است.*
|