mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 `<p>${formattedParagraph}</p>`
|
||||
})
|
||||
.join('')
|
||||
|
||||
return sanitizeHtml(result)
|
||||
}
|
||||
|
||||
export default function Info() {
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
{/* Background */}
|
||||
|
||||
@@ -36,8 +36,12 @@ interface AuthState {
|
||||
}
|
||||
|
||||
// Блокировка для предотвращения 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>()(
|
||||
persist(
|
||||
@@ -69,8 +73,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
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<AuthState>()(
|
||||
// Используем 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<AuthState>()(
|
||||
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<AuthState>()(
|
||||
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<AuthState>()(
|
||||
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<AuthState>()(
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isInitializing = false
|
||||
initializePromise = null
|
||||
initState.isInitializing = false
|
||||
initState.isInitialized = true
|
||||
initState.promise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return initializePromise
|
||||
return initState.promise
|
||||
},
|
||||
|
||||
loginWithTelegram: async (initData) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user