36 KiB
گزارش آدیت امنیتی 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 | ⚠️ risk پذیرفتهشده — تصمیم ۲۰۲۶-۰۸-۰۷ |
| 5 | dangerouslySetInnerHTML روی بدنهٔ بلاگ در BlogReviewPage |
🟨 Medium | ✅ رفع شد (sanitize هنگام ذخیره) |
| 6 | APP_SECRET واقعی در .env.test تحت git |
🟦 Low | ✅ رفع شد |
| 7 | پسورد sandbox درگاه ملت هاردکد | ⬜ Info | ✅ رفع شد (به env منتقل شد) |
| 8 | ۱۰ روت GET که مجوزِ رجیستریشان را enforce نمیکنند |
🟨 Medium | ⚠️ باز — با تست baseline مهار شد |
سیاست اولیه «فقط Critical/High رفع شود» بود؛ کاربر بعداً رفعِ همهٔ یافتههای باز را خواست، پس یافتههای ۲، ۳، ۵، ۶ و ۷ هم بسته شدند. یافتهٔ ۴ طبق تصمیم صریح خارج از محدوده ماند و یافتهٔ ۸ حین همین کار کشف شد.
npm audit --omit=dev # قبل: high=2 moderate=62 بعد: high=0 moderate=3
ddev exec php bin/phpunit # ۱۵۵۵ تست، ۴۸۳۲ assertion، سبز
ماتریس نقشهایی که آدیت با آن اجرا شد
آدیت ۲۰۲۶-۰۷-۱۹ در بخش «محدودیت پوشش» نوشته بود ماتریس 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 را میسنجد:
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 —
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
۴. بازتولید:
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 ساده ممکن نبود و مهاجرت اجباری بود:
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 اعمال شد:
"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
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
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 میآیند:
# 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، کلینیک ۳) دستنخورده است.
پیشنهادها
- بستنِ ده گَپِ یافتهٔ ۸ — با دیدنِ مصرفِ واقعیِ پنل تصمیم بگیر هر کدام کدام مجوز را بخواهد،
بعد ردیفش را از
KNOWN_GAPSبردار. تست دوم خودش یادآوری میکند. - مهاجرت CKEditor به پکیج umbrella
ckeditor5v45+ — تنها راهِ بستنِ یافتهٔ ۴. - یک
ClinicStaffدر tenant دوم — تا آدیت بعدی بتواند IDOR بیننقشیِ پرسنل را واقعاً بزند. - گسترشِ
ApiLeastPrivilegeTestبه روتهای نوشتنی — الان فقطGETبدون path parameter را پوشش میدهد.POST/PATCH/DELETEبدنهٔ معتبر میخواهند، ولی همانها خطرناکترند. - پاکسازیِ ۱۷ خطای
phpstan— بدهیِ قدیمی، بیربط به امنیت، ولی مانعِ «سبز یعنی سبز» است.
پایان گزارش — درایور symfony-security-audit + پروب دستی با JWT واقعی هر نقش + بازرسی کد. هر
یافته بازتولید شده است.