diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml new file mode 100644 index 0000000..c3b4534 --- /dev/null +++ b/.github/workflows/security-audit.yml @@ -0,0 +1,51 @@ +name: Security & Audit Pipeline + +on: + push: + branches: [ "main", "master", "develop" ] + pull_request: + branches: [ "main", "master" ] + +jobs: + audit-and-security: + name: Security Scan & Dependency Audit + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Run Gitleaks to detect secrets + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Backend Security Audit + run: | + cd backend + npm audit --audit-level=high + + - name: Frontend Security Audit + run: | + cd frontend/application + npm audit --audit-level=high + + - name: Backend Build & Type Check + run: | + cd backend + npm ci + npm run build + + - name: Frontend Build & Type Check + run: | + cd frontend/application + npm ci + npm run build diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..58342c7 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,16 @@ +# Gitleaks Configuration for Canina Iran Monorepo +title = "Canina Iran Gitleaks Config" + +[extend] +useDefault = true + +[allowlist] +description = "Global allowlist for example and mock files" +paths = [ + '''^\.env\.example$''', + '''^backend/\.env\.example$''', + '''^frontend/application/\.env\.example$''', + '''node_modules/''', + '''\.next/''', + '''dist/''' +] diff --git a/backend/src/admin/api-keys.service.ts b/backend/src/admin/api-keys.service.ts index efaa236..66c3a05 100644 --- a/backend/src/admin/api-keys.service.ts +++ b/backend/src/admin/api-keys.service.ts @@ -151,7 +151,8 @@ export class ApiKeysService { .catch(() => {}); const scopes = key.scopes.split(',').map((s) => s.trim().toLowerCase()); - const hasAdminScope = scopes.includes('*') || scopes.includes('admin') || scopes.includes('all'); + // SEC-016: Disallow wildcard '*' from granting admin role; require explicit 'admin' scope + const hasAdminScope = scopes.includes('admin'); return { id: key.id, diff --git a/backend/src/admin/media.service.ts b/backend/src/admin/media.service.ts index 4c7de22..eec2180 100644 --- a/backend/src/admin/media.service.ts +++ b/backend/src/admin/media.service.ts @@ -51,7 +51,11 @@ export class MediaService { } const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); - const ext = path.extname(originalname); + const ext = path.extname(originalname).toLowerCase(); + const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.pdf']; + if (!ALLOWED_EXTENSIONS.includes(ext)) { + throw new BadRequestException('پسوند فایل ارسالی مجاز نیست (فرمت SVG و فایل‌های اجرایی مسدود هستند).'); + } const filename = `${uniqueSuffix}${ext}`; const filePath = path.join(uploadDir, filename); diff --git a/backend/src/admin/ssl.service.ts b/backend/src/admin/ssl.service.ts index a6873c4..eba3e08 100644 --- a/backend/src/admin/ssl.service.ts +++ b/backend/src/admin/ssl.service.ts @@ -179,7 +179,27 @@ export class SslService { const cleanDomain = domain .replace(/^https?:\/\//, '') .replace(/\/.*$/, '') - .trim(); + .trim() + .toLowerCase(); + + // SEC-009: Strict domain validation preventing SSRF + const DOMAIN_REGEX = /^(?!-)[a-zA-Z0-9-]{1,63}(? ({ secret: getJwtSecret(), - signOptions: { expiresIn: '7d' }, + signOptions: { expiresIn: '15m' }, }), }), ], diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index d09fbee..1a61236 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,6 +1,7 @@ import { Injectable, BadRequestException, + UnauthorizedException, HttpException, HttpStatus, } from '@nestjs/common'; @@ -13,6 +14,7 @@ import { SmsService } from '../common/services/sms.service'; import * as bcrypt from 'bcryptjs'; import * as crypto from 'crypto'; import { normalizeMobile } from '../common/utils/phone.utils'; +import { getJwtRefreshSecret } from './auth.constants'; export class RegisterInput { firstName!: string; @@ -42,6 +44,28 @@ export class AuthService { private smsService: SmsService, ) {} + private async generateTokens(payload: Record) { + const accessToken = this.jwtService.sign(payload); + + // Refresh token with 30-day lifetime signed by refresh secret + const refreshSecret = getJwtRefreshSecret(); + const tokenId = crypto.randomUUID(); + const refreshPayload = { ...payload, jti: tokenId }; + const refreshToken = this.jwtService.sign(refreshPayload, { + secret: refreshSecret, + expiresIn: '30d', + }); + + // Store active refresh token in Redis (30 days TTL = 2,592,000s) + await this.redisService.set( + `refresh_token:${payload.sub}:${tokenId}`, + '1', + 30 * 24 * 3600, + ); + + return { accessToken, refreshToken }; + } + async sendOtp(sendOtpDto: SendOtpDto) { const phoneNumber = normalizeMobile(sendOtpDto.phoneNumber); @@ -152,15 +176,16 @@ export class AuthService { }); } - const payload = { sub: user.id, phoneNumber: user.mobile }; - const accessToken = this.jwtService.sign(payload); + const payload = { sub: user.id, phoneNumber: user.mobile, role: user.role }; + const tokens = await this.generateTokens(payload); const { password: _p, ...safeUser } = user; return { success: true, data: { user: safeUser, - accessToken, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, }, }; } @@ -208,15 +233,16 @@ export class AuthService { }, }); - const payload = { sub: user.id, phoneNumber: user.mobile }; - const accessToken = this.jwtService.sign(payload); + const payload = { sub: user.id, phoneNumber: user.mobile, role: user.role }; + const tokens = await this.generateTokens(payload); const { password: _p, ...safeUser } = user; return { success: true, data: { user: safeUser, - accessToken, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, }, }; } @@ -250,21 +276,21 @@ export class AuthService { }); } - const payload = { sub: user.id, phoneNumber: user.mobile }; - const accessToken = this.jwtService.sign(payload); + const payload = { sub: user.id, phoneNumber: user.mobile, role: user.role }; + const tokens = await this.generateTokens(payload); const { password: _pw, ...safeUser } = user; return { success: true, data: { user: safeUser, - accessToken, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, }, }; } async adminLogin(body: AdminLoginInput) { - // First check database for user with role 'Admin' or 'SUPER_ADMIN' const dbAdmin = await this.prisma.user.findFirst({ where: { @@ -281,18 +307,28 @@ export class AuthService { email: dbAdmin.email, role: dbAdmin.role, }; + const tokens = await this.generateTokens(payload); const { password: _p, ...safeUser } = dbAdmin; return { success: true, data: { user: safeUser, - accessToken: this.jwtService.sign(payload), + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, }, }; } } - // Fallback to validated environment variable credentials only if configured securely + // In production, fallback credentials MUST NOT be accepted + if (process.env.NODE_ENV === 'production') { + throw new BadRequestException({ + message: 'ایمیل یا رمز عبور مدیریت اشتباه است', + error: 'INVALID_ADMIN_CREDENTIALS', + }); + } + + // Fallback to validated environment variable credentials only in non-production const adminEmail = process.env.ADMIN_EMAIL; const adminPassword = process.env.ADMIN_PASSWORD; @@ -322,6 +358,7 @@ export class AuthService { email: adminEmail, role: 'Admin', }; + const tokens = await this.generateTokens(payload); return { success: true, data: { @@ -330,7 +367,8 @@ export class AuthService { email: body.email, role: 'Admin', }, - accessToken: this.jwtService.sign(payload), + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, }, }; } @@ -342,7 +380,83 @@ export class AuthService { }); } - refresh(user: { + async refreshToken(rawRefreshToken: string) { + if (!rawRefreshToken) { + throw new UnauthorizedException('توکن تازه‌سازی یافت نشد'); + } + + let decoded: any; + try { + decoded = this.jwtService.verify(rawRefreshToken, { + secret: getJwtRefreshSecret(), + }); + } catch { + throw new UnauthorizedException('توکن تازه‌سازی نامعتبر یا منقضی شده است'); + } + + const userId = decoded.sub; + const tokenId = decoded.jti; + + if (!userId || !tokenId) { + throw new UnauthorizedException('اطلاعات توکن نامعتبر است'); + } + + // Check if token exists in Redis + const tokenKey = `refresh_token:${userId}:${tokenId}`; + const tokenValid = await this.redisService.get(tokenKey); + if (!tokenValid) { + throw new UnauthorizedException('توکن تازه‌سازی باطل شده است. لطفاً مجدداً وارد شوید'); + } + + // Invalidate old token (Rotation) + await this.redisService.del(tokenKey); + + let userPayload: Record; + let safeUser: any; + + if (userId === '12345678-1234-1234-1234-123456789012') { + if (process.env.NODE_ENV === 'production') { + throw new UnauthorizedException('حساب کاربری مدیریت معتبر نیست'); + } + userPayload = { + sub: userId, + email: decoded.email, + role: 'Admin', + }; + safeUser = { + id: userId, + email: decoded.email, + role: 'Admin', + }; + } else { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new UnauthorizedException('کاربر یافت نشد'); + } + userPayload = { + sub: user.id, + email: user.email, + phoneNumber: user.mobile, + role: user.role, + }; + const { password: _p, ...restUser } = user; + safeUser = restUser; + } + + // Issue new pair of tokens + const tokens = await this.generateTokens(userPayload); + + return { + success: true, + data: { + user: safeUser, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }, + }; + } + + async refresh(user: { id: string; email?: string; role?: string; @@ -354,11 +468,13 @@ export class AuthService { role: user.role, phoneNumber: user.mobile, }; + const tokens = await this.generateTokens(payload); return { success: true, data: { user, - accessToken: this.jwtService.sign(payload), + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, }, }; } @@ -366,7 +482,7 @@ export class AuthService { async blacklistToken(token: string) { try { const decoded: any = this.jwtService.decode(token); - let ttl = 7 * 24 * 3600; // default 7 days + let ttl = 15 * 60; // 15 minutes default for access token if (decoded && decoded.exp) { const remaining = decoded.exp - Math.floor(Date.now() / 1000); if (remaining > 0) { @@ -374,9 +490,13 @@ export class AuthService { } } await this.redisService.set(`bl_token:${token}`, '1', ttl); + + // If token payload has jti and sub, also invalidate the refresh token in Redis + if (decoded && decoded.sub && decoded.jti) { + await this.redisService.del(`refresh_token:${decoded.sub}:${decoded.jti}`); + } } catch { - // Fallback: cache for 7 days - await this.redisService.set(`bl_token:${token}`, '1', 7 * 24 * 3600); + await this.redisService.set(`bl_token:${token}`, '1', 15 * 60); } } } diff --git a/backend/src/auth/dto/refresh-token.dto.ts b/backend/src/auth/dto/refresh-token.dto.ts new file mode 100644 index 0000000..73f05b6 --- /dev/null +++ b/backend/src/auth/dto/refresh-token.dto.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class RefreshTokenDto { + @ApiProperty({ + description: 'توکن تازه‌سازی (Refresh Token)', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }) + @IsNotEmpty({ message: 'ارسال توکن تازه‌سازی الزامی است' }) + @IsString({ message: 'توکن تازه‌سازی باید رشته متنی باشد' }) + refreshToken: string; +} diff --git a/backend/src/auth/jwt.strategy.ts b/backend/src/auth/jwt.strategy.ts index 2c30539..7b42209 100644 --- a/backend/src/auth/jwt.strategy.ts +++ b/backend/src/auth/jwt.strategy.ts @@ -36,8 +36,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) { throw new UnauthorizedException('این توکن باطل شده است. لطفاً مجدداً وارد شوید'); } } - // Handle fallback admin ID only if ADMIN_EMAIL is configured and matches payload + // Handle fallback admin ID only in non-production environments if ADMIN_EMAIL matches if (payload.sub === '12345678-1234-1234-1234-123456789012') { + if (process.env.NODE_ENV === 'production') { + throw new UnauthorizedException('حساب کاربری مدیریت معتبر نیست'); + } const adminEmail = process.env.ADMIN_EMAIL; if (adminEmail && payload.email === adminEmail) { return { diff --git a/backend/src/common/guards/roles.guard.ts b/backend/src/common/guards/roles.guard.ts index 2b3786f..1ceb813 100644 --- a/backend/src/common/guards/roles.guard.ts +++ b/backend/src/common/guards/roles.guard.ts @@ -38,10 +38,7 @@ export class RolesGuard implements CanActivate { // If request originates from an API key, verify it has admin scope for admin-guarded routes if (user.isApiKey) { const scopes = Array.isArray(user.scopes) ? user.scopes : []; - const hasAdminScope = - scopes.includes('*') || - scopes.includes('admin') || - scopes.includes('all'); + const hasAdminScope = scopes.includes('admin'); if (!hasAdminScope) { throw new ForbiddenException( 'کلید API ارائه‌شده فاقد اسکوپ مدیریتی لازم برای این عملیات است', diff --git a/backend/src/common/revalidation/revalidation.service.ts b/backend/src/common/revalidation/revalidation.service.ts index 34af670..d2a429e 100644 --- a/backend/src/common/revalidation/revalidation.service.ts +++ b/backend/src/common/revalidation/revalidation.service.ts @@ -13,9 +13,15 @@ export class RevalidationService { } private get secret(): string { - return ( - process.env.REVALIDATION_SECRET || 'canina_revalidation_secret_key_2026' - ); + const secret = process.env.REVALIDATION_SECRET; + if (!secret) { + if (process.env.NODE_ENV === 'production') { + throw new Error('REVALIDATION_SECRET is required in production environment.'); + } + this.logger.warn('REVALIDATION_SECRET is not defined in environment variables.'); + return ''; + } + return secret; } async revalidateTag(tag: string): Promise { diff --git a/backend/src/main.ts b/backend/src/main.ts index 5e8daeb..8b9afcf 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -49,12 +49,14 @@ async function bootstrap() { contentSecurityPolicy: process.env.NODE_ENV === 'production' ? { directives: { defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'"], - styleSrc: ["'self'", "'unsafe-inline'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], // Needed for swagger and UI styling if applicable imgSrc: ["'self'", 'data:', 'blob:', 'https:'], connectSrc: ["'self'", 'https:'], fontSrc: ["'self'", 'data:'], objectSrc: ["'none'"], + baseUri: ["'self'"], + frameAncestors: ["'none'"], upgradeInsecureRequests: [], }, } : false, diff --git a/backend/src/orders/orders.controller.ts b/backend/src/orders/orders.controller.ts index a53f70f..f69aea1 100644 --- a/backend/src/orders/orders.controller.ts +++ b/backend/src/orders/orders.controller.ts @@ -147,6 +147,25 @@ export class OrdersController { return this.ordersService.findAllByUser(req.user.id, query); } + @Post(':id/retry-payment') + @ApiOperation({ + summary: 'پرداخت مجدد و تکمیل سفارش با امکان انتخاب روش پرداخت', + }) + @ApiOkResponse({ + description: 'درخواست پرداخت مجدد با موفقیت پردازش شد', + }) + retryPayment( + @Req() req: { user: { id: string } }, + @Param('id') id: string, + @Body() body: { paymentMethod?: string }, + ) { + return this.ordersService.retryPayment( + req.user.id, + id, + body?.paymentMethod, + ); + } + @Get(':id') @ApiOperation({ summary: 'دریافت جزئیات یک سفارش خاص' }) @ApiOkResponse({ @@ -186,25 +205,6 @@ export class OrdersController { }, }, }) - @Post(':id/retry-payment') - @ApiOperation({ - summary: 'پرداخت مجدد و تکمیل سفارش با امکان انتخاب روش پرداخت', - }) - @ApiOkResponse({ - description: 'درخواست پرداخت مجدد با موفقیت پردازش شد', - }) - retryPayment( - @Req() req: { user: { id: string } }, - @Param('id') id: string, - @Body() body: { paymentMethod?: string }, - ) { - return this.ordersService.retryPayment( - req.user.id, - id, - body?.paymentMethod, - ); - } - findOne(@Req() req: { user: { id: string } }, @Param('id') id: string) { return this.ordersService.findOne(id, req.user.id); } diff --git a/backend/src/orders/orders.service.ts b/backend/src/orders/orders.service.ts index ce5535e..755122a 100644 --- a/backend/src/orders/orders.service.ts +++ b/backend/src/orders/orders.service.ts @@ -196,20 +196,20 @@ export class OrdersService { if (!user) { throw new NotFoundException('کاربر یافت نشد'); } - const userBalance = new Prisma.Decimal(user.walletBalance || 0); - if (userBalance.lessThan(finalAmount)) { - throw new BadRequestException( - 'موجودی کیف پول برای پرداخت این سفارش کافی نیست', - ); - } const createdWalletOrder = await this.prisma.$transaction(async (tx) => { - await tx.user.update({ - where: { id: userId }, - data: { - walletBalance: { decrement: Number(finalAmount) }, - }, - }); + // SEC-002: Atomic decrement preventing race condition & overdraft + const updatedCount = await tx.$executeRaw` + UPDATE "users" + SET "wallet_balance" = "wallet_balance" - ${Number(finalAmount)}::decimal + WHERE "id" = ${userId}::uuid AND "wallet_balance" >= ${Number(finalAmount)}::decimal + `; + + if (updatedCount === 0) { + throw new BadRequestException( + 'موجودی کیف پول برای پرداخت این سفارش کافی نیست', + ); + } await tx.walletTransaction.create({ data: { @@ -581,18 +581,19 @@ export class OrdersService { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new NotFoundException('کاربر یافت نشد'); - const userBalance = Number(user.walletBalance || 0); - if (userBalance < finalAmount) { - throw new BadRequestException( - `موجودی کیف پول شما (${userBalance.toLocaleString('fa-IR')} تومان) کمتر از مبلغ سفارش (${finalAmount.toLocaleString('fa-IR')} تومان) است.`, - ); - } - await this.prisma.$transaction(async (tx) => { - await tx.user.update({ - where: { id: userId }, - data: { walletBalance: { decrement: finalAmount } }, - }); + // SEC-002: Atomic decrement preventing race condition & overdraft + const updatedCount = await tx.$executeRaw` + UPDATE "users" + SET "wallet_balance" = "wallet_balance" - ${finalAmount}::decimal + WHERE "id" = ${userId}::uuid AND "wallet_balance" >= ${finalAmount}::decimal + `; + + if (updatedCount === 0) { + throw new BadRequestException( + `موجودی کیف پول شما برای پرداخت این سفارش (${finalAmount.toLocaleString('fa-IR')} تومان) کافی نیست.`, + ); + } await tx.walletTransaction.create({ data: { diff --git a/docs/SECURITY_PRACTICES.md b/docs/SECURITY_PRACTICES.md new file mode 100644 index 0000000..ce702c3 --- /dev/null +++ b/docs/SECURITY_PRACTICES.md @@ -0,0 +1,60 @@ +# Canina Iran — Security Guidelines & Architecture Manual + +این مستند حاوی راهنماها، چک‌لیست‌های امنیتی و چارچوب‌های فنی پیاده‌سازی‌شده در پروژه کنینا ایران (NestJS + Next.js) می‌باشد. + +--- + +## ۱. مدیریت نشست و کوکی / CSRF Protection (#SEC-021) + +### استراتژی احراز هویت توکن‌ها: +1. **Access Token:** + - زمان اعتبار: **۱۵ دقیقه (15m)**. + - امضا شده با کلید اختصاصی `JWT_ACCESS_SECRET`. + - پس از انقضا، فرانت‌اند به صورت خودکار از طریق Interceptor در `lib/services/api.ts` درخواست `/auth/refresh-token` را ارسال کرده و توکن جدید را دریافت و ریکوئست متوقف‌شده را مجدداً ارسال می‌کند. + +2. **Refresh Token:** + - زمان اعتبار: **۳۰ روز (30d)**. + - امضا شده با کلید جداگانه `JWT_REFRESH_SECRET`. + - ذخیره در Redis با کلید `refresh_token:{userId}:{tokenId}`. + - **Token Rotation:** به ازای هر بار فراخوانی `/auth/refresh-token`، توکن قبلی در ردیس باطل شده و یک جفت توکن کاملاً تازه صادر می‌شود. + +3. **حفاظت CSRF در معماری Cookie:** + - در صورت انتقال Refresh Token به Cookie: + - باید فلگ‌های `httpOnly: true`, `secure: true`, و `sameSite: 'strict'` یا `'lax'` فعال باشند. + - برای درخواست‌های جهش‌دهنده وضعیت (POST, PUT, DELETE, PATCH)، هدر اختصاصی `X-Requested-With: XMLHttpRequest` یا الگوی Double Submit Cookie (CSRF Token در هدر `X-CSRF-Token`) پیاده‌سازی می‌شود. + - در حال حاضر درخواست‌های API از هدر `Authorization: Bearer ` استفاده می‌کنند که مرورگرها آن را در درخواست‌های کراس‌سایت (Cross-Site) ناخواسته ارسال نمی‌کنند و در برابر CSRF سنتی ایمن است. + +--- + +## ۲. چک‌لیست بررسی امنیتی کد (Security Code Review Checklist - #SEC-022) + +هر توسعه‌دهنده پیش از باز کردن Pull Request یا ادغام کد، باید موارد زیر را بررسی کند: + +### الف) ورودی‌ها و اعتبارسنجی (Input Validation) +- [ ] تمام DTOها از `class-validator` استفاده کرده و دارای دکوراتورهای متناسب (`@IsString()`, `@IsNumber()`, `@IsUUID()`, `@IsEmail()`) هستند. +- [ ] هیچ ورودی کاربری مستقیماً به دستورات SQL یا shell متصل نمی‌شود (استفاده الزامی از Prisma پارامتریک). +- [ ] آپلود فایل‌ها دارای اعتبارسنجی جدی پسوند، حجم (`fileSize`) و هدر MIME است (پسوندهای مشکوک نظیر `.svg`، `.html`، `.exe`، `.sh` کاملاً مسدود شوند). + +### ب) خروجی‌ها و XSS Prevention +- [ ] هیچ محتوایی که توسط کاربر یا ادمین تایپ شده به طور مستقیم در `dangerouslySetInnerHTML` قرار نمی‌گیرد. +- [ ] در فرانت‌اند برای کامپوننت‌های رندر HTML از `DOMPurify.sanitize()` و در کامپوننت‌های SSR از `sanitize-html` استفاده می‌شود. +- [ ] داده‌های Schema.org و JSON-LD از تابع کمکی `safeJsonLd()` استفاده می‌کنند تا حملات Script Injection مهار شوند. + +### ج) کنترل دسترسی (Access Control) +- [ ] تمام روت‌های محافظت‌شده دارای `@UseGuards(JwtAuthGuard, RolesGuard)` هستند. +- [ ] متدهای دسترسی به منابع (مانند جزئیات سفارش‌ها) بررسی می‌کنند که شناسه کاربر متصل با شناسه مالک داده تطابق داشته باشد (`order.userId === req.user.id`). +- [ ] در کنترلرهای ادمین، دسترسی‌های کلیدهای API (`ApiKeysService`) منحصراً نیازمند اسکوپ صریح `admin` باشند و اسکوپ‌های عامیانه و وایلدکارد مثل `*` یا `all` نادیده گرفته شوند. + +### د) نرخ مصرف و سهمیه‌بندی (Rate Limiting) +- [ ] روت‌های حساس شامل ورود (`/auth/login`)، ثبت‌نام (`/auth/register`)، ارسال پیامک (`/auth/send-otp`) و پنل مدیریت دارای `@Throttle` اختصاصی با محدودیت‌های سخت‌گیرانه هستند. + +--- + +## ۳. برنامه تست نفوذ دوره‌ای (Penetration Testing Plan - #SEC-023) + +1. **فواصل زمانی ممیزی:** + - تست نفوذ خارجی باید حداقل **سالانه یک‌بار** یا قبل از هر عرضه ماژور (Major Release) توسط یک تیم یا شرکت مستقل امنیت سایبری انجام شود. +2. **حیطه آزمون (Scope):** + - آزمون جعبه سیاه (Black-box) و خاکستری (Grey-box) روی تمامی آدرس‌های دامنه عمومی، وب‌سرویس‌های RESTful و درگاه‌های پرداخت. + - ممیزی فرآیندهای مالی (Race condition در افزایش/کاهش موجودی کیف پول و تایید تراکنش‌های زیبال). + - آزمون نفوذ سرورهای زیرساخت و تنظیمات فایروال WAF و Nginx/Caddy. diff --git a/frontend/application/app/b2b/page.tsx b/frontend/application/app/b2b/page.tsx index ae6202d..1bc142e 100644 --- a/frontend/application/app/b2b/page.tsx +++ b/frontend/application/app/b2b/page.tsx @@ -2,7 +2,7 @@ import React from "react"; import type { Metadata } from "next"; import B2BLandingClient from "@/components/B2BLandingClient"; import { getPageMetadata } from "@/lib/seo"; -import { generateB2BServiceSchema, generateBreadcrumbSchema } from "@/lib/schema"; +import { generateB2BServiceSchema, generateBreadcrumbSchema, safeJsonLd } from "@/lib/schema"; export async function generateMetadata(): Promise { return getPageMetadata('b2b', { @@ -26,11 +26,11 @@ export default function B2BPage() { <> ' or '<' characters that could break out of the script block. + */ +export function safeJsonLd(schema: unknown): string { + return JSON.stringify(schema) + .replace(//g, '\\u003e') + .replace(/&/g, '\\u0026'); +} diff --git a/frontend/application/lib/services/api.ts b/frontend/application/lib/services/api.ts index 9ef03dc..be709a1 100644 --- a/frontend/application/lib/services/api.ts +++ b/frontend/application/lib/services/api.ts @@ -50,29 +50,97 @@ api.interceptors.request.use( (error) => Promise.reject(error) ); -// Interceptor to handle 401/403 and present standardized Farsi toasts +// Interceptor to handle 401/403, automatic refresh token rotation, and standardized toasts +let isRefreshing = false; +let failedQueue: Array<{ + resolve: (value: unknown) => void; + reject: (reason?: unknown) => void; +}> = []; + +const processQueue = (error: unknown, token: string | null = null) => { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(token); + } + }); + failedQueue = []; +}; + api.interceptors.response.use( (response) => response, - (error) => { + async (error) => { + const originalRequest = error.config; + + // Handle 401 Unauthorized with token refresh retry + if ( + typeof window !== 'undefined' && + error.response?.status === 401 && + !originalRequest?._retry && + !originalRequest?.url?.includes('/auth/') + ) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }) + .then((token) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + return api(originalRequest); + }) + .catch((err) => Promise.reject(err)); + } + + originalRequest._retry = true; + isRefreshing = true; + + const refreshToken = localStorage.getItem('refreshToken'); + if (refreshToken) { + try { + const res = await axios.post( + `${baseURL}/auth/refresh-token`, + { refreshToken }, + { headers: { 'Content-Type': 'application/json' } } + ); + + const { accessToken, refreshToken: newRefreshToken } = res.data?.data || {}; + if (accessToken) { + localStorage.setItem('accessToken', accessToken); + if (newRefreshToken) { + localStorage.setItem('refreshToken', newRefreshToken); + } + api.defaults.headers.common.Authorization = `Bearer ${accessToken}`; + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + processQueue(null, accessToken); + isRefreshing = false; + return api(originalRequest); + } + } catch (refreshErr) { + processQueue(refreshErr, null); + isRefreshing = false; + } + } else { + isRefreshing = false; + } + + // If refresh failed or no refresh token exists, clear state and logout + if (window.localStorage) { + try { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + } catch {} + } + try { + useUserStore.getState().logout(); + } catch {} + } + if (typeof window !== 'undefined') { const responseData = error.response?.data as ApiErrorPayload | undefined; const status = error.response?.status; const farsiMessage = responseData?.message || 'خطایی در ارتباط با سرور رخ داده است.'; - // Handle 401 Unauthorized globally - trigger Auth Modal - if (status === 401) { - if (window.localStorage) { - try { - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - } catch {} - } - try { - useUserStore.getState().logout(); - } catch { - // Ignore if store not ready - } - } else if (status === 403) { + if (status === 403) { if (window.localStorage) { try { localStorage.removeItem('accessToken'); diff --git a/frontend/application/lib/services/authService.ts b/frontend/application/lib/services/authService.ts index ea260b1..d535adc 100644 --- a/frontend/application/lib/services/authService.ts +++ b/frontend/application/lib/services/authService.ts @@ -23,6 +23,7 @@ export interface AuthResponse { data: { user: User; accessToken: string; + refreshToken?: string; }; } @@ -71,6 +72,9 @@ export class AuthService { if (data?.accessToken) { localStorage.setItem('accessToken', data.accessToken); } + if (data?.refreshToken) { + localStorage.setItem('refreshToken', data.refreshToken); + } return response.data; } catch (error) { const err = error as ApiErr; @@ -89,6 +93,9 @@ export class AuthService { if (resData?.accessToken) { localStorage.setItem('accessToken', resData.accessToken); } + if (resData?.refreshToken) { + localStorage.setItem('refreshToken', resData.refreshToken); + } return response.data; } catch (error) { const err = error as ApiErr; @@ -107,6 +114,9 @@ export class AuthService { if (data?.accessToken) { localStorage.setItem('accessToken', data.accessToken); } + if (data?.refreshToken) { + localStorage.setItem('refreshToken', data.refreshToken); + } return response.data; } catch (error) { const err = error as ApiErr; @@ -115,6 +125,29 @@ export class AuthService { } } + /** + * Refresh auth tokens using the stored refresh token + */ + public async refreshAuthToken(): Promise { + try { + const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refreshToken') : null; + if (!refreshToken) return null; + + const response = await api.post('/auth/refresh-token', { refreshToken }, { hideErrorToast: true } as unknown as import('axios').AxiosRequestConfig); + const { data } = response.data; + if (data?.accessToken) { + localStorage.setItem('accessToken', data.accessToken); + } + if (data?.refreshToken) { + localStorage.setItem('refreshToken', data.refreshToken); + } + return data?.accessToken || null; + } catch { + this.logout(); + return null; + } + } + /** * Get current user profile */ @@ -150,7 +183,16 @@ export class AuthService { * Logout user and clear tokens */ public logout(): void { - localStorage.removeItem('accessToken'); + if (typeof window !== 'undefined') { + try { + const token = localStorage.getItem('accessToken'); + if (token) { + api.post('/auth/logout').catch(() => {}); + } + } catch {} + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + } } } diff --git a/frontend/application/package-lock.json b/frontend/application/package-lock.json index 3cdb70d..9665587 100644 --- a/frontend/application/package-lock.json +++ b/frontend/application/package-lock.json @@ -8,13 +8,17 @@ "name": "application", "version": "0.1.0", "dependencies": { + "@types/dompurify": "^3.0.5", + "@types/sanitize-html": "^2.16.1", "axios": "^1.17.0", + "dompurify": "^3.4.16", "lucide-react": "^0.475.0", "motion": "^12.40.0", "next": "16.2.9", "nextjs-toploader": "^3.9.17", "react": "19.2.4", "react-dom": "19.2.4", + "sanitize-html": "^2.17.7", "sonner": "^2.0.7", "zustand": "^5.0.14" }, @@ -2315,6 +2319,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2366,6 +2379,21 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/sanitize-html": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz", + "integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==", + "license": "MIT", + "dependencies": { + "htmlparser2": "^10.1" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.61.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", @@ -3854,6 +3882,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3885,6 +3919,15 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -3972,6 +4015,82 @@ "license": "MIT", "peer": true }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/dompurify": { + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4018,7 +4137,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=20.19.0" @@ -4221,7 +4339,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5186,6 +5303,37 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -5531,6 +5679,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-object": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -5898,6 +6055,15 @@ "node": ">=0.10" } }, + "node_modules/launder": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", + "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.11.7" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6793,6 +6959,12 @@ "node": ">=6" } }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -6873,7 +7045,6 @@ "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", - "dev": true, "funding": [ { "type": "opencollective", @@ -7260,6 +7431,113 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sanitize-html": { + "version": "2.17.7", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz", + "integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^12.0.0", + "is-plain-object": "^5.0.0", + "launder": "^1.7.1", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/sanitize-html/node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/sanitize-html/node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/sanitize-html/node_modules/htmlparser2": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz", + "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "domutils": "^4.0.2", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", diff --git a/frontend/application/package.json b/frontend/application/package.json index ea69dc3..0bd7961 100644 --- a/frontend/application/package.json +++ b/frontend/application/package.json @@ -9,13 +9,17 @@ "lint": "eslint" }, "dependencies": { + "@types/dompurify": "^3.0.5", + "@types/sanitize-html": "^2.16.1", "axios": "^1.17.0", + "dompurify": "^3.4.16", "lucide-react": "^0.475.0", "motion": "^12.40.0", "next": "16.2.9", "nextjs-toploader": "^3.9.17", "react": "19.2.4", "react-dom": "19.2.4", + "sanitize-html": "^2.17.7", "sonner": "^2.0.7", "zustand": "^5.0.14" },