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

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

60 KiB
Raw Blame History

گزارش آدیت امنیتی 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);
}

و صدا زدنش در ابتدای هر سه متد: showview، replaceupdate، deleteupdate.

۷. علت انتخاب راه‌حل:

  • منبع 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.phptestTreatmentProtocolReadDeniedByDefault، testTreatmentProtocolReadAllowedWhenServicesGranted، testTreatmentProtocolWriteNeedsServicesUpdate، testTreatmentProtocolWriteAllowedWhenServicesUpdateGranted. کل فایل: ۳۳ تست، ۴۸ assertion، سبز.

۱۰. مستندات: docs/api/treatment.md — هر سه اندپوینت با مجوز جدید و دلیل انتخابش.


2. 🟧 HIGH — react-router با پنج advisory باز

۱. فایل: package.jsonreact-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-q6mhRSCErrorHandler بدون اعتبارسنجی 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.jsonlodash-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:23private 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:66QaTest@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 و transitionClaimpayments.update
  • my/appointments, today-stats, my/clinic-doctorsappointments.view
  • POST /my/appointment و patient-lookupappointments.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 کاملاً بازنویسی می‌کرد. بخش‌بندی طولِ نوبت و منابعِ لازم را تعیین می‌کند، پس بازنویسی‌اش یعنی به‌هم‌ریختنِ برنامهٔ همهٔ نوبت‌های آن سرویس.

۴. رفع: showservices.view، replaceservices.update، previewservices.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، و در محدودهٔ این جلسه نبود.

۴. فهرست کامل — گروه‌بندی بر اساس کنترلر:

  • AppointmentControllerupdateStatus, confirm, update, serviceReschedule
  • AppointmentSettingsControllercreateSchedule, updateSchedule, deleteSchedule, createOverride, updateOverride, deleteOverride, createHoliday, updateHoliday, deleteHoliday
  • ClinicControllerupdate, detachDoctor, createAddress, updateAddress, deleteAddress
  • ClinicDoctorPermissionControllerupdatePermissions
  • ClinicInvitationControllerinviteDoctor, resendInvitation, changeInvitationStatus, deleteInvitation
  • DoctorControllerupdate, delete, updateAddress, deleteAddress
  • ResourceBlockControllercreate, delete
  • RepresentationControllerupdate
  • SecretaryControllercreate, 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 ندارند و چکشان مالکیتِ خودِ رکورد است:

  • ClinicControllercreateAddress, updateAddress, deleteAddress. addresses فقط view دارد؛ نوشتنِ آدرس صریحاً owner-or-admin است و اصلاً قابل واگذاری نیست.
  • DoctorControllerupdate, delete, updateAddress, deleteAddress.
  • RepresentationController::update.
  • SecretaryControllercreate, update, deactivate, syncClinicDoctors.

پوششِ نشتِ باقی‌مانده کم‌ارزش است: uuidِ پزشک از فهرست عمومی پزشکان در دسترس است. برای منشی و نماینده ارزشش بیشتر است ولی همچنان Low. بستنشان یا کلیدِ تازه در رجیستری می‌خواهد — که UI مجوزها و دو Entity و تایپ‌های فرانت را درگیر می‌کند — یا یک مکانیزم موازیِ نقش‌محور، که یافتهٔ ۱ توصیه کرد نسازیم.

ApiLeastPrivilegeTest::testHoistedGatesAnswer403BeforeTheLookup این ۲۲ تا را قفل می‌کند: اگر کسی خطِ پیش‌چک را بردارد، پاسخ به ۴۰۴ برمی‌گردد و تست قرمز می‌شود.

پاک‌سازی phpstan — و آنچه زیرش پنهان بود

هر ۱۷ خطای بیرون از baseline بسته شد و phpstan حالا No errors می‌دهد. baseline از ۴۴ به ۴۳ ردیف رسید. جنس خطاها:

  • ۱ باگ زنده — یافتهٔ ۱۱ بالا.
  • ۳ ناهم‌خوانی نوعBillingController فیلترهای from/to را string می‌فرستاد و InvoiceService عددِ صحیح می‌خواست. تبدیل در مرزِ ورودی نشست، نه در repository.
  • ۶ property تزریق‌شده و بلااستفاده در پنج سرویس — حذف شدند.
  • ۲ فراخوانیِ getEntityManager() از بیرون در SecretaryService — با EntityManagerInterface تزریق‌شده جایگزین شد.
  • ۲ ignore pattern کهنه که دیگر با هیچ خطایی مطابقت نداشتند.
  • ۳ مقایسهٔ همیشه-درست — ساده شدند.

دو مورد عمداً با @phpstan-ignore-next-line ماندند: ServiceItem::getConsumables() و getStaffMembers(). phpstan فقط constructor را می‌بیند و می‌گوید property همیشه مقدار دارد؛ Doctrine اما بدون constructor هیدریت می‌کند. گاردِ ??= عمدی است.

پاک‌سازی مقاله‌های قدیمی

app:blog:sanitize-bodies ساخته شد و روی هر ۴۲۷ مقاله اجرا شد. دستور است نه migration، چون سیاست ممکن است دوباره سفت شود و آن‌وقت باید همین گذر تکرار شود.

عددِ خام گمراه‌کننده بود: «۴۲۶ مقاله تغییر می‌کند» در نگاه اول یعنی ۴۲۶ مقالهٔ آلوده. تفکیکِ جنسِ تغییر نشان داد:

