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:
hamed
2026-08-07 21:13:38 +03:30
parent a4a24c51af
commit 6876135a53
114 changed files with 2067 additions and 269 deletions
+5 -2
View File
@@ -25,6 +25,7 @@ class BlogController extends BaseController
private readonly FileValidatorService $fileValidator,
private readonly \App\Blog\Service\BlogWriter $blogWriter,
private readonly \App\Blog\Service\BlogCacheInvalidator $cacheInvalidator,
private readonly \App\Blog\Service\BlogBodySanitizer $bodySanitizer,
private readonly string $projectDir,
) {}
@@ -369,7 +370,9 @@ class BlogController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'title و body باید رشته باشند', 422);
}
$title = trim((string) ($data['title'] ?? ''));
$body = trim((string) ($data['body'] ?? ''));
// پاک‌سازی پیش از سنجشِ خالی‌بودن: بدنه‌ای که فقط `<script>` است، بعد از
// پاک‌سازی خالی می‌شود و باید همان ۴۲۲ «الزامی است» را بگیرد، نه ذخیره شود.
$body = $this->bodySanitizer->clean(trim((string) ($data['body'] ?? '')));
if (empty($title) || empty($body)) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'title و body الزامی است', 422);
@@ -475,7 +478,7 @@ class BlogController extends BaseController
$data = json_decode($request->getContent(), true) ?? [];
if (array_key_exists('title', $data)) $blog->setTitle($data['title']);
if (array_key_exists('body', $data)) $blog->setBody($data['body']);
if (array_key_exists('body', $data)) $blog->setBody($this->bodySanitizer->clean((string) $data['body']));
if (array_key_exists('summary', $data)) $blog->setSummary($data['summary']);
if (array_key_exists('tags', $data)) $blog->setTags((array)$data['tags']);
if (array_key_exists('status', $data)) $blog->setStatus($data['status']);
@@ -35,6 +35,7 @@ class RepresentationBlogController extends BaseController
private readonly CityRepository $cityRepo,
private readonly BlogWriter $writer,
private readonly BlogCacheInvalidator $cacheInvalidator,
private readonly \App\Blog\Service\BlogBodySanitizer $bodySanitizer,
) {}
private function currentRepresentation(User $user): Representation
@@ -106,7 +107,9 @@ class RepresentationBlogController extends BaseController
$data = json_decode($request->getContent(), true) ?? [];
$title = trim((string) ($data['title'] ?? ''));
$body = trim((string) ($data['body'] ?? ''));
// پاک‌سازی پیش از سنجشِ خالی‌بودن — بدنه‌ای که چیزی جز markup ناامن ندارد
// باید ۴۲۲ بگیرد نه اینکه خالی ذخیره شود.
$body = $this->bodySanitizer->clean(trim((string) ($data['body'] ?? '')));
if ($title === '' || $body === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'title و body الزامی است', 422);
}
@@ -140,7 +143,7 @@ class RepresentationBlogController extends BaseController
$data = json_decode($request->getContent(), true) ?? [];
if (array_key_exists('title', $data)) $blog->setTitle((string) $data['title']);
if (array_key_exists('body', $data)) $blog->setBody((string) $data['body']);
if (array_key_exists('body', $data)) $blog->setBody($this->bodySanitizer->clean((string) $data['body']));
if (array_key_exists('summary', $data)) $blog->setSummary($data['summary']);
if (array_key_exists('tags', $data)) $blog->setTags((array) $data['tags']);
if (array_key_exists('image_url', $data)) $blog->setImageUrl($data['image_url'] ?: null);
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Blog\Service;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerInterface;
/**
* پاک‌سازی HTML بدنهٔ مقاله در **لحظهٔ ذخیره**.
*
* بدنهٔ مقاله را ادمین با CKEditor می‌نویسد و پنل بعداً با
* `dangerouslySetInnerHTML` رندرش می‌کند (BlogReviewPage). تا پیش از این، هرچه
* می‌آمد همان ذخیره و همان رندر می‌شد.
*
* چرا هنگام ذخیره و نه هنگام نمایش: بدنه چند مصرف‌کننده دارد — پنل ادمین، سایت
* عمومی `nobat724_front` و فید. اگر پاک‌سازی در لایهٔ نمایش بود، هر مصرف‌کنندهٔ
* تازه دوباره آسیب‌پذیر شروع می‌کرد. یک نقطهٔ ورود، یک تضمین.
*
* چرا با وجود CSP: هدر فعلی `script-src 'self'` است و `<script>` تزریقی را اجرا
* نمی‌کند، ولی CSP لایهٔ دوم است نه اولین دفاع، و هندلرهای inline و لینک
* `javascript:` را کامل نمی‌بندد.
*
* سیاست عناصر در `config/packages/html_sanitizer.yaml` است، نه اینجا: همان‌جا
* جای پیکربندی است و تغییرش نباید کد لازم داشته باشد.
*/
class BlogBodySanitizer
{
public function __construct(
#[Autowire(service: 'html_sanitizer.sanitizer.blog.body')]
private readonly HtmlSanitizerInterface $sanitizer,
) {}
public function clean(string $html): string
{
return $this->sanitizer->sanitize($html);
}
}
+42 -13
View File
@@ -18,9 +18,14 @@ class MellatGateway implements PaymentGatewayInterface
// نکته: API روی REST (ipg2) است ولی فرمِ پرداخت فقط روی pgwchannel/startpay.mellat سالم است (ipg2/startpay = 404).
private const SANDBOX_REST_BASE = 'https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/ipg2/rest';
private const SANDBOX_PAYMENT_URL = 'https://sandbox.banktest.ir/mellat/bpm.shaparak.ir/pgwchannel/startpay.mellat';
private const SANDBOX_TERMINAL_ID = '134759344';
private const SANDBOX_USERNAME = 'user134759344';
private const SANDBOX_PASSWORD = '17384843';
/**
* اعتبارنامهٔ نمایشیِ banktest.ir — عمومی و منتشرشده در مستندات خودشان، نه
* secret. با این حال از کد بیرون کشیده شد تا اسکنر امنیتی «پسورد هاردکد»
* نبیند و هرکس خواست بدون ویرایش کد به ترمینال تستِ خودش سوییچ کند.
* مقادیر زیر فقط پیش‌فرض‌اند؛ `MELLAT_SANDBOX_*` بازنویسی‌شان می‌کند.
*/
private const DEFAULT_SANDBOX_TERMINAL_ID = '134759344';
private const DEFAULT_SANDBOX_USERNAME = 'user134759344';
private ?\SoapClient $soap = null;
@@ -31,8 +36,32 @@ class MellatGateway implements PaymentGatewayInterface
private readonly ?string $terminalId = '',
private readonly ?string $username = '',
private readonly ?string $password = '',
private readonly ?string $sandboxTerminalId = '',
private readonly ?string $sandboxUsername = '',
private readonly ?string $sandboxPassword = '',
) {}
private function sandboxTerminalId(): string
{
return ($this->sandboxTerminalId ?: null) ?? self::DEFAULT_SANDBOX_TERMINAL_ID;
}
private function sandboxUsername(): string
{
return ($this->sandboxUsername ?: null) ?? self::DEFAULT_SANDBOX_USERNAME;
}
/**
* برخلاف دو مقدار بالا، این یکی پیش‌فرضِ درون‌کد ندارد و فقط از
* `MELLAT_SANDBOX_PASSWORD` می‌آید (مقدارِ نمایشیِ banktest.ir در `.env`).
* نبودنش یعنی sandbox پیکربندی نشده — و آن بهتر از نگه‌داشتن یک رشتهٔ
* پسوردمانند در `src/` است.
*/
private function sandboxPassword(): string
{
return (string) ($this->sandboxPassword ?: '');
}
public function getName(): string
{
return 'mellat';
@@ -95,7 +124,7 @@ class MellatGateway implements PaymentGatewayInterface
/** هدر Basic Auth برای REST sandbox (base64 از userName:userPassword). */
private function restHeaders(): array
{
$auth = base64_encode(self::SANDBOX_USERNAME . ':' . self::SANDBOX_PASSWORD);
$auth = base64_encode($this->sandboxUsername() . ':' . $this->sandboxPassword());
return ['Content-Type' => 'application/json', 'Authorization' => 'Basic ' . $auth];
}
@@ -123,9 +152,9 @@ class MellatGateway implements PaymentGatewayInterface
// در sandbox، credentials از config خوانده نمی‌شوند تا مقادیر واقعیِ prod نشتی نکنند.
if ($this->sandbox()) {
return match ($key) {
'mellat_terminal_id' => self::SANDBOX_TERMINAL_ID,
'mellat_username' => self::SANDBOX_USERNAME,
'mellat_password' => self::SANDBOX_PASSWORD,
'mellat_terminal_id' => $this->sandboxTerminalId(),
'mellat_username' => $this->sandboxUsername(),
'mellat_password' => $this->sandboxPassword(),
default => $envFallback ?? '',
};
}
@@ -139,9 +168,9 @@ class MellatGateway implements PaymentGatewayInterface
[$resCode, $refId] = $this->restParts(
$this->httpClient->request('POST', self::SANDBOX_REST_BASE . '/bpPayRequest', [
'json' => [
'terminalId' => (int) self::SANDBOX_TERMINAL_ID,
'userName' => self::SANDBOX_USERNAME,
'userPassword' => self::SANDBOX_PASSWORD,
'terminalId' => (int) $this->sandboxTerminalId(),
'userName' => $this->sandboxUsername(),
'userPassword' => $this->sandboxPassword(),
'orderId' => (int) $orderId,
'amount' => $amountRials,
'localDate' => $this->date(),
@@ -215,9 +244,9 @@ class MellatGateway implements PaymentGatewayInterface
// هم sandbox و هم prod: verify سپس settle جدا. 0=موفق، 43=قبلاً verify، 45=قبلاً settle.
if ($this->sandbox()) {
$payload = [
'terminalId' => (int) self::SANDBOX_TERMINAL_ID,
'userName' => self::SANDBOX_USERNAME,
'userPassword' => self::SANDBOX_PASSWORD,
'terminalId' => (int) $this->sandboxTerminalId(),
'userName' => $this->sandboxUsername(),
'userPassword' => $this->sandboxPassword(),
'orderId' => (int) $saleOrderId,
'saleOrderId' => (int) $saleOrderId,
'saleReferenceId' => (int) $saleReferenceId,
@@ -3,9 +3,11 @@
namespace App\Treatment\Controller;
use App\Auth\Entity\User;
use App\Clinic\Security\ClinicDoctorAccessChecker;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Doctor\Service\AddressResolver;
use App\Secretary\Security\SecretaryAccessChecker;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
@@ -31,12 +33,31 @@ class TreatmentProtocolController extends BaseController
private readonly ResourceServiceOfferingRepository $offerings,
private readonly AddressResolver $branches,
private readonly EntityManagerInterface $em,
private readonly SecretaryAccessChecker $secretaryAccess,
private readonly ClinicDoctorAccessChecker $clinicDoctorAccess,
) {}
/**
* پروتکل یک خاصیتِ سرویس است، پس مجوزش هم همان `services` است — قرینهٔ
* ServiceCatalogController و ClinicServiceController.
*
* حذف پروتکل `update` می‌گیرد نه `delete`: سرویس حذف نمی‌شود، فقط سوییچِ
* «طول درمان» روی همان سرویس خاموش می‌شود.
*
* @param 'view'|'update' $action
*/
private function denyUnlessGranted(User $user, string $action): void
{
$this->secretaryAccess->denyUnlessGranted($user, 'services', $action);
$this->clinicDoctorAccess->denyUnlessGranted($user, 'services', $action);
}
/** `null` یعنی سوییچ «طول درمان» خاموش است، نه اینکه چیزی پیدا نشد. */
#[Route('/api/v1/service-item/{uuid}/treatment-protocol', name: 'treatment_protocol_show', methods: ['GET'])]
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$service = $this->requireItem($user, $uuid);
$protocol = $this->protocols->findForService($service);
@@ -57,6 +78,8 @@ class TreatmentProtocolController extends BaseController
#[Route('/api/v1/service-item/{uuid}/treatment-protocol', name: 'treatment_protocol_replace', methods: ['PUT'])]
public function replace(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$this->denyUnlessGranted($user, 'update');
$data = json_decode($request->getContent(), true);
if (!is_array($data)) {
@@ -72,6 +95,8 @@ class TreatmentProtocolController extends BaseController
#[Route('/api/v1/service-item/{uuid}/treatment-protocol', name: 'treatment_protocol_delete', methods: ['DELETE'])]
public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$this->denyUnlessGranted($user, 'update');
$protocol = $this->protocols->findForService($this->requireItem($user, $uuid));
if ($protocol !== null) {