diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx
index 032c77f..2ac3e2b 100644
--- a/src/components/ConnectionModal.tsx
+++ b/src/components/ConnectionModal.tsx
@@ -61,6 +61,48 @@ const platformIconComponents: Record = {
// Platform order for display
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']
+// Allowed app schemes for deep links
+const allowedAppSchemes = [
+ 'happ://', 'flclash://', 'clash://', 'sing-box://', 'v2rayng://',
+ 'sub://', 'shadowrocket://', 'hiddify://', 'streisand://',
+ 'quantumult://', 'surge://', 'loon://', 'nekobox://', 'v2box://'
+]
+
+/**
+ * Validate URL to prevent XSS via javascript: and other dangerous schemes
+ * Only allows http, https, and known app store URLs
+ */
+function isValidExternalUrl(url: string | undefined): boolean {
+ if (!url) return false
+ const lowerUrl = url.toLowerCase().trim()
+
+ // Block dangerous schemes
+ const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
+ if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) {
+ return false
+ }
+
+ // Allow only http/https URLs
+ return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://')
+}
+
+/**
+ * Validate deep link URL - only allows known VPN app schemes
+ */
+function isValidDeepLink(url: string | undefined): boolean {
+ if (!url) return false
+ const lowerUrl = url.toLowerCase().trim()
+
+ // Block dangerous schemes
+ const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
+ if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) {
+ return false
+ }
+
+ // Allow known app schemes
+ return allowedAppSchemes.some(scheme => lowerUrl.startsWith(scheme))
+}
+
// Detect user's platform from user agent
function detectPlatform(): string | null {
if (typeof window === 'undefined' || !navigator?.userAgent) {
@@ -170,7 +212,11 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
// Handle deep link click - use miniapp redirect page like in miniapp index.html
const handleConnect = (app: AppInfo) => {
- if (!app.deepLink) return
+ // Validate deep link to prevent XSS
+ if (!app.deepLink || !isValidDeepLink(app.deepLink)) {
+ console.warn('Invalid or missing deep link:', app.deepLink)
+ return
+ }
const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en'
const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(app.deepLink)}&lang=${lang}`
@@ -442,7 +488,9 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
{selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && (
- {selectedApp.installationStep.buttons.map((btn, idx) => (
+ {selectedApp.installationStep.buttons
+ .filter((btn) => isValidExternalUrl(btn.buttonLink))
+ .map((btn, idx) => (
(
const formatTimeLeft = (expiresAt: string, t: (key: string) => string): string => {
const now = new Date()
- const expires = new Date(expiresAt)
+ // Ensure UTC parsing - if no timezone specified, assume UTC
+ let expires: Date
+ if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
+ expires = new Date(expiresAt)
+ } else {
+ // No timezone - treat as UTC
+ expires = new Date(expiresAt + 'Z')
+ }
const diffMs = expires.getTime() - now.getTime()
if (diffMs <= 0) return ''
diff --git a/src/components/PromoOffersSection.tsx b/src/components/PromoOffersSection.tsx
index d125985..d335bac 100644
--- a/src/components/PromoOffersSection.tsx
+++ b/src/components/PromoOffersSection.tsx
@@ -36,7 +36,14 @@ const ServerIcon = () => (
// Helper functions
const formatTimeLeft = (expiresAt: string): string => {
const now = new Date()
- const expires = new Date(expiresAt)
+ // Ensure UTC parsing - if no timezone specified, assume UTC
+ let expires: Date
+ if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
+ expires = new Date(expiresAt)
+ } else {
+ // No timezone - treat as UTC
+ expires = new Date(expiresAt + 'Z')
+ }
const diffMs = expires.getTime() - now.getTime()
if (diffMs <= 0) return 'Истекло'
diff --git a/src/components/TelegramLoginButton.tsx b/src/components/TelegramLoginButton.tsx
index c26ec5d..1aae655 100644
--- a/src/components/TelegramLoginButton.tsx
+++ b/src/components/TelegramLoginButton.tsx
@@ -15,8 +15,10 @@ export default function TelegramLoginButton({
useEffect(() => {
if (!containerRef.current || !botUsername) return
- // Clear previous widget
- containerRef.current.innerHTML = ''
+ // Clear previous widget using safe DOM API
+ while (containerRef.current.firstChild) {
+ containerRef.current.removeChild(containerRef.current.firstChild)
+ }
// Get current URL for redirect
const redirectUrl = `${window.location.origin}/auth/telegram/callback`
diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx
index 3b12f7a..1b8ce8f 100644
--- a/src/components/TopUpModal.tsx
+++ b/src/components/TopUpModal.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { useMutation } from '@tanstack/react-query'
import { balanceApi } from '../api/balance'
import { useCurrency } from '../hooks/useCurrency'
+import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'
import type { PaymentMethod } from '../types'
const TELEGRAM_LINK_REGEX = /^https?:\/\/t\.me\//i
@@ -139,6 +140,14 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); setError(null)
+
+ // Rate limit check: max 3 payment attempts per 30 seconds
+ if (!checkRateLimit(RATE_LIMIT_KEYS.PAYMENT, 3, 30000)) {
+ const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT)
+ setError(t('balance.tooManyRequests', { seconds: resetTime }) || `Слишком много запросов. Подождите ${resetTime} сек.`)
+ return
+ }
+
if (hasOptions && !selectedOption) { setError(t('balance.selectPaymentOption', 'Выберите способ оплаты')); return }
const amountCurrency = parseFloat(amount)
if (isNaN(amountCurrency) || amountCurrency <= 0) { setError(t('balance.invalidAmount', 'Invalid amount')); return }
diff --git a/src/locales/en.json b/src/locales/en.json
index 604af76..4e85c02 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -126,6 +126,9 @@
"selectServers": "Select Servers",
"selectDevices": "Devices",
"perDevice": "/ device",
+ "devicesFree": "devices free",
+ "perExtraDevice": "/ extra device",
+ "perMonth": "/mo",
"summary": "Summary",
"total": "Total",
"purchase": "Purchase",
@@ -728,7 +731,8 @@
"checkIntervalDesc": "How often to check for overdue tickets (30-600 seconds)",
"reminderCooldown": "Reminder interval (minutes)",
"reminderCooldownDesc": "Minimum time between reminders (1-120 minutes)",
- "settingsUpdateError": "Error saving settings"
+ "settingsUpdateError": "Error saving settings",
+ "copyTelegramId": "Click to copy Telegram ID"
},
"tariffs": {
"title": "Tariff Management",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index d9cfed8..e46a052 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -126,6 +126,9 @@
"selectServers": "Выберите серверы",
"selectDevices": "Устройства",
"perDevice": "/ устройство",
+ "devicesFree": "устройств бесплатно",
+ "perExtraDevice": "/ доп. устройство",
+ "perMonth": "/мес",
"summary": "Итого",
"total": "К оплате",
"purchase": "Купить",
@@ -728,7 +731,8 @@
"checkIntervalDesc": "Как часто проверять просроченные тикеты (30-600 секунд)",
"reminderCooldown": "Интервал напоминаний (минуты)",
"reminderCooldownDesc": "Минимальное время между напоминаниями (1-120 минут)",
- "settingsUpdateError": "Ошибка сохранения настроек"
+ "settingsUpdateError": "Ошибка сохранения настроек",
+ "copyTelegramId": "Нажмите чтобы скопировать Telegram ID"
},
"tariffs": {
"title": "Управление тарифами",
diff --git a/src/pages/AdminTickets.tsx b/src/pages/AdminTickets.tsx
index 1ca8c7c..83f2d9c 100644
--- a/src/pages/AdminTickets.tsx
+++ b/src/pages/AdminTickets.tsx
@@ -155,10 +155,21 @@ export default function AdminTickets() {
const formatUser = (ticket: AdminTicket | AdminTicketDetail) => {
if (!ticket.user) return 'Unknown'
- const { first_name, last_name, username, telegram_id } = ticket.user
+ const { first_name, last_name, username } = ticket.user
if (first_name || last_name) return `${first_name || ''} ${last_name || ''}`.trim()
if (username) return `@${username}`
- return `ID: ${telegram_id}`
+ return 'User'
+ }
+
+ const copyToClipboard = (text: string) => {
+ navigator.clipboard.writeText(text).catch(() => {
+ const textarea = document.createElement('textarea')
+ textarea.value = text
+ document.body.appendChild(textarea)
+ textarea.select()
+ document.execCommand('copy')
+ document.body.removeChild(textarea)
+ })
}
return (
@@ -248,7 +259,17 @@ export default function AdminTickets() {
- {formatUser(ticket)} | {new Date(ticket.updated_at).toLocaleDateString()}
+ {formatUser(ticket)}
+ {ticket.user?.telegram_id && (
+
+ )}
+ {' '}| {new Date(ticket.updated_at).toLocaleDateString()}
{ticket.last_message && (
@@ -315,8 +336,17 @@ export default function AdminTickets() {
- {t('admin.tickets.from')}: {formatUser(selectedTicket)} |{' '}
- {t('admin.tickets.created')}: {new Date(selectedTicket.created_at).toLocaleString()}
+ {t('admin.tickets.from')}: {formatUser(selectedTicket)}
+ {selectedTicket.user?.telegram_id && (
+
+ )}
+ {' '}| {t('admin.tickets.created')}: {new Date(selectedTicket.created_at).toLocaleString()}
{['open', 'pending', 'answered', 'closed'].map((s) => (
diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx
index 2d82d9d..5762afb 100644
--- a/src/pages/Subscription.tsx
+++ b/src/pages/Subscription.tsx
@@ -1095,6 +1095,13 @@ export default function Subscription() {
{/* Tariff List - current tariff first */}
{[...tariffs]
+ // Hide Trial tariffs for users who already have trial subscription
+ .filter((tariff) => {
+ if (subscription?.is_trial && tariff.name.toLowerCase().includes('trial')) {
+ return false
+ }
+ return true
+ })
.sort((a, b) => {
const aIsCurrent = a.is_current || a.id === subscription?.tariff_id
const bIsCurrent = b.is_current || b.id === subscription?.tariff_id
@@ -1787,7 +1794,15 @@ export default function Subscription() {
{/* Step: Server Selection */}
{currentStep === 'servers' && selectedPeriod?.servers.options && (
- {selectedPeriod.servers.options.map((server) => {
+ {selectedPeriod.servers.options
+ // Hide Trial server for users who already have trial subscription
+ .filter((server) => {
+ if (subscription?.is_trial && server.name.toLowerCase().includes('trial')) {
+ return false
+ }
+ return true
+ })
+ .map((server) => {
const hasExistingDiscount = !!(server.discount_percent && server.discount_percent > 0)
const promoServer = applyPromoDiscount(server.price_kopeks, hasExistingDiscount)
@@ -1822,7 +1837,7 @@ export default function Subscription() {
{server.name}
- {formatPrice(promoServer.price)}
+ {formatPrice(promoServer.price)}{t('subscription.perMonth')}
{promoServer.original && (
{formatPrice(promoServer.original)}
)}
@@ -1860,8 +1875,15 @@ export default function Subscription() {
+
-
- {formatPrice(selectedPeriod.devices.price_per_device_kopeks)} {t('subscription.perDevice')}
+
+
+ {selectedPeriod.devices.min} {t('subscription.devicesFree')}
+
+ {selectedPeriod.devices.max > selectedPeriod.devices.min && (
+
+ {formatPrice(selectedPeriod.devices.price_per_device_kopeks)} {t('subscription.perExtraDevice')}
+
+ )}
)}
diff --git a/src/pages/Support.tsx b/src/pages/Support.tsx
index 80226cd..e161c40 100644
--- a/src/pages/Support.tsx
+++ b/src/pages/Support.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { ticketsApi } from '../api/tickets'
import { infoApi } from '../api/info'
import { logger } from '../utils/logger'
+import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'
import type { TicketDetail, TicketMessage } from '../types'
const log = logger.createLogger('Support')
@@ -132,6 +133,7 @@ export default function Support() {
const [newTitle, setNewTitle] = useState('')
const [newMessage, setNewMessage] = useState('')
const [replyMessage, setReplyMessage] = useState('')
+ const [rateLimitError, setRateLimitError] = useState
(null)
// Media attachment states
const [createAttachment, setCreateAttachment] = useState(null)
@@ -507,6 +509,13 @@ export default function Support() {
+ {rateLimitError && (
+
+ {rateLimitError}
+
+ )}
+
+ {rateLimitError && (
+
+ {rateLimitError}
+
+ )}
)}
diff --git a/src/store/auth.ts b/src/store/auth.ts
index d970d62..9799c21 100644
--- a/src/store/auth.ts
+++ b/src/store/auth.ts
@@ -71,7 +71,8 @@ export const useAuthStore = create
()(
},
logout: () => {
- const { refreshToken } = get()
+ // Get refresh token from secure storage, not zustand state
+ const refreshToken = tokenStorage.getRefreshToken()
if (refreshToken) {
authApi.logout(refreshToken).catch(() => {
// Logout API call failed - ignore silently
@@ -259,8 +260,9 @@ export const useAuthStore = create()(
}),
{
name: 'cabinet-auth',
+ // Only persist user info for UI caching
+ // Tokens are stored securely in sessionStorage via tokenStorage
partialize: (state) => ({
- refreshToken: state.refreshToken,
user: state.user,
}),
}
diff --git a/src/utils/rateLimit.ts b/src/utils/rateLimit.ts
new file mode 100644
index 0000000..199e09e
--- /dev/null
+++ b/src/utils/rateLimit.ts
@@ -0,0 +1,117 @@
+/**
+ * Client-side rate limiting utilities to prevent spam requests
+ */
+
+interface RateLimitEntry {
+ count: number
+ resetTime: number
+}
+
+const rateLimitStore = new Map()
+
+/**
+ * Check if action is rate limited
+ * @param key Unique key for the action (e.g., 'payment', 'ticket-create')
+ * @param maxRequests Maximum requests allowed in the time window
+ * @param windowMs Time window in milliseconds
+ * @returns true if action is allowed, false if rate limited
+ */
+export function checkRateLimit(
+ key: string,
+ maxRequests: number = 3,
+ windowMs: number = 10000
+): boolean {
+ const now = Date.now()
+ const entry = rateLimitStore.get(key)
+
+ // Clean up expired entry
+ if (entry && now >= entry.resetTime) {
+ rateLimitStore.delete(key)
+ }
+
+ const currentEntry = rateLimitStore.get(key)
+
+ if (!currentEntry) {
+ // First request - allow and start tracking
+ rateLimitStore.set(key, {
+ count: 1,
+ resetTime: now + windowMs,
+ })
+ return true
+ }
+
+ if (currentEntry.count >= maxRequests) {
+ // Rate limit exceeded
+ return false
+ }
+
+ // Increment counter and allow
+ currentEntry.count++
+ return true
+}
+
+/**
+ * Get remaining time until rate limit resets (in seconds)
+ */
+export function getRateLimitResetTime(key: string): number {
+ const entry = rateLimitStore.get(key)
+ if (!entry) return 0
+ const remaining = entry.resetTime - Date.now()
+ return remaining > 0 ? Math.ceil(remaining / 1000) : 0
+}
+
+/**
+ * Reset rate limit for a key (e.g., after successful action)
+ */
+export function resetRateLimit(key: string): void {
+ rateLimitStore.delete(key)
+}
+
+/**
+ * Debounce function - delays execution until after wait ms have elapsed
+ */
+export function debounce unknown>(
+ func: T,
+ wait: number
+): (...args: Parameters) => void {
+ let timeoutId: ReturnType | null = null
+
+ return (...args: Parameters) => {
+ if (timeoutId) {
+ clearTimeout(timeoutId)
+ }
+ timeoutId = setTimeout(() => {
+ func(...args)
+ timeoutId = null
+ }, wait)
+ }
+}
+
+/**
+ * Throttle function - ensures function is called at most once per limit ms
+ */
+export function throttle unknown>(
+ func: T,
+ limit: number
+): (...args: Parameters) => void {
+ let inThrottle = false
+
+ return (...args: Parameters) => {
+ if (!inThrottle) {
+ func(...args)
+ inThrottle = true
+ setTimeout(() => {
+ inThrottle = false
+ }, limit)
+ }
+ }
+}
+
+// Rate limit keys for different actions
+export const RATE_LIMIT_KEYS = {
+ PAYMENT: 'payment',
+ TICKET_CREATE: 'ticket-create',
+ TICKET_REPLY: 'ticket-reply',
+ PROMO_ACTIVATE: 'promo-activate',
+ WHEEL_SPIN: 'wheel-spin',
+} as const