جنس تغییر تعداد یعنی چه
سخت‌سازی ۳۹۸ افزودن rel="noopener noreferrer" یا decode شدن &nbsp;
حذف attribute ۲۸ فقط style روی ۲۲ جدول و ۱ div؛ بقیه ترمیمِ HTML شکسته
بدون تغییر ۱

هیچ مقاله‌ای markup اجرایی نداشت — نه <script>، نه onerror، نه javascript:. یعنی یافتهٔ ۵ یک ریسک بالقوه را بست، نه یک نشتِ فعال را.

پس از اجرا، ۴۲۵ مقاله rel="noopener noreferrer" دارند و هیچ‌کدام style= ندارند. اجرای دوم صفر تغییر گزارش می‌دهد.

ناپایداریِ سوییت — ریشه‌اش پیدا و بسته شد

گزارش ۲۰۲۶-۰۸-۰۷ نوشته بود یک تست، ۲۰۰ تست بعدتر تستی بی‌ربط را می‌شکند، و آن را با resetManager() در tearDown همان تست مهار کرده بود. مهار بود نه رفع: با اضافه‌شدنِ تست‌های این جلسه، خطا سه بار روی سه تستِ متفاوت ظاهر شد — ClinicRecordAccessTest، ServiceRescheduleTest، CommentListNPlusOneTest — و هر سه در اجرای تکی سبز بودند.

ریشه، بسته‌شدنِ manager نبود. تستی که موجودیتی را persist() می‌کند و بی‌flush() تمام می‌شود، همان unit of work را برای تست بعدی به ارث می‌گذارد؛ آنجا اولین flush() با «A new entity was found through the relationship …» می‌شکند. setUp فقط وقتی ریست می‌کرد که manager بسته باشد، و این حالت manager را باز ولی آلوده می‌گذارد.

رفع: یک $this->em->clear() در ApiTestCase::setUp. عمداً clear() نه resetManager() — همان نمونه می‌ماند، پس هیچ ارجاعی به managerِ مرده نمی‌رسد و فقط identity map خالی می‌شود. دو اجرای کاملِ پیاپی سبز شد.

تغییرات این جلسه

src/Shared/Controller/PermissionGateTrait.php              گِیت مشترک + پیش‌چکِ منشی (جدید)
src/Resource/Controller/ResourcePermissionTrait.php        روی گِیت مشترک سوار شد
src/Billing/Controller/BillingController.php               ۱۲ روت → payments.* + تبدیل نوعِ فیلترها
src/Appointment/Controller/MyAppointmentsController.php    ۵ روت → appointments.* + تزریق AddressResolver
src/Appointment/Plan/Controller/AppointmentPlanController.php  ۳ روت → services.* (یافتهٔ ۹)
src/Appointment/Controller/AppointmentController.php       ۴ پیش‌چک (یافتهٔ ۱۰)
src/Appointment/Controller/AppointmentSettingsController.php   ۹ پیش‌چک
src/Clinic/Controller/ClinicController.php                 ۲ پیش‌چک
src/Clinic/Controller/ClinicDoctorPermissionController.php ۱ پیش‌چک
src/ClinicInvitation/Controller/ClinicInvitationController.php ۴ پیش‌چک
src/Appointment/Availability/Controller/ResourceBlockController.php ۲ پیش‌چک
src/Blog/Command/SanitizeBlogBodiesCommand.php             پاک‌سازی مقاله‌های قدیمی (جدید)
config/packages/html_sanitizer.yaml                        سه attributeِ ظاهریِ جدول مجاز شد
assets/admin/components/RichTextEditor.tsx                 ادیتور مشترک روی ckeditor5@48 (جدید)
assets/admin/pages/BlogFormPage.tsx                        استفاده از ادیتور مشترک
assets/admin/pages/RepresentationBlogFormPage.tsx          استفاده از ادیتور مشترک
assets/admin/styles.css                                    .blog-body — ظاهر جدولِ مقاله
package.json                                               ckeditor5@48؛ build-classic حذف شد
phpstan-baseline.neon                                      دو ردیفِ کهنه رفت، ۴۴ → ۴۳
پنج سرویس (Patient/Inventory/Secretary/ClinicService/…)     propertyهای بلااستفاده حذف شد
tests/Shared/ApiLeastPrivilegeTest.php                     پویش نوشتنی + ALLOWED_WRITE + قفلِ GATE_BEFORE_LOOKUP
tests/Staff/StaffCrossTenantTest.php                       IDOR بین‌محیطیِ پرسنل (جدید)
tests/Appointment/AppointmentTreatmentSessionLinkTest.php  رگرسیونِ یافتهٔ ۱۱ (جدید)
tests/Blog/BlogBodySanitizerTest.php                       attributeهای جدول
tests/Secretary/SecretaryAppointmentScopeTest.php          منشیِ بی‌رابطه: خالی → ۴۰۳
tests/ApiTestCase.php                                      clear() در setUp — رفعِ ناپایداریِ سوییت
docs/api/billing.md · appointment.md · appointment-plan.md · blog.md · insurance.md · doctor-service.md

پایان گزارش — درایور symfony-security-audit + پروب دستی با JWT واقعی هر نقش + بازرسی کد. هر یافته بازتولید شده است.