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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user