Files
clinicpro/docs/security/AUDIT-2026-07-19.md
T

20 KiB
Raw Blame History

گزارش آدیت امنیتی 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.yamlcookie_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-encorepretty-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)، این ریسک واقعی است.

۵. بازتولید:

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):

// 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.jsonlodash (GHSA-r5fr-rjxr-66jc). به‌علاوه ۶۱ moderate (عمدتاً درخت @ckeditor/ckeditor5-*).

۴. توضیح: npm audit --omit=dev یک High گزارش می‌دهد: lodash vulnerable to Code Injection via _.template imports key names. اگر جایی _.template روی رشتهٔ کنترل‌شدهٔ کاربر اجرا شود، منجر به اجرای کد در مرورگر می‌شود. در این کدبیس سینک مستقیمی پیدا نشد (احتمالاً وابستگی گذرا)، ولی وجودش در باندل prod ریسک است.

۵. بازتولید:

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):

{
  "overrides": {
    "lodash": "^4.17.21"
  }
}
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:3APP_SECRET=42f34156531bad221462ff02bd43f42b (git-tracked). همچنین .env.test:3 مقدار تستی دارد (بی‌خطر).

۴. توضیح: APP_SECRET برای CSRF token، signed URI و امضای کوکی remember-me استفاده می‌شود. لو رفتنش در git تاریخچه ماندگار است. چون prod سکرت جدا می‌گیرد، تأثیر محدود به محیط dev است؛ ولی اگر روزی deploy به‌اشتباه با APP_ENV=dev بالا بیاید، این سکرتِ عمومی فعال می‌شود.

۵. بازتولید:

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):

# مقدار واقعی در .env.local (خارج از git) بماند
APP_SECRET=change_me_in_env_local

۸. علت انتخاب: Symfony به‌طور استاندارد .env.local را برای overrideهای حساس دارد و آن را ignore می‌کند. گزینهٔ رد شده: پاک‌کردن از تاریخچه با git filter-repo — پرهزینه و برای یک سکرت dev غیرضروری؛ فقط rotate کافی است.


۱. عنوان: 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 است ولی پین‌نشدن آن را شکننده می‌کند.

۵. بازتولید:

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):

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:23private const SANDBOX_PASSWORD = '17384843';

۴. توضیح: اسکنر آن را «hardcoded credential» علامت زد. این پسورد محیط تست عمومی شاپرک است و راز نیست؛ اما ثابت‌بودنش در کد بین «راز» و «مقدار تست» ابهام می‌سازد.

۵. بازتولید:

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 را هرگز هارد‌کد نکن.

۷. کد اصلاح‌شده:

// مقدار عمومی sandbox شاپرک — راز نیست. اعتبار prod از env می‌آید.
private const SANDBOX_PASSWORD = '17384843';

۸. علت انتخاب: حذف false-positive آینده در آدیت‌ها بدون تغییر رفتار. اعتبار prod همان الگوی موجود (env) را نگه می‌دارد.


leadهای درایور که پس از بازرسی رد شدند

lead چرا رد شد
FileUploadService/FileValidatorService rename/fopen «filesystem mutation» نام مقصد از sanitizeFilenamebin2hex(random_bytes(16)).ext؛ محتوا با magic-byte (jpeg/png/webp) تأیید؛ .. مسدود. طراحی امن.
templates/payment/result.html.twig:140redirect_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:88md5 «weak hash» برای تولید شناسهٔ مصنوعی import (غیرامنیتی)، نه هش رمز. بی‌خطر.
SeedDemoDataCommandmt_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 + بازرسی دستی. هر یافته بازتولید شده است.