diff --git a/src/api/client.ts b/src/api/client.ts index 782a924..808184c 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -6,6 +6,32 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || '/api' // Настраиваем endpoint для refresh tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/refresh`) +// CSRF token management +const CSRF_COOKIE_NAME = 'csrf_token' +const CSRF_HEADER_NAME = 'X-CSRF-Token' + +function getCsrfToken(): string | null { + if (typeof document === 'undefined') return null + const match = document.cookie.match(new RegExp(`(^| )${CSRF_COOKIE_NAME}=([^;]+)`)) + return match ? match[2] : null +} + +function generateCsrfToken(): string { + const array = new Uint8Array(32) + crypto.getRandomValues(array) + return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('') +} + +function ensureCsrfToken(): string { + let token = getCsrfToken() + if (!token) { + token = generateCsrfToken() + // Set cookie with SameSite=Strict for CSRF protection + document.cookie = `${CSRF_COOKIE_NAME}=${token}; path=/; SameSite=Strict; Secure` + } + return token +} + const getTelegramInitData = (): string | null => { if (typeof window === 'undefined') return null @@ -51,6 +77,13 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => if (telegramInitData && config.headers) { config.headers['X-Telegram-Init-Data'] = telegramInitData } + + // Add CSRF token for state-changing methods + const method = config.method?.toUpperCase() + if (method && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && config.headers) { + config.headers[CSRF_HEADER_NAME] = ensureCsrfToken() + } + return config }) diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 2629da2..3b12f7a 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -75,28 +75,20 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) { const starsPaymentMutation = useMutation({ mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks), onSuccess: (data) => { - console.log('[Stars] API response:', data) - console.log('[Stars] invoice_url:', data.invoice_url) const webApp = window.Telegram?.WebApp - console.log('[Stars] webApp:', webApp) - console.log('[Stars] openInvoice available:', !!webApp?.openInvoice) if (!data.invoice_url) { - console.error('[Stars] No invoice_url in response!') setError('Сервер не вернул ссылку на оплату') return } if (!webApp?.openInvoice) { - console.error('[Stars] openInvoice not available - not in Telegram Mini App?') setError('Оплата Stars доступна только в Telegram Mini App') return } - console.log('[Stars] Calling openInvoice with:', data.invoice_url) try { webApp.openInvoice(data.invoice_url, (status) => { - console.log('[Stars] Invoice callback status:', status) if (status === 'paid') { setError(null) onClose() @@ -107,12 +99,10 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) { } }) } catch (e) { - console.error('[Stars] openInvoice error:', e) setError('Ошибка открытия окна оплаты: ' + String(e)) } }, onError: (error: unknown) => { - console.error('[Stars] API error:', error) const axiosError = error as { response?: { data?: { detail?: string }, status?: number } } const detail = axiosError?.response?.data?.detail const status = axiosError?.response?.status diff --git a/src/pages/DeepLinkRedirect.tsx b/src/pages/DeepLinkRedirect.tsx index dcce3b9..1fea4b3 100644 --- a/src/pages/DeepLinkRedirect.tsx +++ b/src/pages/DeepLinkRedirect.tsx @@ -26,6 +26,19 @@ const appSchemes = [ const COUNTDOWN_SECONDS = 5 +// Validate deep link to prevent javascript: and other dangerous schemes +const isValidDeepLink = (url: string): 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 + } + // Only allow known app schemes + return appSchemes.some(app => lowerUrl.startsWith(app.scheme)) +} + export default function DeepLinkRedirect() { const { i18n } = useTranslation() const navigate = useNavigate() @@ -104,13 +117,13 @@ export default function DeepLinkRedirect() { // Open deep link - same as miniapp, just window.location.href const openDeepLink = useCallback(() => { - if (!deepLink) return + if (!deepLink || !isValidDeepLink(deepLink)) return window.location.href = deepLink }, [deepLink]) // Countdown timer effect useEffect(() => { - if (!deepLink) { + if (!deepLink || !isValidDeepLink(deepLink)) { setStatus('error') return } diff --git a/src/pages/Info.tsx b/src/pages/Info.tsx index da7b601..5e246ea 100644 --- a/src/pages/Info.tsx +++ b/src/pages/Info.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' +import DOMPurify from 'dompurify' import { infoApi, FaqPage } from '../api/info' const InfoIcon = () => ( @@ -41,6 +42,15 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( type TabType = 'faq' | 'rules' | 'privacy' | 'offer' +// Sanitize HTML content to prevent XSS +const sanitizeHtml = (html: string): string => { + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'u', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre', 'span', 'div'], + ALLOWED_ATTR: ['href', 'target', 'rel', 'class'], + ALLOW_DATA_ATTR: false, + }) +} + // Convert plain text to HTML with proper formatting const formatContent = (content: string): string => { if (!content) return '' @@ -49,11 +59,11 @@ const formatContent = (content: string): string => { const hasHtmlTags = /<[a-z][\s\S]*>/i.test(content) if (hasHtmlTags) { - return content + return sanitizeHtml(content) } // Convert plain text to formatted HTML - return content + const result = content .split(/\n\n+/) // Split by double newlines (paragraphs) .map(paragraph => { // Check if it's a header (starts with # or numeric like "1.") @@ -83,6 +93,8 @@ const formatContent = (content: string): string => { return `

