20 KiB
گزارش آدیت امنیتی 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) برمیگرداند.
- پیگیری (۲۰۲۶-۰۷-۲۳): دو خطای کنسول CSP رفع شد. (الف) فونت Vazirmatn از CDN گوگل حذف شد (
- ۳ — 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به خط نگهداریشده پین شد. نکتهٔ مهم: advisoryGHSA-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 به پکیج umbrellackeditor5(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.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 ریسک است.
۵. بازتولید:
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:3 → APP_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 کافی است.
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 است ولی پیننشدن آن را شکننده میکند.
۵. بازتولید:
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:23 → private 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» |
نام مقصد از 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 میکند. بیخطر. |
پیشنهادهای معماری / امنیت / کیفیت
- پوشش تست نقشها: اسکریپت بازسازی کاربران تست (
create_test_users.phpکه در ریشهٔclinicpro/نبود) را بازگردان یا یک Doctrine fixture برای هر ۵ نقش بساز تا آدیتهای بعدی ماتریس کامل authz + IDOR بیننقشی را زنده تست کنند. الان فقطadmin+doctorدر DB هستند. - IDOR دستی: برای منابع
{uuid}(پرداخت، نوبت، رکورد بیمار) با دو کاربر همنقش تست مالکیت انجام شود — این آدیت بهخاطر تککاربربودن هر نقش آن را زنده پوشش نداد. (نکته مثبت:initiateAppointmentچکgetUser()->getId()دارد.) - CSP نهایی با nonce: پس از یافتهٔ ۱، اگر اسکریپت inline لازم شد، nonce بهجای
unsafe-inline. - CI dependency gate:
npm audit --omit=devوcomposer auditرا در CI با آستانهٔ fail-on-high اضافه کن تا lodash/CKEditor دوباره برنگردند. - Rate limit مثبت: پیکربندی rate limiter قوی است؛ فقط مطمئن شو روی
payment/callbackهم یک سقف منطقی هست (ضد flood).
پایان گزارش — تولیدشده با درایور symfony-security-audit + بازرسی دستی. هر یافته بازتولید شده است.