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
60 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 | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ (مهاجرت به 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 رفع شود» بود؛ کاربر بعداً رفعِ همهٔ یافتههای باز را خواست، پس یافتههای ۲، ۳، ۵، ۶ و ۷ هم بسته شدند. یافتهٔ ۸ حین همین کار کشف شد.
یافتههای ۹ تا ۱۲ در جلسههای ۲۰۲۶-۰۸-۰۸ کشف شدند، حین بستنِ یافتههای ۸ و ۴. شرحشان در بخش «پیگیری ۲۰۲۶-۰۸-۰۸» انتهای همین سند است.
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 را میسنجد:
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، کلینیک ۳) دستنخورده است.
پیشنهادها
بستنِ ده گَپِ یافتهٔ ۸— انجام شد ۲۰۲۶-۰۸-۰۸. جزئیات پایین.- مهاجرت CKEditor به پکیج umbrella
ckeditor5v45+ — تنها راهِ بستنِ یافتهٔ ۴. یک— انجام شد، ولی در fixture نه در DB زنده.ClinicStaffدر tenant دومگسترشِ— انجام شد ۲۰۲۶-۰۸-۰۸.ApiLeastPrivilegeTestبه روتهای نوشتنی- پاکسازیِ ۱۷ خطای
phpstan— بدهیِ قدیمی، بیربط به امنیت، ولی مانعِ «سبز یعنی سبز» است. - بستنِ یافتهٔ ۱۰ — گِیت را در ۳۴ روت به بالای واکشی ببر. ~۱۵ تایشان 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.viewPOST /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,serviceRescheduleAppointmentSettingsController—createSchedule,updateSchedule,deleteSchedule,createOverride,updateOverride,deleteOverride,createHoliday,updateHoliday,deleteHolidayClinicController—update,detachDoctor,createAddress,updateAddress,deleteAddressClinicDoctorPermissionController—updatePermissionsClinicInvitationController—inviteDoctor,resendInvitation,changeInvitationStatus,deleteInvitationDoctorController—update,delete,updateAddress,deleteAddressResourceBlockController—create,deleteRepresentationController—updateSecretaryController—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 واقعی هر نقش + بازرسی کد. هر
یافته بازتولید شده است.