${formattedParagraph}

` }) .join('') + + return sanitizeHtml(result) } export default function Info() { diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 3c782c0..2d82d9d 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -301,18 +301,20 @@ export default function Subscription() { // Auto-scroll to switch tariff modal when it appears useEffect(() => { if (switchTariffId && switchModalRef.current) { - setTimeout(() => { + const timer = setTimeout(() => { switchModalRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }, 100) + return () => clearTimeout(timer) } }, [switchTariffId]) // Auto-scroll to tariff purchase form when it appears useEffect(() => { if (showTariffPurchase && tariffPurchaseRef.current) { - setTimeout(() => { + const timer = setTimeout(() => { tariffPurchaseRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }, 100) + return () => clearTimeout(timer) } }, [showTariffPurchase]) @@ -320,11 +322,12 @@ export default function Subscription() { useEffect(() => { const state = location.state as { scrollToExtend?: boolean } | null if (state?.scrollToExtend && tariffsCardRef.current) { - setTimeout(() => { + const timer = setTimeout(() => { tariffsCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }, 300) // Clear the state to prevent re-scrolling on subsequent renders window.history.replaceState({}, document.title) + return () => clearTimeout(timer) } }, [location.state]) diff --git a/src/pages/TelegramCallback.tsx b/src/pages/TelegramCallback.tsx index 46905cb..5bb6a0f 100644 --- a/src/pages/TelegramCallback.tsx +++ b/src/pages/TelegramCallback.tsx @@ -32,19 +32,27 @@ export default function TelegramCallback() { return } + // Parse and validate numeric fields + const parsedId = parseInt(id, 10) + const parsedAuthDate = parseInt(authDate, 10) + + if (Number.isNaN(parsedId) || Number.isNaN(parsedAuthDate)) { + setError(t('auth.telegramRequired')) + return + } + try { await loginWithTelegramWidget({ - id: parseInt(id, 10), + id: parsedId, first_name: firstName, last_name: lastName || undefined, username: username || undefined, photo_url: photoUrl || undefined, - auth_date: parseInt(authDate, 10), + auth_date: parsedAuthDate, hash: hash, }) navigate('/') } catch (err: unknown) { - console.error('Telegram auth error:', err) const error = err as { response?: { data?: { detail?: string } } } setError(error.response?.data?.detail || t('common.error')) } diff --git a/src/pages/TelegramRedirect.tsx b/src/pages/TelegramRedirect.tsx index 2488db9..3b52b1b 100644 --- a/src/pages/TelegramRedirect.tsx +++ b/src/pages/TelegramRedirect.tsx @@ -5,6 +5,29 @@ import { useQuery } from '@tanstack/react-query' import { useAuthStore } from '../store/auth' import { brandingApi } from '../api/branding' +// Validate redirect URL to prevent open redirect attacks +const getSafeRedirectUrl = (url: string | null): string => { + if (!url) return '/' + // Only allow relative paths starting with / + // Block protocol-relative URLs (//evil.com) and absolute URLs + if (!url.startsWith('/') || url.startsWith('//')) { + return '/' + } + // Additional check for encoded characters that could bypass validation + try { + const decoded = decodeURIComponent(url) + if (!decoded.startsWith('/') || decoded.startsWith('//') || decoded.includes('://')) { + return '/' + } + } catch { + return '/' + } + return url +} + +const MAX_RETRY_ATTEMPTS = 3 +const RETRY_COUNT_KEY = 'telegram_redirect_retry_count' + export default function TelegramRedirect() { const { t } = useTranslation() const navigate = useNavigate() @@ -12,6 +35,10 @@ export default function TelegramRedirect() { const { loginWithTelegram, isAuthenticated, isLoading: authLoading } = useAuthStore() const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'not-telegram'>('loading') const [errorMessage, setErrorMessage] = useState('') + const [retryCount, setRetryCount] = useState(() => { + const stored = sessionStorage.getItem(RETRY_COUNT_KEY) + return stored ? parseInt(stored, 10) : 0 + }) // Get branding for nice display const { data: branding } = useQuery({ @@ -24,8 +51,8 @@ export default function TelegramRedirect() { const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V' const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null - // Get redirect target from URL params - const redirectTo = searchParams.get('redirect') || '/' + // Get redirect target from URL params (validated) + const redirectTo = getSafeRedirectUrl(searchParams.get('redirect')) useEffect(() => { // If already authenticated, redirect immediately @@ -76,13 +103,28 @@ export default function TelegramRedirect() { setTimeout(initTelegram, 300) }, [loginWithTelegram, navigate, isAuthenticated, authLoading, redirectTo, t]) - // Handle retry + // Handle retry with limit to prevent infinite loops const handleRetry = () => { + if (retryCount >= MAX_RETRY_ATTEMPTS) { + setErrorMessage('Превышено количество попыток. Попробуйте позже.') + sessionStorage.removeItem(RETRY_COUNT_KEY) + return + } + const newCount = retryCount + 1 + setRetryCount(newCount) + sessionStorage.setItem(RETRY_COUNT_KEY, String(newCount)) setStatus('loading') setErrorMessage('') window.location.reload() } + // Clear retry count on successful auth + useEffect(() => { + if (status === 'success') { + sessionStorage.removeItem(RETRY_COUNT_KEY) + } + }, [status]) + return (
{/* Background */} diff --git a/src/store/auth.ts b/src/store/auth.ts index c53747e..d970d62 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -36,8 +36,12 @@ interface AuthState { } // Блокировка для предотвращения race condition при инициализации -let initializePromise: Promise | null = null -let isInitializing = false +// Используем объект для атомарности операций +const initState = { + promise: null as Promise | null, + isInitializing: false, + isInitialized: false, +} export const useAuthStore = create()( persist( @@ -69,8 +73,8 @@ export const useAuthStore = create()( logout: () => { const { refreshToken } = get() if (refreshToken) { - authApi.logout(refreshToken).catch((error) => { - console.error('[Auth] Logout API call failed:', error) + authApi.logout(refreshToken).catch(() => { + // Logout API call failed - ignore silently }) } tokenStorage.clearTokens() @@ -93,8 +97,7 @@ export const useAuthStore = create()( // Используем apiClient для единообразной обработки ошибок const response = await apiClient.get<{ is_admin: boolean }>('/cabinet/auth/me/is-admin') set({ isAdmin: response.data.is_admin }) - } catch (error) { - console.error('[Auth] Failed to check admin status:', error) + } catch { set({ isAdmin: false }) } }, @@ -103,19 +106,24 @@ export const useAuthStore = create()( try { const user = await authApi.getMe() set({ user }) - } catch (error) { - console.error('[Auth] Failed to refresh user:', error) + } catch { + // Failed to refresh user - ignore silently } }, initialize: async () => { - // Защита от race condition - если уже идёт инициализация, ждём её завершения - if (isInitializing && initializePromise) { - return initializePromise + // Защита от race condition - если уже инициализировано, выходим + if (initState.isInitialized) { + return } - isInitializing = true - initializePromise = (async () => { + // Если уже идёт инициализация, ждём её завершения + if (initState.isInitializing && initState.promise) { + return initState.promise + } + + initState.isInitializing = true + initState.promise = (async () => { try { set({ isLoading: true }) @@ -167,8 +175,7 @@ export const useAuthStore = create()( isLoading: false, }) get().checkAdminStatus() - } catch (error) { - console.error('[Auth] getMe failed, trying refresh:', error) + } catch { // Token might be invalid on server, try to refresh const newToken = await tokenRefreshManager.refreshAccessToken() if (newToken) { @@ -182,8 +189,7 @@ export const useAuthStore = create()( isLoading: false, }) get().checkAdminStatus() - } catch (userError) { - console.error('[Auth] getMe failed after refresh:', userError) + } catch { tokenStorage.clearTokens() set({ accessToken: null, @@ -206,12 +212,13 @@ export const useAuthStore = create()( } } } finally { - isInitializing = false - initializePromise = null + initState.isInitializing = false + initState.isInitialized = true + initState.promise = null } })() - return initializePromise + return initState.promise }, loginWithTelegram: async (initData) => { diff --git a/src/utils/token.ts b/src/utils/token.ts index 0c7522f..62f32dc 100644 --- a/src/utils/token.ts +++ b/src/utils/token.ts @@ -211,8 +211,8 @@ class TokenRefreshManager { } return null - } catch (error) { - console.error('[TokenRefreshManager] Refresh failed:', error) + } catch { + // Token refresh failed - don't log sensitive error details return null } }