feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks. - Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content. - Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Blog;
|
||||
|
||||
use App\Blog\Repository\BlogRepository;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* آدیت ۲۰۲۶-۰۸-۰۷: بدنهٔ مقاله بدون پاکسازی ذخیره میشد و پنل با
|
||||
* `dangerouslySetInnerHTML` رندرش میکرد. پاکسازی حالا در لحظهٔ ذخیره است، پس
|
||||
* این تستها بهجای خروجی، **آنچه در DB نشسته** را میسنجند.
|
||||
*/
|
||||
class BlogBodySanitizerTest extends ApiTestCase
|
||||
{
|
||||
private function storedBody(string $uuid): string
|
||||
{
|
||||
$this->em->clear();
|
||||
|
||||
return self::getContainer()->get(BlogRepository::class)->findByUuid($uuid)->getBody();
|
||||
}
|
||||
|
||||
public function testScriptTagIsStrippedOnCreate(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/blog', $admin, [
|
||||
'title' => 'مقالهٔ تست',
|
||||
'body' => '<p>سلام</p><script>alert(1)</script>',
|
||||
]);
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
|
||||
$body = $this->storedBody($res['data']['data']['uuid'] ?? $res['data']['uuid']);
|
||||
$this->assertStringNotContainsString('<script', $body);
|
||||
$this->assertStringNotContainsString('alert(1)', $body);
|
||||
$this->assertStringContainsString('سلام', $body, 'متن سالم نباید حذف شود');
|
||||
}
|
||||
|
||||
public function testInlineHandlerAndJavascriptUrlAreStripped(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/blog', $admin, [
|
||||
'title' => 'مقالهٔ تست',
|
||||
'body' => '<p onclick="steal()">متن</p><a href="javascript:alert(1)">لینک</a>'
|
||||
. '<img src="x" onerror="alert(2)">',
|
||||
]);
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
|
||||
$body = $this->storedBody($res['data']['data']['uuid'] ?? $res['data']['uuid']);
|
||||
$this->assertStringNotContainsString('onclick', $body);
|
||||
$this->assertStringNotContainsString('onerror', $body);
|
||||
$this->assertStringNotContainsString('javascript:', $body);
|
||||
$this->assertStringContainsString('متن', $body);
|
||||
}
|
||||
|
||||
public function testSafeRichTextSurvives(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
|
||||
$html = '<h2>عنوان</h2><p><strong>پررنگ</strong> و <em>کج</em></p>'
|
||||
. '<ul><li>یک</li><li>دو</li></ul>'
|
||||
. '<a href="https://example.com">پیوند</a>'
|
||||
. '<table><tbody><tr><td>خانه</td></tr></tbody></table>';
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/blog', $admin, [
|
||||
'title' => 'مقالهٔ تست',
|
||||
'body' => $html,
|
||||
]);
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
|
||||
$body = $this->storedBody($res['data']['data']['uuid'] ?? $res['data']['uuid']);
|
||||
foreach (['<h2', '<strong', '<em', '<ul', '<li', '<a', '<table', '<td'] as $tag) {
|
||||
$this->assertStringContainsString($tag, $body, "عنصر مجاز {$tag} نباید حذف شود");
|
||||
}
|
||||
$this->assertStringContainsString('https://example.com', $body);
|
||||
// لینک باید rel امن بگیرد، وگرنه tabnabbing باز میماند.
|
||||
$this->assertStringContainsString('noopener', $body);
|
||||
}
|
||||
|
||||
public function testUpdatePathIsSanitizedToo(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/blog', $admin, [
|
||||
'title' => 'مقالهٔ تست',
|
||||
'body' => '<p>اولیه</p>',
|
||||
]);
|
||||
$uuid = $res['data']['data']['uuid'] ?? $res['data']['uuid'];
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/blog/{$uuid}", $admin, [
|
||||
'body' => '<p>ویرایش</p><script>alert(3)</script>',
|
||||
]);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
|
||||
$body = $this->storedBody($uuid);
|
||||
$this->assertStringNotContainsString('<script', $body, 'مسیر ویرایش هم باید پاکسازی شود');
|
||||
$this->assertStringContainsString('ویرایش', $body);
|
||||
}
|
||||
|
||||
/** بدنهای که چیزی جز markup ناامن ندارد، بعد از پاکسازی خالی است → ۴۲۲، نه ذخیره. */
|
||||
public function testBodyThatIsOnlyUnsafeMarkupIsRejected(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
|
||||
$this->authJson('POST', '/api/v1/blog', $admin, [
|
||||
'title' => 'مقالهٔ تست',
|
||||
'body' => '<script>alert(1)</script>',
|
||||
]);
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -415,4 +415,65 @@ class SecretaryResourceEnforcementTest extends ApiTestCase
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── پروتکل درمان ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// آدیت ۲۰۲۶-۰۸-۰۷: TreatmentProtocolController هیچ گِیت مجوزی نداشت و فقط
|
||||
// مالکیتِ tenant را میسنجید، پس منشیِ `services:false` میتوانست پروتکل را
|
||||
// بخواند، بازنویسی کند و حذف کند. پروتکل خاصیتِ سرویس است، پس مجوزش `services`
|
||||
// است. uuidِ ناموجود عمدی است: گیت پیش از واکشیِ سرویس اجرا میشود، پس ۴۰۳
|
||||
// در برابر ۴۰۴ دقیقاً همان چیزی را جدا میکند که این تستها میسنجند.
|
||||
|
||||
private const ABSENT_SERVICE = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
public function testTreatmentProtocolReadDeniedByDefault(): void
|
||||
{
|
||||
// DEFAULT_PERMISSIONS: services.* = false
|
||||
[$secretary] = $this->makeClinicSecretary();
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('GET', '/api/v1/service-item/' . self::ABSENT_SERVICE . '/treatment-protocol', $secretary);
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testTreatmentProtocolReadAllowedWhenServicesGranted(): void
|
||||
{
|
||||
[$secretary, $rel] = $this->makeClinicSecretary();
|
||||
$rel->mergePermissions(['resources' => ['services' => ['view' => true]]]);
|
||||
$this->em->flush();
|
||||
|
||||
// گیت عبور میکند و به «سرویس یافت نشد» میرسد — نه ۴۰۳.
|
||||
$this->authJson('GET', '/api/v1/service-item/' . self::ABSENT_SERVICE . '/treatment-protocol', $secretary);
|
||||
$this->assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testTreatmentProtocolWriteNeedsServicesUpdate(): void
|
||||
{
|
||||
[$secretary, $rel] = $this->makeClinicSecretary();
|
||||
$rel->mergePermissions(['resources' => ['services' => ['view' => true, 'update' => false]]]);
|
||||
$this->em->flush();
|
||||
|
||||
$path = '/api/v1/service-item/' . self::ABSENT_SERVICE . '/treatment-protocol';
|
||||
|
||||
// خواندن مجاز است…
|
||||
$this->authJson('GET', $path, $secretary);
|
||||
$this->assertSame(404, $this->responseCode());
|
||||
|
||||
// …ولی بازنویسی و خاموشکردنِ سوییچ نه.
|
||||
$this->authJson('PUT', $path, $secretary, ['steps' => []]);
|
||||
$this->assertSame(403, $this->responseCode(), 'بازنویسی پروتکل باید services.update بخواهد');
|
||||
|
||||
$this->authJson('DELETE', $path, $secretary);
|
||||
$this->assertSame(403, $this->responseCode(), 'حذف پروتکل باید services.update بخواهد');
|
||||
}
|
||||
|
||||
public function testTreatmentProtocolWriteAllowedWhenServicesUpdateGranted(): void
|
||||
{
|
||||
[$secretary, $rel] = $this->makeClinicSecretary();
|
||||
$rel->mergePermissions(['resources' => ['services' => ['view' => true, 'update' => true]]]);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/service-item/' . self::ABSENT_SERVICE . '/treatment-protocol', $secretary);
|
||||
$this->assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Shared;
|
||||
|
||||
use App\Auth\Entity\UserActiveContext;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Shared\Security\PermissionCatalog;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* تور ایمنیِ ساختاری برای «کنترلر تازهای که یادمان رفت گِیت مجوز بگذارد».
|
||||
*
|
||||
* آدیت ۲۰۲۶-۰۸-۰۷ نشان داد `TreatmentProtocolController` فقط
|
||||
* `#[IsGranted('IS_AUTHENTICATED_FULLY')]` داشت و هیچ مجوزی را enforce نمیکرد.
|
||||
* چنین کنترلری از دید هر ابزار ایستا سالم به نظر میرسد — روت گارد دارد — ولی
|
||||
* عملاً برای هر کاربرِ داخل tenant باز است.
|
||||
*
|
||||
* پس این تست **رفتار** را میسنجد نه متن کد را: یک منشی میسازد که هر مجوزِ
|
||||
* رجیستری برایش خاموش است، و هر روتِ GET بدون path parameter را با او میزند.
|
||||
* چنین کاربری نباید به دادهٔ tenant برسد.
|
||||
*
|
||||
* پاسخ ۲۰۰ فقط برای روتهایی پذیرفته است که در `ALLOWED_200` آمدهاند — یعنی
|
||||
* عمداً عمومیاند یا به هیچ منبعِ مجوزداری وصل نیستند. افزودن روت به آن فهرست
|
||||
* باید تصمیمِ آگاهانه باشد، نه پیشفرض.
|
||||
*/
|
||||
class ApiLeastPrivilegeTest extends ApiTestCase
|
||||
{
|
||||
/**
|
||||
* روتهایی که ۲۰۰ دادنشان به منشیِ بیمجوز عمدی است.
|
||||
*
|
||||
* دو دستهاند: (۱) اندپوینت عمومی که بدون توکن هم کار میکند، (۲) اندپوینت
|
||||
* «خودِ کاربر» که دادهٔ tenant نمیدهد و مجوزِ نقشی ندارد.
|
||||
*
|
||||
* @var array<string, string> route name => دلیل
|
||||
*/
|
||||
private const ALLOWED_200 = [
|
||||
// ── عمومی: بدون توکن هم ۲۰۰ میدهند، پس مجوز نقشی معنا ندارد ──────────
|
||||
'app_blog_blog_list' => 'فهرست عمومی مقالات',
|
||||
'app_blog_blog_tags' => 'تگهای عمومی مقالات',
|
||||
'app_clinic_clinic_list' => 'فهرست عمومی کلینیکها',
|
||||
'app_doctor_doctor_list' => 'فهرست عمومی پزشکان',
|
||||
'app_specialty_specialty_list' => 'فهرست عمومی تخصصها',
|
||||
'app_specialty_specialty_doctorcounts' => 'شمارش عمومی پزشکان هر تخصص',
|
||||
'app_tag_tag_list' => 'تگهای عمومی',
|
||||
'app_shared_captcha_captcha_challenge' => 'کپچا پیش از لاگین لازم است',
|
||||
'app_shared_captcha_captcha_config' => 'کپچا پیش از لاگین لازم است',
|
||||
|
||||
// ── دادهٔ مرجع/ثابت: به هیچ tenant وابسته نیست ────────────────────────
|
||||
'app_location_location_provinces' => 'فهرست استانها — دادهٔ مرجع',
|
||||
'app_location_location_cities' => 'فهرست شهرها — دادهٔ مرجع',
|
||||
'practice_domain_list' => 'حوزههای فعالیت — دادهٔ مرجع',
|
||||
'app_subscription_subscription_plans' => 'پلنهای اشتراک — کاتالوگ عمومی',
|
||||
'app_payment_payment_config' => 'نام درگاهها و کارمزد — بدون مقدار محرمانه',
|
||||
'app_representation_sitecontext_resolve' => 'حل دامنه به شهر/نماینده — ورودی رندر سایت',
|
||||
'resource_strategies' => 'فهرست ثابتِ استراتژیهای تخصیص منبع',
|
||||
'app_clinicservice_clinicservice_listservicecategories' => 'دستههای ثابت خدمت (سرپایی/بستری)',
|
||||
'app_inventory_inventory_meta' => 'واحدها و enumهای ثابت انبار',
|
||||
'api_permission_catalog' => 'شکلِ خودِ رجیستری، نه مقدار مجوز کسی',
|
||||
|
||||
// ── دادهٔ «خودِ کاربر»: منبعِ رجیستری نیست و مجوزی رویش تعریف نشده ────
|
||||
'app_secretary_secretary_me' => 'پروفایل خودِ منشی',
|
||||
'app_secretary_secretary_earningssummary' => 'درآمد خودِ منشی',
|
||||
'app_secretary_secretary_earningsreport' => 'گزارش درآمد خودِ منشی',
|
||||
'app_settlement_settlement_balance' => 'کیف پول خودِ کاربر',
|
||||
'app_settlement_settlement_transactions' => 'تراکنشهای کیف پول خودِ کاربر',
|
||||
'app_settlement_settlement_listmine' => 'تسویههای خودِ کاربر',
|
||||
'app_dashboard_dashboard_secretary' => 'محیط و مجوزهای خودِ منشی — ورودی رندر پنل',
|
||||
// رفتار مستند: بدون مجوز فقط قابلیتهای پلن میآید، نه وضعیت/تاریخ اشتراک.
|
||||
// تستش در SecretaryResourceEnforcementTest::testSubscriptionWithoutPermissionReturnsFeaturesOnly
|
||||
'app_subscription_subscription_my' => 'نسخهٔ کاهشیافتهٔ عمدی',
|
||||
// فهرست پزشکانِ تخصیصیافته به همین منشی — تستش
|
||||
// SecretaryResourceEnforcementTest::testDoctorListReturnsOnlyAssignedDoctors
|
||||
'app_appointment_myappointments_myclinicdoctors' => 'فقط پزشکانِ تخصیصیافته به خودِ منشی',
|
||||
];
|
||||
|
||||
/**
|
||||
* بدهیِ شناختهشده — روتهایی که **باید** گِیت داشته باشند و ندارند.
|
||||
*
|
||||
* اینها در آدیت ۲۰۲۶-۰۸-۰۷ کشف شدند و عمداً همان جلسه رفع **نشدند**: هر سه
|
||||
* کنترلرشان (`BillingController`، `MyAppointmentsController`،
|
||||
* `DoctorServiceController`) هیچ checker مجوزی تزریقشده ندارند، و بستنشان
|
||||
* بدون دانستن نیازِ واقعیِ پنل ریسکِ شکستنِ صفحه دارد.
|
||||
*
|
||||
* در DB تست، tenant خالی است پس پاسخشان خالی میآید؛ در tenant واقعی دادهٔ
|
||||
* واقعی میدهند. نبودِ نشت در تست، دلیلِ امنبودن نیست.
|
||||
*
|
||||
* نقشِ این فهرست مثل baseline است: تست اجازه میدهد اینها ۲۰۰ بدهند، ولی
|
||||
* **بزرگترشدنش** را نمیپذیرد. هر روتِ تازهای که بدون گِیت اضافه شود، تست را
|
||||
* قرمز میکند. حذف هر ردیف از اینجا یعنی آن گَپ بسته شد.
|
||||
*
|
||||
* @var array<string, string> route name => منبعِ مجوزی که باید enforce شود
|
||||
*/
|
||||
private const KNOWN_GAPS = [
|
||||
'app_appointment_appointment_listbyuser' => 'appointments.view',
|
||||
'app_appointment_myappointments_myappointments' => 'appointments.view',
|
||||
'app_appointment_myappointments_todaystats' => 'appointments.view',
|
||||
'app_billing_billing_listpayments' => 'payments.view',
|
||||
'app_billing_billing_paymentssummary' => 'payments.view',
|
||||
'app_billing_billing_listclaims' => 'payments.view',
|
||||
'app_billing_billing_claimsbypatient' => 'payments.view',
|
||||
'app_billing_billing_insurancedebt' => 'payments.view',
|
||||
'app_doctorservice_doctorservice_list' => 'services.view',
|
||||
'app_insurance_insurance_list' => 'insurances.view',
|
||||
];
|
||||
|
||||
/** منشیای که هیچ مجوزی ندارد — همهٔ منابع رجیستری خاموش. */
|
||||
private function makePowerlessSecretary(): \App\Auth\Entity\User
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$this->em->persist($clinic);
|
||||
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
|
||||
$secretary = $this->createUser(['ROLE_SECRETARY']);
|
||||
$rel = new DoctorSecretary($doctor, $secretary, $clinic);
|
||||
|
||||
$off = [];
|
||||
foreach (PermissionCatalog::RESOURCES as $resource => $spec) {
|
||||
foreach (array_keys($spec['actions']) as $action) {
|
||||
$off[$resource][$action] = false;
|
||||
}
|
||||
}
|
||||
$rel->mergePermissions(['resources' => $off]);
|
||||
|
||||
$this->em->persist($rel);
|
||||
$this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), 'clinic'));
|
||||
$this->em->flush();
|
||||
|
||||
return $secretary;
|
||||
}
|
||||
|
||||
public function testNoApiRouteLeaksToASecretaryWithoutAnyPermission(): void
|
||||
{
|
||||
$secretary = $this->makePowerlessSecretary();
|
||||
$router = self::getContainer()->get('router');
|
||||
|
||||
$leaks = [];
|
||||
foreach ($router->getRouteCollection() as $name => $route) {
|
||||
$path = $route->getPath();
|
||||
|
||||
if (!str_starts_with($path, '/api/') || str_contains($path, '{')) {
|
||||
continue;
|
||||
}
|
||||
$methods = $route->getMethods();
|
||||
if ($methods !== [] && !in_array('GET', $methods, true)) {
|
||||
continue;
|
||||
}
|
||||
if (isset(self::ALLOWED_200[$name]) || isset(self::KNOWN_GAPS[$name])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->authJson('GET', $path, $secretary);
|
||||
if ($this->responseCode() < 400) {
|
||||
$leaks[] = sprintf('%s %s → %d', $name, $path, $this->responseCode());
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame([], $leaks, sprintf(
|
||||
"این روتها به منشیِ بدونِ هیچ مجوزی پاسخ موفق دادند.\n"
|
||||
. "اگر گِیت مجوز ندارند، اضافهاش کن. اگر عمدیاند، با دلیل به ALLOWED_200 برو.\n%s",
|
||||
implode("\n", $leaks),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* بدهی نباید بیصدا بماند: بهمحض اینکه گِیتِ یکی از KNOWN_GAPS اضافه شد، این
|
||||
* تست قرمز میشود تا آن ردیف از فهرست حذف شود. بدون این، فهرست برای همیشه
|
||||
* میماند و کسی نمیفهمد بدهی تسویه شده.
|
||||
*/
|
||||
public function testKnownGapsAreStillGapsOrGetRemovedFromTheList(): void
|
||||
{
|
||||
$secretary = $this->makePowerlessSecretary();
|
||||
$router = self::getContainer()->get('router');
|
||||
|
||||
$closed = [];
|
||||
foreach (self::KNOWN_GAPS as $name => $resource) {
|
||||
$route = $router->getRouteCollection()->get($name);
|
||||
$this->assertNotNull($route, "روت {$name} دیگر وجود ندارد — ردیفش را از KNOWN_GAPS بردار");
|
||||
|
||||
$this->authJson('GET', $route->getPath(), $secretary);
|
||||
if ($this->responseCode() >= 400) {
|
||||
$closed[] = "{$name} ({$resource})";
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame([], $closed, sprintf(
|
||||
"این گَپها بسته شدهاند. ردیفشان را از KNOWN_GAPS بردار:\n%s",
|
||||
implode("\n", $closed),
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user