Merge pull request #14 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-01-18 06:30:18 +03:00
committed by GitHub
9 changed files with 153 additions and 45 deletions

View File

@@ -6,6 +6,32 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
// Настраиваем endpoint для refresh // Настраиваем endpoint для refresh
tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/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 => { const getTelegramInitData = (): string | null => {
if (typeof window === 'undefined') return null if (typeof window === 'undefined') return null
@@ -51,6 +77,13 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) =>
if (telegramInitData && config.headers) { if (telegramInitData && config.headers) {
config.headers['X-Telegram-Init-Data'] = telegramInitData 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 return config
}) })

View File

@@ -75,28 +75,20 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
const starsPaymentMutation = useMutation({ const starsPaymentMutation = useMutation({
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks), mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
onSuccess: (data) => { onSuccess: (data) => {
console.log('[Stars] API response:', data)
console.log('[Stars] invoice_url:', data.invoice_url)
const webApp = window.Telegram?.WebApp const webApp = window.Telegram?.WebApp
console.log('[Stars] webApp:', webApp)
console.log('[Stars] openInvoice available:', !!webApp?.openInvoice)
if (!data.invoice_url) { if (!data.invoice_url) {
console.error('[Stars] No invoice_url in response!')
setError('Сервер не вернул ссылку на оплату') setError('Сервер не вернул ссылку на оплату')
return return
} }
if (!webApp?.openInvoice) { if (!webApp?.openInvoice) {
console.error('[Stars] openInvoice not available - not in Telegram Mini App?')
setError('Оплата Stars доступна только в Telegram Mini App') setError('Оплата Stars доступна только в Telegram Mini App')
return return
} }
console.log('[Stars] Calling openInvoice with:', data.invoice_url)
try { try {
webApp.openInvoice(data.invoice_url, (status) => { webApp.openInvoice(data.invoice_url, (status) => {
console.log('[Stars] Invoice callback status:', status)
if (status === 'paid') { if (status === 'paid') {
setError(null) setError(null)
onClose() onClose()
@@ -107,12 +99,10 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
} }
}) })
} catch (e) { } catch (e) {
console.error('[Stars] openInvoice error:', e)
setError('Ошибка открытия окна оплаты: ' + String(e)) setError('Ошибка открытия окна оплаты: ' + String(e))
} }
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
console.error('[Stars] API error:', error)
const axiosError = error as { response?: { data?: { detail?: string }, status?: number } } const axiosError = error as { response?: { data?: { detail?: string }, status?: number } }
const detail = axiosError?.response?.data?.detail const detail = axiosError?.response?.data?.detail
const status = axiosError?.response?.status const status = axiosError?.response?.status

View File

@@ -26,6 +26,19 @@ const appSchemes = [
const COUNTDOWN_SECONDS = 5 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() { export default function DeepLinkRedirect() {
const { i18n } = useTranslation() const { i18n } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
@@ -104,13 +117,13 @@ export default function DeepLinkRedirect() {
// Open deep link - same as miniapp, just window.location.href // Open deep link - same as miniapp, just window.location.href
const openDeepLink = useCallback(() => { const openDeepLink = useCallback(() => {
if (!deepLink) return if (!deepLink || !isValidDeepLink(deepLink)) return
window.location.href = deepLink window.location.href = deepLink
}, [deepLink]) }, [deepLink])
// Countdown timer effect // Countdown timer effect
useEffect(() => { useEffect(() => {
if (!deepLink) { if (!deepLink || !isValidDeepLink(deepLink)) {
setStatus('error') setStatus('error')
return return
} }

View File

@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import DOMPurify from 'dompurify'
import { infoApi, FaqPage } from '../api/info' import { infoApi, FaqPage } from '../api/info'
const InfoIcon = () => ( const InfoIcon = () => (
@@ -41,6 +42,15 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
type TabType = 'faq' | 'rules' | 'privacy' | 'offer' 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 // Convert plain text to HTML with proper formatting
const formatContent = (content: string): string => { const formatContent = (content: string): string => {
if (!content) return '' if (!content) return ''
@@ -49,11 +59,11 @@ const formatContent = (content: string): string => {
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(content) const hasHtmlTags = /<[a-z][\s\S]*>/i.test(content)
if (hasHtmlTags) { if (hasHtmlTags) {
return content return sanitizeHtml(content)
} }
// Convert plain text to formatted HTML // Convert plain text to formatted HTML
return content const result = content
.split(/\n\n+/) // Split by double newlines (paragraphs) .split(/\n\n+/) // Split by double newlines (paragraphs)
.map(paragraph => { .map(paragraph => {
// Check if it's a header (starts with # or numeric like "1.") // Check if it's a header (starts with # or numeric like "1.")
@@ -83,6 +93,8 @@ const formatContent = (content: string): string => {
return `<p>${formattedParagraph}</p>` return `<p>${formattedParagraph}</p>`
}) })
.join('') .join('')
return sanitizeHtml(result)
} }
export default function Info() { export default function Info() {

View File

@@ -301,18 +301,20 @@ export default function Subscription() {
// Auto-scroll to switch tariff modal when it appears // Auto-scroll to switch tariff modal when it appears
useEffect(() => { useEffect(() => {
if (switchTariffId && switchModalRef.current) { if (switchTariffId && switchModalRef.current) {
setTimeout(() => { const timer = setTimeout(() => {
switchModalRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) switchModalRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 100) }, 100)
return () => clearTimeout(timer)
} }
}, [switchTariffId]) }, [switchTariffId])
// Auto-scroll to tariff purchase form when it appears // Auto-scroll to tariff purchase form when it appears
useEffect(() => { useEffect(() => {
if (showTariffPurchase && tariffPurchaseRef.current) { if (showTariffPurchase && tariffPurchaseRef.current) {
setTimeout(() => { const timer = setTimeout(() => {
tariffPurchaseRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) tariffPurchaseRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 100) }, 100)
return () => clearTimeout(timer)
} }
}, [showTariffPurchase]) }, [showTariffPurchase])
@@ -320,11 +322,12 @@ export default function Subscription() {
useEffect(() => { useEffect(() => {
const state = location.state as { scrollToExtend?: boolean } | null const state = location.state as { scrollToExtend?: boolean } | null
if (state?.scrollToExtend && tariffsCardRef.current) { if (state?.scrollToExtend && tariffsCardRef.current) {
setTimeout(() => { const timer = setTimeout(() => {
tariffsCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) tariffsCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 300) }, 300)
// Clear the state to prevent re-scrolling on subsequent renders // Clear the state to prevent re-scrolling on subsequent renders
window.history.replaceState({}, document.title) window.history.replaceState({}, document.title)
return () => clearTimeout(timer)
} }
}, [location.state]) }, [location.state])

View File

@@ -32,19 +32,27 @@ export default function TelegramCallback() {
return 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 { try {
await loginWithTelegramWidget({ await loginWithTelegramWidget({
id: parseInt(id, 10), id: parsedId,
first_name: firstName, first_name: firstName,
last_name: lastName || undefined, last_name: lastName || undefined,
username: username || undefined, username: username || undefined,
photo_url: photoUrl || undefined, photo_url: photoUrl || undefined,
auth_date: parseInt(authDate, 10), auth_date: parsedAuthDate,
hash: hash, hash: hash,
}) })
navigate('/') navigate('/')
} catch (err: unknown) { } catch (err: unknown) {
console.error('Telegram auth error:', err)
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
setError(error.response?.data?.detail || t('common.error')) setError(error.response?.data?.detail || t('common.error'))
} }

View File

@@ -5,6 +5,29 @@ import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../store/auth' import { useAuthStore } from '../store/auth'
import { brandingApi } from '../api/branding' 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() { export default function TelegramRedirect() {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
@@ -12,6 +35,10 @@ export default function TelegramRedirect() {
const { loginWithTelegram, isAuthenticated, isLoading: authLoading } = useAuthStore() const { loginWithTelegram, isAuthenticated, isLoading: authLoading } = useAuthStore()
const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'not-telegram'>('loading') const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'not-telegram'>('loading')
const [errorMessage, setErrorMessage] = useState('') 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 // Get branding for nice display
const { data: branding } = useQuery({ 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 logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null
// Get redirect target from URL params // Get redirect target from URL params (validated)
const redirectTo = searchParams.get('redirect') || '/' const redirectTo = getSafeRedirectUrl(searchParams.get('redirect'))
useEffect(() => { useEffect(() => {
// If already authenticated, redirect immediately // If already authenticated, redirect immediately
@@ -76,13 +103,28 @@ export default function TelegramRedirect() {
setTimeout(initTelegram, 300) setTimeout(initTelegram, 300)
}, [loginWithTelegram, navigate, isAuthenticated, authLoading, redirectTo, t]) }, [loginWithTelegram, navigate, isAuthenticated, authLoading, redirectTo, t])
// Handle retry // Handle retry with limit to prevent infinite loops
const handleRetry = () => { 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') setStatus('loading')
setErrorMessage('') setErrorMessage('')
window.location.reload() window.location.reload()
} }
// Clear retry count on successful auth
useEffect(() => {
if (status === 'success') {
sessionStorage.removeItem(RETRY_COUNT_KEY)
}
}, [status])
return ( return (
<div className="min-h-screen flex items-center justify-center p-4"> <div className="min-h-screen flex items-center justify-center p-4">
{/* Background */} {/* Background */}

View File

@@ -36,8 +36,12 @@ interface AuthState {
} }
// Блокировка для предотвращения race condition при инициализации // Блокировка для предотвращения race condition при инициализации
let initializePromise: Promise<void> | null = null // Используем объект для атомарности операций
let isInitializing = false const initState = {
promise: null as Promise<void> | null,
isInitializing: false,
isInitialized: false,
}
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
persist( persist(
@@ -69,8 +73,8 @@ export const useAuthStore = create<AuthState>()(
logout: () => { logout: () => {
const { refreshToken } = get() const { refreshToken } = get()
if (refreshToken) { if (refreshToken) {
authApi.logout(refreshToken).catch((error) => { authApi.logout(refreshToken).catch(() => {
console.error('[Auth] Logout API call failed:', error) // Logout API call failed - ignore silently
}) })
} }
tokenStorage.clearTokens() tokenStorage.clearTokens()
@@ -93,8 +97,7 @@ export const useAuthStore = create<AuthState>()(
// Используем apiClient для единообразной обработки ошибок // Используем apiClient для единообразной обработки ошибок
const response = await apiClient.get<{ is_admin: boolean }>('/cabinet/auth/me/is-admin') const response = await apiClient.get<{ is_admin: boolean }>('/cabinet/auth/me/is-admin')
set({ isAdmin: response.data.is_admin }) set({ isAdmin: response.data.is_admin })
} catch (error) { } catch {
console.error('[Auth] Failed to check admin status:', error)
set({ isAdmin: false }) set({ isAdmin: false })
} }
}, },
@@ -103,19 +106,24 @@ export const useAuthStore = create<AuthState>()(
try { try {
const user = await authApi.getMe() const user = await authApi.getMe()
set({ user }) set({ user })
} catch (error) { } catch {
console.error('[Auth] Failed to refresh user:', error) // Failed to refresh user - ignore silently
} }
}, },
initialize: async () => { initialize: async () => {
// Защита от race condition - если уже идёт инициализация, ждём её завершения // Защита от race condition - если уже инициализировано, выходим
if (isInitializing && initializePromise) { if (initState.isInitialized) {
return initializePromise return
} }
isInitializing = true // Если уже идёт инициализация, ждём её завершения
initializePromise = (async () => { if (initState.isInitializing && initState.promise) {
return initState.promise
}
initState.isInitializing = true
initState.promise = (async () => {
try { try {
set({ isLoading: true }) set({ isLoading: true })
@@ -167,8 +175,7 @@ export const useAuthStore = create<AuthState>()(
isLoading: false, isLoading: false,
}) })
get().checkAdminStatus() get().checkAdminStatus()
} catch (error) { } catch {
console.error('[Auth] getMe failed, trying refresh:', error)
// Token might be invalid on server, try to refresh // Token might be invalid on server, try to refresh
const newToken = await tokenRefreshManager.refreshAccessToken() const newToken = await tokenRefreshManager.refreshAccessToken()
if (newToken) { if (newToken) {
@@ -182,8 +189,7 @@ export const useAuthStore = create<AuthState>()(
isLoading: false, isLoading: false,
}) })
get().checkAdminStatus() get().checkAdminStatus()
} catch (userError) { } catch {
console.error('[Auth] getMe failed after refresh:', userError)
tokenStorage.clearTokens() tokenStorage.clearTokens()
set({ set({
accessToken: null, accessToken: null,
@@ -206,12 +212,13 @@ export const useAuthStore = create<AuthState>()(
} }
} }
} finally { } finally {
isInitializing = false initState.isInitializing = false
initializePromise = null initState.isInitialized = true
initState.promise = null
} }
})() })()
return initializePromise return initState.promise
}, },
loginWithTelegram: async (initData) => { loginWithTelegram: async (initData) => {

View File

@@ -211,8 +211,8 @@ class TokenRefreshManager {
} }
return null return null
} catch (error) { } catch {
console.error('[TokenRefreshManager] Refresh failed:', error) // Token refresh failed - don't log sensitive error details
return null return null
} }
} }