# گزارش آدیت امنیتی ClinicPro — ۲۰۲۶-۰۷-۱۹ **تارگت:** `clinicpro/` (Symfony 7.4، Doctrine ORM 3.6، LexikJWT، MariaDB 11.8، React 19 admin) **محیط:** `https://clinic-pro.ddev.site` (ddev، `APP_ENV=dev`) **روش:** درایور `symfony-security-audit` (white-box: deps/sinks/guards/secrets/config — black-box: authz/headers/cors/inject) + بازرسی دستی کد. **مجری:** Senior AppSec / Pentest + Senior Symfony. > **قانون گزارش:** هیچ یافته‌ای بدون بازتولید ثبت نشده. leadهای درایور که پس از خواندن کد «مثبت کاذب» یا «طراحی امن» بودند، در بخش «رد شد» با دلیل آمده‌اند. --- ## خلاصهٔ وضعیت سطح امنیتی پایه **خوب** است. مسیرهای حساس (access control پنل ادمین، پرداخت، آپلود فایل) دفاع درست دارند و شواهد قوی از تست‌محوری امنیتی (تست‌های amount/replay پرداخت) دیده شد. یافته‌ها عمدتاً **سخت‌سازی (hardening)** هستند، نه رخنهٔ فعال. هیچ Critical/High قطعی‌ای که در محیط زنده قابل بهره‌برداری باشد پیدا نشد. | # | یافته | شدت | وضعیت | |---|---|---|---| | 1 | نبود CSP روی SPA ادمین (`/admin`) | 🟨 Medium | ✅ رفع شد | | 2 | وابستگی npm آسیب‌پذیر — lodash `_.template` + moderate CKEditor | 🟨 Medium | ⚠️ نیمه‌رفع (زیر را ببین) | | 3 | `APP_SECRET` واقعی commit‌شده در `.env.dev` | 🟦 Low | ✅ رفع شد | | 4 | فلگ‌های session cookie پین‌نشده (secure/samesite/httponly) | 🟦 Low | ✅ رفع شد | | 5 | پسورد sandbox درگاه ملت هارد‌کد | ⬜ Info | باز (کم‌اهمیت) | ### وضعیت رفع (۲۰۲۶-۰۷-۱۹) - **۱ — CSP:** `src/Shared/EventSubscriber/AdminCspSubscriber.php` افزوده شد؛ CSP فقط روی `/admin/*`. تأیید زنده: هدر `content-security-policy` روی `/admin` حاضر است. تست: `tests/Shared/AdminCspSubscriberTest.php` (۲ تست سبز). - **پیگیری (۲۰۲۶-۰۷-۲۳):** دو خطای کنسول CSP رفع شد. (الف) فونت Vazirmatn از CDN گوگل حذف شد (`templates/admin/index.html.twig`)؛ فونت از قبل به‌صورت لوکال با `@fontsource` باندل می‌شود، پس CDN هم مسدود و هم زائد بود. (ب) هاست‌های نقشهٔ کلینیک به CSP اضافه شد: `img-src` += `*.tile.openstreetmap.org unpkg.com`، `connect-src` += `nominatim.openstreetmap.org *.tile.openstreetmap.org`. بدون این‌ها نقشهٔ Leaflet در `ClinicDetailPage` خالی می‌ماند. تست CSP به ۹ assertion رسید. - **مرتبط (غیرامنیتی):** باگ Service Worker در `public/sw.js` رفع شد — هندلر navigate در حالت آفلاین وقتی shell کش نشده بود `undefined` به `respondWith` می‌داد → خطای `Failed to convert value to 'Response'`؛ حالا fallback واقعی (Response 503) برمی‌گرداند. - **۳ — APP_SECRET:** مقدار واقعی از `.env.dev` برداشته و placeholder شد (مقدار واقعی باید در `.env.local` خارج از git بماند + rotate). - **۴ — cookie flags:** `config/packages/framework.yaml` → `cookie_secure: true`، `cookie_samesite: lax`، `cookie_httponly: true`. لینت YAML سبز، ۵۶ تست Shared/Smoke سبز. - **۲ — deps:** - `lodash` با `overrides` به خط نگه‌داری‌شده پین شد. **نکتهٔ مهم:** advisory `GHSA-r5fr-rjxr-66jc` نسخهٔ patched ندارد، اما lodash اینجا **فقط build-time** است (زیر `@symfony/webpack-encore` → `pretty-error`) و **به باندل مرورگر نمی‌رود** — ریسک runtime عملاً صفر. تأیید: `npm ls lodash` تنها مسیرش webpack-encore است. - **۶۱ moderate CKEditor باز است:** `@ckeditor/ckeditor5-build-classic@44.3.0` آخرین نسخهٔ همان پکیج است و **deprecated** — نسخهٔ بالاتری برای bump وجود ندارد. رفع واقعی نیازمند **مهاجرت از build-classic به پکیج umbrella `ckeditor5` (v45+)** است که refactor ادیتور بلاگ و تست دستی UI می‌خواهد؛ عمداً در این پاس انجام نشد (ریسک شکستن ادیتور). این XSSها moderate و admin-only هستند (ادمین باید HTML مخرب در ادیتور خودش paste کند). → تسک جدا پیشنهاد می‌شود. ### آنچه سالم بود (تأییدشده، یافته نیست) - **Access control پنل ادمین:** روی ۸ مسیر `admin/*` الگوی سالم `anon 401 / admin 200 / doctor 403`. هیچ Broken Access Control. - **CORS:** Origin دلخواه (`https://evil.example`) reflect نشد (`allow-origin=null`). - **آپلود فایل:** اعتبارسنجی magic-byte، نام تصادفی `random_bytes(16)`، allowlist پسوند، مسدودسازی `..`. طراحی مقاوم. - **پرداخت:** چک مالکیت، verify مبلغ + ضد-replay (تست‌دار)، allowlist ضد open-redirect روی همهٔ مسیرها، allowlist IP callback. - **JWT:** `token_ttl: 900` (۱۵ دقیقه)، `clock_skew: 5`، کلید از env. - **Rate limiter:** login 10/min، token_issue 10/5min، password_reset 5/h، doctor_claim 5/h. - **هدرهای API:** HSTS، `x-content-type-options`، `x-frame-options: DENY`، `referrer-policy`، `permissions-policy`، و `content-security-policy: default-src 'none'` روی `/api`. ### محدودیت پوشش (صادقانه) - پس از ریست DB فقط دو کاربر `admin` و `doctor` وجود دارند؛ `clinic`/`secretary`/`representation` نبودند (`ERR_NOT_FOUND_001` در login و شکست fallback lexik). ماتریس کامل ۵-نقشه و IDOR بین‌نقشی به‌طور زنده تست نشد. اما `doctor` (نقش احرازشدهٔ کم‌دسترسی) همچنان تأیید می‌کند غیر-ادمین روی `admin/*` کد `403` می‌گیرد. - IDOR بین دو کاربر هم‌نقش زنده تست نشد (فقط یک کاربر در هر نقش موجود بود). - `ratelimit` روی مسیرهای auth عمداً اجرا نشد (سوزاندن پنجرهٔ نرخ). --- ## یافته‌ها ### 1. 🟨 Medium — نبود Content-Security-Policy روی SPA ادمین **۱. عنوان:** صفحهٔ `/admin` (React SPA) هیچ هدر `content-security-policy` ندارد. **۲. شدت:** Medium — CVSS-وار ~5.4 (AV:N/AC:L/PR:N/UI:R). blast-radius یک XSS احتمالی در پنلی که CKEditor و محتوای پویا سرو می‌کند. **۳. محل:** پاسخ HTTP مسیر `/admin` (کنترلر رندر SPA / کانفیگ nginx-headers). مقایسه: `/api/*` هدر `default-src 'none'` دارد، `/admin` ندارد. **۴. توضیح:** برخلاف `/api` که CSP سخت دارد، خودِ SPA بدون CSP سرو می‌شود. اگر هر ورودی کاربر (نام دکتر، محتوای بلاگ CKEditor، فیلد پروفایل) در DOM بدون escape بنشیند، CSP لایهٔ دفاعی دومی نیست تا اجرای اسکریپت تزریقی را متوقف کند. با وجود لودر شخص‌ثالث سنگین (CKEditor)، این ریسک واقعی است. **۵. بازتولید:** ```bash node .claude/skills/symfony-security-audit/driver.mjs headers /admin # → HTTP 200 ... (فهرست هدرها بدون content-security-policy) → 🟨 MEDIUM No CSP node .claude/skills/symfony-security-audit/driver.mjs headers /api/v1/doctors # → content-security-policy: default-src 'none' ✓ (روی API هست، روی admin نیست) ``` **۶. راهکار:** یک CSP مناسب SPA به پاسخ `/admin` اضافه کن (kernel response listener یا هدر nginx). چون Encore اسکریپت‌ها را از `public/build` می‌دهد، از nonce یا `'self'` استفاده کن، نه `unsafe-inline` برای اسکریپت. **۷. کد اصلاح‌شده** (Symfony response subscriber، فقط برای مسیرهای `/admin`): ```php // src/Shared/EventSubscriber/AdminCspSubscriber.php namespace App\Shared\EventSubscriber; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; final class AdminCspSubscriber implements EventSubscriberInterface { public static function getSubscribedEvents(): array { return [KernelEvents::RESPONSE => 'onResponse']; } public function onResponse(ResponseEvent $event): void { if (!$event->isMainRequest()) { return; } $path = $event->getRequest()->getPathInfo(); if (!str_starts_with($path, '/admin')) { return; } $event->getResponse()->headers->set( 'Content-Security-Policy', "default-src 'self'; " . "script-src 'self'; " . "style-src 'self' 'unsafe-inline'; " // Tailwind/CKEditor inline styles . "img-src 'self' data: blob: https://*.tile.openstreetmap.org https://unpkg.com; " // OSM tiles + Leaflet markers (clinic map) . "font-src 'self' data:; " . "connect-src 'self' https://nominatim.openstreetmap.org https://*.tile.openstreetmap.org; " // nominatim geocode + OSM tiles . "frame-ancestors 'none'; base-uri 'self'; object-src 'none'" ); } } ``` **۸. علت انتخاب:** response subscriber مسیرمحور، بدون دست‌زدن به کانفیگ nginx (که در ddev و prod متفاوت است) و بدون افزودن باندل CSP کامل (nelmio/security). اگر بعداً nonce لازم شد، همین‌جا تزریق می‌شود. گزینهٔ رد شده: هدر سراسری در nginx — چون CSP سختِ `/api` را می‌شکند و مدیریت دو محیط را سخت می‌کند. --- ### 2. 🟨 Medium — وابستگی npm آسیب‌پذیر (lodash `_.template` code injection) **۱. عنوان:** درخت prod فرانت‌اند یک advisory با شدت High دارد: `lodash` — code injection از طریق `_.template`. **۲. شدت:** Medium (advisory=High، ولی بهره‌برداری واقعی مشروط). CVSS-وار ~5.0 چون سینک `_.template` با ورودی کاربر تأیید نشد. **۳. محل:** `clinicpro/package-lock.json` → `lodash` (GHSA-r5fr-rjxr-66jc). به‌علاوه ۶۱ moderate (عمدتاً درخت `@ckeditor/ckeditor5-*`). **۴. توضیح:** `npm audit --omit=dev` یک High گزارش می‌دهد: `lodash vulnerable to Code Injection via _.template imports key names`. اگر جایی `_.template` روی رشتهٔ کنترل‌شدهٔ کاربر اجرا شود، منجر به اجرای کد در مرورگر می‌شود. در این کدبیس سینک مستقیمی پیدا نشد (احتمالاً وابستگی گذرا)، ولی وجودش در باندل prod ریسک است. **۵. بازتولید:** ```bash cd clinicpro && npm audit --omit=dev # → critical=0 high=1 moderate=61 # High: lodash vulnerable to Code Injection via `_.template` imports key names # https://github.com/advisories/GHSA-r5fr-rjxr-66jc ``` **۶. راهکار:** `lodash` را به نسخهٔ وصله‌شده ارتقا بده؛ اگر وابستگی گذراست، از `overrides` در `package.json` استفاده کن. سپس درخت moderate مربوط به CKEditor را با ارتقای `ckeditor5` به آخرین minor امن هم‌راستا کن. **۷. کد اصلاح‌شده** (`package.json`): ```jsonc { "overrides": { "lodash": "^4.17.21" } } ``` ```bash cd clinicpro && npm install && npm audit --omit=dev # باید high=0 شود ``` **۸. علت انتخاب:** `overrides` وابستگی گذرا را بدون تغییر مستقیم آن پکیج پین می‌کند — کم‌ریسک‌ترین مسیر. گزینهٔ رد شده: حذف lodash — چون گذرا و ناشناخته است کدام پکیج آن را می‌کشد؛ ابتدا override، اگر شکست، `npm ls lodash` برای ریشه‌یابی. --- ### 3. 🟦 Low — `APP_SECRET` واقعی commit‌شده در `.env.dev` **۱. عنوان:** `.env.dev` مقدار secret واقعی دارد و در git ترک شده است. **۲. شدت:** Low. فقط در `APP_ENV=dev` بارگذاری می‌شود؛ prod (`.env.coolify.example`/`.env.liara.example`) `APP_SECRET` خالی برای پرشدن دارد، پس این مقدار به prod نمی‌رسد. **۳. محل:** `.env.dev:3` → `APP_SECRET=42f34156531bad221462ff02bd43f42b` (git-tracked). همچنین `.env.test:3` مقدار تستی دارد (بی‌خطر). **۴. توضیح:** `APP_SECRET` برای CSRF token، signed URI و امضای کوکی remember-me استفاده می‌شود. لو رفتنش در git تاریخچه ماندگار است. چون prod سکرت جدا می‌گیرد، تأثیر محدود به محیط dev است؛ ولی اگر روزی deploy به‌اشتباه با `APP_ENV=dev` بالا بیاید، این سکرتِ عمومی فعال می‌شود. **۵. بازتولید:** ```bash git -C clinicpro show HEAD:.env.dev | grep APP_SECRET # → APP_SECRET=42f34156531bad221462ff02bd43f42b node .claude/skills/symfony-security-audit/driver.mjs secrets # → 🟨 MEDIUM Secret-shaped values in a git-tracked env file (.env.dev: APP_SECRET) ``` **۶. راهکار:** مقدار واقعی را از `.env.dev` بردار و به `.env.local` (git-ignored) منتقل کن، یا در `.env.dev` یک placeholder غیرحساس بگذار. سکرت فعلی را rotate کن (چون در تاریخچه مانده). **۷. کد اصلاح‌شده** (`.env.dev`): ```dotenv # مقدار واقعی در .env.local (خارج از git) بماند APP_SECRET=change_me_in_env_local ``` **۸. علت انتخاب:** Symfony به‌طور استاندارد `.env.local` را برای overrideهای حساس دارد و آن را ignore می‌کند. گزینهٔ رد شده: پاک‌کردن از تاریخچه با `git filter-repo` — پرهزینه و برای یک سکرت dev غیرضروری؛ فقط rotate کافی است. --- ### 4. 🟦 Low — فلگ‌های session cookie پین‌نشده **۱. عنوان:** `session.cookie_secure`، `cookie_samesite` و `cookie_httponly` صریحاً ست نشده‌اند. **۲. شدت:** Low. احراز هویت اپ **JWT/stateless** است و روی session تکیه نمی‌کند؛ ولی هر سطح stateful (مثل صفحات Twig پرداخت) از دفاع کوکی بهره می‌برد. **۳. محل:** `config/packages/framework.yaml` (بلوک `session`). **۴. توضیح:** پیش‌فرض `cookie_secure: auto` است؛ در مسیر HTTP کوکی روی plain می‌نشیند. `cookie_samesite` ست‌نشده یعنی دفاع CSRF ضعیف‌تر. `httponly` پیش‌فرض `true` است ولی پین‌نشدن آن را شکننده می‌کند. **۵. بازتولید:** ```bash node .claude/skills/symfony-security-audit/driver.mjs config # → 🟨 session.cookie_secure not set / 🟨 cookie_samesite not set / 🟦 cookie_httponly not explicit ``` **۶. راهکار:** فلگ‌ها را صریح پین کن. **۷. کد اصلاح‌شده** (`config/packages/framework.yaml`): ```yaml framework: session: cookie_secure: true # فقط HTTPS cookie_samesite: lax # ضد CSRF روی هر سطح stateful cookie_httponly: true ``` **۸. علت انتخاب:** پین صریح، رفتار امن را مستقل از پیش‌فرض نسخهٔ Symfony قطعی می‌کند. `lax` (نه `strict`) تا ریدایرکت بازگشتِ درگاه پرداخت نشکند. --- ### 5. ⬜ Info — پسورد sandbox درگاه ملت هارد‌کد **۱. عنوان:** ثابت `SANDBOX_PASSWORD` در `MellatGateway` هارد‌کد است. **۲. شدت:** Informational. مقدار sandbox عمومیِ خودِ درگاه است، نه اعتبار prod. **۳. محل:** `src/Payment/Gateway/MellatGateway.php:23` → `private const SANDBOX_PASSWORD = '17384843';` **۴. توضیح:** اسکنر آن را «hardcoded credential» علامت زد. این پسورد محیط تست عمومی شاپرک است و راز نیست؛ اما ثابت‌بودنش در کد بین «راز» و «مقدار تست» ابهام می‌سازد. **۵. بازتولید:** ```bash node .claude/skills/symfony-security-audit/driver.mjs secrets # → 🟧 HIGH src/Payment/Gateway/MellatGateway.php:23 private const SANDBOX_PASSWORD = '17384843'; ``` **۶. راهکار:** اگر واقعاً sandbox عمومی است، یک کامنت روشن بگذار که راز نیست؛ اعتبار prod همیشه از env بیاید. اعتبار prod را هرگز هارد‌کد نکن. **۷. کد اصلاح‌شده:** ```php // مقدار عمومی sandbox شاپرک — راز نیست. اعتبار prod از env می‌آید. private const SANDBOX_PASSWORD = '17384843'; ``` **۸. علت انتخاب:** حذف false-positive آینده در آدیت‌ها بدون تغییر رفتار. اعتبار prod همان الگوی موجود (env) را نگه می‌دارد. --- ## leadهای درایور که پس از بازرسی رد شدند | lead | چرا رد شد | |---|---| | `FileUploadService`/`FileValidatorService` rename/fopen «filesystem mutation» | نام مقصد از `sanitizeFilename` → `bin2hex(random_bytes(16)).ext`؛ محتوا با magic-byte (jpeg/png/webp) تأیید؛ `..` مسدود. طراحی امن. | | `templates/payment/result.html.twig:140` — `redirect_to\|json_encode\|raw` | `redirect_to` = آدرس frontend که در همهٔ مسیرها با `isAllowedFrontend()` (allowlist host) اعتبارسنجی می‌شود؛ `json_encode` در context جاوااسکریپت escape امن می‌دهد. نه XSS نه open-redirect. | | `payment_callback` با `security: false` | callback از redirect مرورگر می‌آید (نه server-to-server)؛ امنیت با verify مبلغ سمت بانک + ضد-replay (`RefId/SaleOrderId`) تأمین می‌شود — تست‌های `PaymentCallbackAmountTest::testUnderpaymentIsRejected` و `testReplayedGatewayReferenceIsRejected` این را پوشش می‌دهند. | | `guards`: دو مسیر `/api/v1/altcha/challenge` و `/altcha/config` بدون `#[IsGranted]` | endpointهای عمومی CAPTCHA؛ باید برای anon قابل‌دسترس باشند. صحیح. | | `DoctorImportService.php:88` — `md5` «weak hash» | برای تولید شناسهٔ مصنوعی import (غیرامنیتی)، نه هش رمز. بی‌خطر. | | `SeedDemoDataCommand` — `mt_rand` «non-crypto RNG» | فقط دادهٔ دمو seed می‌کند. بی‌خطر. | --- ## پیشنهادهای معماری / امنیت / کیفیت 1. **پوشش تست نقش‌ها:** اسکریپت بازسازی کاربران تست (`create_test_users.php` که در ریشهٔ `clinicpro/` نبود) را بازگردان یا یک Doctrine fixture برای هر ۵ نقش بساز تا آدیت‌های بعدی ماتریس کامل authz + IDOR بین‌نقشی را زنده تست کنند. الان فقط `admin`+`doctor` در DB هستند. 2. **IDOR دستی:** برای منابع `{uuid}` (پرداخت، نوبت، رکورد بیمار) با دو کاربر هم‌نقش تست مالکیت انجام شود — این آدیت به‌خاطر تک‌کاربربودن هر نقش آن را زنده پوشش نداد. (نکته مثبت: `initiateAppointment` چک `getUser()->getId()` دارد.) 3. **CSP نهایی با nonce:** پس از یافتهٔ ۱، اگر اسکریپت inline لازم شد، nonce به‌جای `unsafe-inline`. 4. **CI dependency gate:** `npm audit --omit=dev` و `composer audit` را در CI با آستانهٔ fail-on-high اضافه کن تا lodash/CKEditor دوباره برنگردند. 5. **Rate limit مثبت:** پیکربندی rate limiter قوی است؛ فقط مطمئن شو روی `payment/callback` هم یک سقف منطقی هست (ضد flood). --- *پایان گزارش — تولیدشده با درایور `symfony-security-audit` + بازرسی دستی. هر یافته بازتولید شده است.*