fix(security): remediate audit vulnerabilities SEC-001 through SEC-023
Some checks failed
Deploy Canina / deploy (push) Successful in 2m25s
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 7s

This commit is contained in:
پارسا آقایی 2026-09-23 23:00:25 +03:30
parent dc88677169
commit bd49fbc4a0
31 changed files with 867 additions and 114 deletions

51
.github/workflows/security-audit.yml vendored Normal file
View File

@ -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

16
.gitleaks.toml Normal file
View File

@ -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/'''
]

View File

@ -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,

View File

@ -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);

View File

@ -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}(?<!-)(\.[a-zA-Z0-9-]{1,63})*\.[a-zA-Z]{2,}$/;
const IP_REGEX = /^(?:\d{1,3}\.){3}\d{1,3}$|^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
if (
!cleanDomain ||
!DOMAIN_REGEX.test(cleanDomain) ||
IP_REGEX.test(cleanDomain) ||
cleanDomain === 'localhost' ||
cleanDomain.endsWith('.local') ||
cleanDomain.endsWith('.internal')
) {
return resolve({
onlineCheck: false,
error: 'فرمت نام دامنه نامعتبر است یا آدرس‌های IP و داخلی مجاز نیستند.',
});
}
const socket = tls.connect(
{
host: cleanDomain,

View File

@ -24,6 +24,7 @@ import {
} from '@nestjs/swagger';
import { AdminLoginDto } from './dto/admin-login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
@ApiTags('Auth - احراز هویت')
@Controller('auth')
@ -72,6 +73,7 @@ export class AuthController {
return this.authService.verifyOtp(verifyOtpDto);
}
@Throttle({ default: { limit: 5, ttl: 60000 } })
@Post('register')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'ثبت‌نام با ایمیل/موبایل و رمز عبور' })
@ -83,6 +85,7 @@ export class AuthController {
return this.authService.register(registerDto);
}
@Throttle({ default: { limit: 5, ttl: 60000 } })
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'ورود با موبایل و رمز عبور' })
@ -102,6 +105,16 @@ export class AuthController {
return this.authService.adminLogin(body);
}
@Throttle({ default: { limit: 10, ttl: 60000 } })
@Post('refresh-token')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'دریافت جفت توکن جدید با استفاده از توکن تازه‌سازی' })
@ApiOkResponse({ description: 'توکن‌های جدید با موفقیت صادر شدند' })
@ApiBadRequestResponse({ description: 'توکن نامعتبر یا منقضی است' })
refreshToken(@Body() body: RefreshTokenDto) {
return this.authService.refreshToken(body.refreshToken);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post('refresh')

View File

@ -19,7 +19,7 @@ import { RedisModule } from '../redis/redis.module';
JwtModule.registerAsync({
useFactory: () => ({
secret: getJwtSecret(),
signOptions: { expiresIn: '7d' },
signOptions: { expiresIn: '15m' },
}),
}),
],

View File

@ -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<string, any>) {
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<string, any>;
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);
}
}
}

View File

@ -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;
}

View File

@ -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 {

View File

@ -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 ارائه‌شده فاقد اسکوپ مدیریتی لازم برای این عملیات است',

View File

@ -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<boolean> {

View File

@ -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,

View File

@ -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);
}

View File

@ -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: {

View File

@ -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 <token>` استفاده می‌کنند که مرورگرها آن را در درخواست‌های کراس‌سایت (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.

View File

@ -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<Metadata> {
return getPageMetadata('b2b', {
@ -26,11 +26,11 @@ export default function B2BPage() {
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(b2bServiceSchema) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(b2bServiceSchema) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbs) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbs) }}
/>
<B2BLandingClient />
</>

View File

@ -3,6 +3,7 @@ import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import BlogPostClient from "../../../components/BlogPostClient";
import { getSeoConfig, formatPageTitle } from "../../../lib/seo";
import { safeJsonLd } from "../../../lib/schema";
function safeIso(val: any, fallback?: string): string | undefined {
if (!val) return fallback;
@ -246,11 +247,11 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(articleJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbJsonLd) }}
/>
<BlogPostClient
blog={blog}

View File

@ -10,7 +10,7 @@ export async function generateMetadata(): Promise<Metadata> {
});
}
import { generateLocalBusinessSchema, generateBreadcrumbSchema } from "@/lib/schema";
import { generateLocalBusinessSchema, generateBreadcrumbSchema, safeJsonLd } from "@/lib/schema";
export default function ContactPage() {
const localBusinessSchema = generateLocalBusinessSchema({
@ -35,11 +35,11 @@ export default function ContactPage() {
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(localBusinessSchema) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(localBusinessSchema) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbs) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbs) }}
/>
<div className="bg-medical-gray-50 py-16 px-4 sm:px-6 lg:px-8 font-vazir" dir="rtl">
<div className="max-w-6xl mx-auto space-y-16">

View File

@ -1,7 +1,7 @@
import type { Metadata } from 'next';
import VideosPage from "../../components/VideosPage";
import { getPageMetadata } from "../../lib/seo";
import { generateVideoObjectSchema, generateBreadcrumbSchema } from "../../lib/schema";
import { generateVideoObjectSchema, generateBreadcrumbSchema, safeJsonLd } from "../../lib/schema";
export async function generateMetadata(): Promise<Metadata> {
return getPageMetadata('videos', {
@ -61,12 +61,12 @@ export default async function Videos() {
{videoSchemas.length > 0 && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListSchema) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(itemListSchema) }}
/>
)}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbs) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbs) }}
/>
<VideosPage />
</>

View File

@ -1,8 +1,9 @@
import Link from 'next/link';
import { ChevronRight, Sparkles, Package, ArrowLeft, ShieldCheck } from 'lucide-react';
import type { Metadata } from 'next';
import sanitizeHtml from 'sanitize-html';
import { getSeoConfig, formatPageTitle } from "../../../lib/seo";
import { generateMedicalWebPageSchema, generateBreadcrumbSchema } from "../../../lib/schema";
import { generateMedicalWebPageSchema, generateBreadcrumbSchema, safeJsonLd } from "../../../lib/schema";
import SafeImage from "../../../components/SafeImage";
const API_URL = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4001/api';
@ -110,11 +111,11 @@ export default async function WikiTermPage({ params }: { params: Promise<{ slug:
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(medicalSchema) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(medicalSchema) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbs) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(breadcrumbs) }}
/>
<div className="bg-medical-gray-50 min-h-screen py-12 px-4 sm:px-6 lg:px-8 font-vazir" dir="rtl">
@ -143,7 +144,18 @@ export default async function WikiTermPage({ params }: { params: Promise<{ slug:
<div
className="prose prose-lg max-w-none text-medical-gray-700 leading-relaxed prose-headings:font-black prose-headings:text-medical-gray-900 prose-p:leading-loose"
dangerouslySetInnerHTML={{ __html: term.definition }}
dangerouslySetInnerHTML={{
__html: sanitizeHtml(term.definition || '', {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'h1', 'h2', 'span']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
span: ['class', 'style'],
div: ['class'],
p: ['class'],
},
}),
}}
/>
</article>

View File

@ -1,5 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import DOMPurify from 'dompurify';
import {
ChevronRight,
Calendar,
@ -175,7 +176,14 @@ export default function BlogPostClient({ blog, publishedDate, authorName }: Blog
{/* Main Rich Content HTML Renderer */}
<div
className="prose prose-base sm:prose-lg max-w-none text-medical-gray-700 leading-loose space-y-5 article-html-content"
dangerouslySetInnerHTML={{ __html: blog.content || '' }}
dangerouslySetInnerHTML={{
__html: typeof window !== 'undefined'
? DOMPurify.sanitize(blog.content || '', {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling'],
})
: blog.content || '',
}}
/>
{/* Tags Chip Footer */}

View File

@ -2,6 +2,7 @@
'use client';
import React from 'react';
import DOMPurify from 'dompurify';
import { useSettingsStore } from '../lib/store/settingsStore';
const DEFAULT_ENAMAD_HTML = `<a referrerpolicy='origin' target='_blank' href='https://trustseal.enamad.ir/?id=765218&Code=dOdphRTqZwp3Kep5m07XwaqA0DTFh5ny'><img referrerpolicy='origin' src='https://trustseal.enamad.ir/logo.aspx?id=765218&Code=dOdphRTqZwp3Kep5m07XwaqA0DTFh5ny' alt='' style='cursor:pointer' code='dOdphRTqZwp3Kep5m07XwaqA0DTFh5ny'></a>`;
@ -10,6 +11,14 @@ export default function EnamadBadge() {
const getText = useSettingsStore((state) => state.getText);
const enamadHtml = getText('ENAMAD_HTML_CODE', DEFAULT_ENAMAD_HTML);
const cleanEnamadHtml =
typeof window !== 'undefined'
? DOMPurify.sanitize(enamadHtml, {
ALLOWED_TAGS: ['a', 'img'],
ALLOWED_ATTR: ['referrerpolicy', 'target', 'href', 'src', 'alt', 'style', 'code', 'width', 'height'],
})
: enamadHtml;
return (
<div className="relative flex flex-col items-center justify-center p-6 bg-white/90 backdrop-blur-md rounded-2xl border border-gray-100 shadow-sm hover:shadow-md transition-all duration-300">
<div className="text-xs font-semibold text-teal-600 bg-teal-50 px-3 py-1 rounded-full mb-3">
@ -18,7 +27,7 @@ export default function EnamadBadge() {
<div
className="relative min-w-[120px] min-h-[120px] flex items-center justify-center bg-white rounded-xl overflow-hidden border border-gray-100 p-2"
dangerouslySetInnerHTML={{ __html: enamadHtml }}
dangerouslySetInnerHTML={{ __html: cleanEnamadHtml }}
/>
<p className="text-xs text-gray-500 mt-4 text-center leading-relaxed font-medium">

View File

@ -3,6 +3,7 @@ import React, { useEffect, useState } from "react";
import { HelpCircle, ChevronDown } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { useSettingsStore } from "../lib/store/settingsStore";
import { safeJsonLd } from "../lib/schema";
import api from "../lib/services/api";
export interface FAQItem {
@ -59,7 +60,7 @@ export default function FAQSection() {
{faqs.length > 0 && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
dangerouslySetInnerHTML={{ __html: safeJsonLd(faqSchema) }}
/>
)}

View File

@ -20,6 +20,7 @@ import {
Loader2
} from "lucide-react";
import { getMediaUrl } from "../lib/media";
import DOMPurify from "dompurify";
interface VideoModalPlayerProps {
isOpen: boolean;
@ -306,7 +307,14 @@ export default function VideoModalPlayer({ isOpen, onClose, video }: VideoModalP
</button>
<div
className="w-full h-full [&>iframe]:w-full [&>iframe]:h-full"
dangerouslySetInnerHTML={{ __html: video.videoUrl || '' }}
dangerouslySetInnerHTML={{
__html: typeof window !== 'undefined'
? DOMPurify.sanitize(video.videoUrl || '', {
ALLOWED_TAGS: ['iframe'],
ALLOWED_ATTR: ['src', 'width', 'height', 'frameborder', 'allow', 'allowfullscreen', 'title'],
})
: video.videoUrl || '',
}}
/>
</div>
) : (

View File

@ -1,6 +1,7 @@
"use client";
import React from "react";
import DOMPurify from "dompurify";
import SafeImage from "../SafeImage";
import { Product } from "../../lib/data/products";
import { X, ShieldCheck, CheckCircle2, Award, Stethoscope, Sparkles, Layers, Info } from "lucide-react";
@ -77,7 +78,11 @@ export default function ProductDetailModal({ product, categoryColor = "#0284C7",
</span>
{/<[a-z][\s\S]*>/i.test(product.description || '') ? (
<div
dangerouslySetInnerHTML={{ __html: product.description || '' }}
dangerouslySetInnerHTML={{
__html: typeof window !== 'undefined'
? DOMPurify.sanitize(product.description || '')
: product.description || '',
}}
className="text-sm text-slate-600 leading-relaxed font-medium space-y-2 [&>h1]:font-black [&>h2]:font-black [&>h3]:font-black [&>p]:leading-relaxed [&>ul]:list-disc [&>ul]:pr-4 [&>ol]:list-decimal [&>ol]:pr-4 text-justify"
/>
) : (

View File

@ -231,3 +231,14 @@ export function generateBreadcrumbSchema(items: BreadcrumbItem[]) {
})),
};
}
/**
* SEC-017: Safely serialize schema data to JSON string for application/ld+json script tags,
* escaping any '</script>' or '<' characters that could break out of the script block.
*/
export function safeJsonLd(schema: unknown): string {
return JSON.stringify(schema)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
}

View File

@ -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');

View File

@ -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<string | null> {
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');
}
}
}

View File

@ -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",

View File

@ -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"
},