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/TelegramRedirect.tsx b/src/pages/TelegramRedirect.tsx index 2488db9..b01859b 100644 --- a/src/pages/TelegramRedirect.tsx +++ b/src/pages/TelegramRedirect.tsx @@ -5,6 +5,26 @@ 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 +} + export default function TelegramRedirect() { const { t } = useTranslation() const navigate = useNavigate() @@ -24,8 +44,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