feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks. - Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content. - Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
This commit is contained in:
@@ -40,7 +40,10 @@ late shifts the rest of their course rather than getting the next session too ea
|
||||
|
||||
## GET `/api/v1/service-item/{uuid}/treatment-protocol`
|
||||
|
||||
**Permission:** `IS_AUTHENTICATED_FULLY`, محدود به محیط جاری — سرویس محیط دیگر `404` میگیرد.
|
||||
**Permission:** `IS_AUTHENTICATED_FULLY` + `services.view`, محدود به محیط جاری — سرویس محیط دیگر
|
||||
`404` میگیرد. پروتکل خاصیتِ سرویس است، پس همان مجوزِ `services` را میگیرد، نه مجوز `treatment`
|
||||
که برای پروندهٔ درمان است. گیتِ مجوز **پیش از** واکشی سرویس اجرا میشود، پس منشیِ بدون
|
||||
`services.view` روی uuid ناموجود هم `403` میگیرد نه `404`.
|
||||
|
||||
`data: null` یعنی سوییچ خاموش است، نه اینکه چیزی پیدا نشد.
|
||||
|
||||
@@ -93,7 +96,7 @@ Everything is validated **before** anything is written: an invalid step at the e
|
||||
not wipe the valid steps already stored. Steps and staff are then cleared and rewritten inside one
|
||||
transaction.
|
||||
|
||||
**Permission:** `IS_AUTHENTICATED_FULLY`, محدود به محیط جاری.
|
||||
**Permission:** `IS_AUTHENTICATED_FULLY` + `services.update`, محدود به محیط جاری.
|
||||
|
||||
### Request Body (`application/json`)
|
||||
| Field | Type | Required | توضیح |
|
||||
@@ -189,7 +192,8 @@ A no-show does not burn the session: its status becomes `no_show`, its appointme
|
||||
Turn the switch off — the protocol, its steps and its staff list are removed and the service is
|
||||
single-session again. Idempotent: deleting a service that has no protocol still answers `200`.
|
||||
|
||||
**Permission:** `IS_AUTHENTICATED_FULLY`, محدود به محیط جاری.
|
||||
**Permission:** `IS_AUTHENTICATED_FULLY` + `services.update`, محدود به محیط جاری. عمداً `update`
|
||||
است نه `delete`: سرویس حذف نمیشود، فقط سوییچِ «طول درمان» روی همان سرویس خاموش میشود.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
# گزارش آدیت امنیتی 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 | ⚠️ باز — نیازمند تصمیم |
|
||||
| 3 | `lodash-es` — code injection در `_.template` + دو prototype pollution | 🟧 High | ⚠️ باز (از آدیت قبلی) |
|
||||
| 4 | ۶۲ moderate در `@ckeditor/ckeditor5-build-classic` (deprecated) | 🟨 Medium | ⚠️ risk پذیرفتهشده — تصمیم ۲۰۲۶-۰۸-۰۷ |
|
||||
| 5 | `dangerouslySetInnerHTML` روی بدنهٔ بلاگ در `BlogReviewPage` | 🟨 Medium | ⚠️ باز |
|
||||
| 6 | `APP_SECRET` واقعی در `.env.test` تحت git | 🟦 Low | ⚠️ باز |
|
||||
| 7 | پسورد sandbox درگاه ملت هاردکد | ⬜ Info | باز (از آدیت قبلی، کماهمیت) |
|
||||
|
||||
طبق تصمیم کاربر پیش از اجرا: Critical و High **همان جلسه** رفع میشوند؛ Medium و پایینتر فقط
|
||||
گزارش میشوند. یافتهٔ ۱ رفع شد. یافتههای ۲ و ۳ High هستند ولی رفعشان **ارتقای وابستگی** است نه
|
||||
تغییر کد این repo، و شکستنِ روتینگ پنل یا ادیتور را در پی دارد — پس تصمیم به کاربر واگذار شد.
|
||||
|
||||
---
|
||||
|
||||
## ماتریس نقشهایی که آدیت با آن اجرا شد
|
||||
|
||||
آدیت ۲۰۲۶-۰۷-۱۹ در بخش «محدودیت پوشش» نوشته بود ماتریس 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 v7 است و رفع یعنی bump به نسخهای خارج از range. سه
|
||||
advisory (RSC، SSR hydration، CSRF مود RSC) به این پنل که کلاینتساید محض است مربوط نمیشوند؛ ولی
|
||||
open redirect و DoS مربوطاند. **رفع نشد** چون ارتقای major روتینگِ ۵۰ صفحهٔ پنل تست دستی
|
||||
میخواهد و در یک پاس امنیتی ریسکش بیشتر از خودِ یافته است. تسک جدا پیشنهاد میشود.
|
||||
|
||||
---
|
||||
|
||||
### 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 است؛ باید ردیابی شود کدام پکیج آن را میکشد.
|
||||
|
||||
---
|
||||
|
||||
### 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، همین خط.
|
||||
|
||||
**۴. وضعیت:** باز طبق سیاست (Medium بدون تأیید رفع نمیشود). CSP فعلی `script-src 'self'` است، پس
|
||||
`<script>` تزریقی اجرا نمیشود؛ ولی هندلرهای inline و `javascript:` را CSP فعلی کامل نمیبندد.
|
||||
پیشنهاد: sanitize سمت سرور هنگام ذخیره، نه فقط هنگام نمایش.
|
||||
|
||||
---
|
||||
|
||||
### 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` که یافتهٔ ۳ گزارش قبلی بود، تمیز است — آن رفع پابرجاست. `.env.test` فقط محیط تست را
|
||||
امضا میکند و ارزش عملیاتی ندارد، ولی مقدارِ واقعی در git بهتر است placeholder شود.
|
||||
|
||||
---
|
||||
|
||||
### 7. ⬜ INFO — پسورد sandbox درگاه ملت
|
||||
|
||||
`src/Payment/Gateway/MellatGateway.php:23` — `private const SANDBOX_PASSWORD = '17384843'`. همان
|
||||
یافتهٔ ۵ گزارش قبلی، همچنان باز، همچنان کماهمیت (اعتبارنامهٔ عمومیِ محیط تست درگاه).
|
||||
|
||||
---
|
||||
|
||||
## آنچه سالم بود (تست شد، یافته نیست)
|
||||
|
||||
**۱. جداسازی 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
|
||||
tests/Secretary/SecretaryResourceEnforcementTest.php ۴ تست رگرسیون
|
||||
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، همه
|
||||
از قبل موجود).
|
||||
|
||||
**دادهٔ DB:** پروب `DELETE` روی سرویسِ کلینیک ۱ اجرا شد؛ آن سرویس پروتکل نداشت، پس چیزی حذف نشد.
|
||||
تنها پروتکلِ موجود (`service_item_id=15`، کلینیک ۳) دستنخورده است.
|
||||
|
||||
---
|
||||
|
||||
## پیشنهادها
|
||||
|
||||
1. **ارتقای `react-router`** — تسک جدا با تست دستی مسیرهای پنل. یافتهٔ ۲.
|
||||
2. **ردیابی `lodash-es`** — کدام dependency آن را میکشد؛ اگر transitive است، override.
|
||||
3. **sanitize سمت سرور برای بدنهٔ بلاگ** — یافتهٔ ۵ را مستقل از CKEditor میبندد.
|
||||
4. **یک `ClinicStaff` در tenant دوم** — تا آدیت بعدی بتواند IDOR بیننقشیِ پرسنل را واقعاً بزند.
|
||||
5. **تست ساختاری برای گیت کنترلرها** — چیزی شبیه `TenantSchemaCoverageTest` که کنترلر جدیدِ بدون
|
||||
گیت مجوز را قرمز کند. یافتهٔ ۱ دقیقاً از همین شکاف آمد: `#[IsGranted('IS_AUTHENTICATED_FULLY')]`
|
||||
سطح-کلاس، درایور را راضی میکند ولی هیچ مجوزی را enforce نمیکند.
|
||||
|
||||
---
|
||||
|
||||
*پایان گزارش — درایور `symfony-security-audit` + پروب دستی با JWT واقعی هر نقش + بازرسی کد. هر
|
||||
یافته بازتولید شده است.*
|
||||
Reference in New Issue
Block a user