From 96d451173fb6994f9487dd7b6f03d8a569cb083d Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 18 Jan 2026 06:44:43 +0300 Subject: [PATCH 01/11] Update ConnectionModal.tsx --- src/components/ConnectionModal.tsx | 52 ++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) 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) => ( Date: Sun, 18 Jan 2026 06:47:59 +0300 Subject: [PATCH 02/11] Update auth.ts --- src/store/auth.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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, }), } From 84e351ceb4bdf65ec548096568328126cbdb3a0b Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 18 Jan 2026 06:49:36 +0300 Subject: [PATCH 03/11] Update TelegramLoginButton.tsx --- src/components/TelegramLoginButton.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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` From 039e75586dd755c67e76bd6eccddd34e300d35f3 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 18 Jan 2026 06:53:17 +0300 Subject: [PATCH 04/11] Add files via upload --- src/utils/rateLimit.ts | 117 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/utils/rateLimit.ts 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 From 5eeb8aafa373993eaa5b70876618f9707df57291 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 18 Jan 2026 06:53:49 +0300 Subject: [PATCH 05/11] Add files via upload --- src/components/TopUpModal.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) 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 } From 705f0676e64bc15be5c2c1c564486c08f5fd2d46 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 18 Jan 2026 06:54:25 +0300 Subject: [PATCH 06/11] Update Support.tsx --- src/pages/Support.tsx | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) 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() {
{ e.preventDefault() + setRateLimitError(null) + // Rate limit: max 3 tickets per 60 seconds + if (!checkRateLimit(RATE_LIMIT_KEYS.TICKET_CREATE, 3, 60000)) { + const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_CREATE) + setRateLimitError(t('support.tooManyRequests', { seconds: resetTime }) || `Слишком много запросов. Подождите ${resetTime} сек.`) + return + } createMutation.mutate() }} className="space-y-4" @@ -567,6 +576,12 @@ export default function Support() { )}
+ {rateLimitError && ( +
+ {rateLimitError} +
+ )} +
+ {rateLimitError && ( +
+ {rateLimitError} +
+ )} )} From de4a694efd0d4fd1d68cd997e62a1e4fbc729a06 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 18 Jan 2026 07:01:51 +0300 Subject: [PATCH 07/11] Update Subscription.tsx --- src/pages/Subscription.tsx | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) 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')} +
+ )}
)} From a6d990d15c35407ce41e3c136f3d673a5f66ab4f Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 18 Jan 2026 07:02:20 +0300 Subject: [PATCH 08/11] Add files via upload --- src/locales/en.json | 3 +++ src/locales/ru.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/locales/en.json b/src/locales/en.json index 71808f8..c66fe28 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -124,6 +124,9 @@ "selectServers": "Select Servers", "selectDevices": "Devices", "perDevice": "/ device", + "devicesFree": "devices free", + "perExtraDevice": "/ extra device", + "perMonth": "/mo", "summary": "Summary", "total": "Total", "purchase": "Purchase", diff --git a/src/locales/ru.json b/src/locales/ru.json index 339bffb..73b6ffe 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -124,6 +124,9 @@ "selectServers": "Выберите серверы", "selectDevices": "Устройства", "perDevice": "/ устройство", + "devicesFree": "устройств бесплатно", + "perExtraDevice": "/ доп. устройство", + "perMonth": "/мес", "summary": "Итого", "total": "К оплате", "purchase": "Купить", From 6c8fa873d24d6161afa95bcf8c40e993ecaef8cc Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 18 Jan 2026 07:07:33 +0300 Subject: [PATCH 09/11] Add files via upload --- src/locales/en.json | 3 ++- src/locales/ru.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index c66fe28..1b3ec48 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -727,7 +727,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 73b6ffe..94422cc 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -727,7 +727,8 @@ "checkIntervalDesc": "Как часто проверять просроченные тикеты (30-600 секунд)", "reminderCooldown": "Интервал напоминаний (минуты)", "reminderCooldownDesc": "Минимальное время между напоминаниями (1-120 минут)", - "settingsUpdateError": "Ошибка сохранения настроек" + "settingsUpdateError": "Ошибка сохранения настроек", + "copyTelegramId": "Нажмите чтобы скопировать Telegram ID" }, "tariffs": { "title": "Управление тарифами", From 808c59c8f07a947974b831e4145c4c0c48289094 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 18 Jan 2026 07:07:59 +0300 Subject: [PATCH 10/11] Add files via upload --- src/pages/AdminTickets.tsx | 40 +++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) 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) => ( From f343e60598d0e9b9408d511d4d6df5bf9b806b51 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 18 Jan 2026 07:16:10 +0300 Subject: [PATCH 11/11] Add files via upload --- src/components/PromoDiscountBadge.tsx | 9 ++++++++- src/components/PromoOffersSection.tsx | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/components/PromoDiscountBadge.tsx b/src/components/PromoDiscountBadge.tsx index 8f4f845..a210fe0 100644 --- a/src/components/PromoDiscountBadge.tsx +++ b/src/components/PromoDiscountBadge.tsx @@ -18,7 +18,14 @@ const ClockIcon = () => ( 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 'Истекло'