diff --git a/src/pages/TelegramRedirect.tsx b/src/pages/TelegramRedirect.tsx index 2ab30c5..f7583ed 100644 --- a/src/pages/TelegramRedirect.tsx +++ b/src/pages/TelegramRedirect.tsx @@ -7,26 +7,7 @@ import { useShallow } from 'zustand/shallow'; import { brandingApi } from '../api/branding'; import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK'; import { tokenStorage } from '../utils/token'; - -// 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; -}; +import { getSafeRedirectPath } from '../utils/safeRedirect'; const MAX_RETRY_ATTEMPTS = 3; const RETRY_COUNT_KEY = 'telegram_redirect_retry_count'; @@ -65,7 +46,7 @@ export default function TelegramRedirect() { const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null; // Get redirect target from URL params (validated) - const redirectTo = getSafeRedirectUrl(searchParams.get('redirect')); + const redirectTo = getSafeRedirectPath(searchParams.get('redirect')); useEffect(() => { // All timers scheduled inside this effect funnel through `timers` so the diff --git a/src/pages/TopUpAmount.tsx b/src/pages/TopUpAmount.tsx index 448102d..3ea1a48 100644 --- a/src/pages/TopUpAmount.tsx +++ b/src/pages/TopUpAmount.tsx @@ -13,6 +13,7 @@ import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import type { PaymentMethod, PaymentMethodOption } from '../types'; import BentoCard from '../components/ui/BentoCard'; import { saveTopUpPendingInfo } from '../utils/topUpStorage'; +import { getSafeRedirectPath } from '../utils/safeRedirect'; import { copyToClipboard } from '@/utils/clipboard'; // Icons @@ -145,7 +146,12 @@ export default function TopUpAmount() { }, [navigate]); const handleSuccess = useCallback(() => { - navigate(returnTo || '/balance', { replace: true }); + // returnTo arrives via query string — validate as an in-app path before + // navigate(), otherwise an absolute or encoded URL produces ugly + // path artefacts in the URL bar. The validator returns '/' for invalid + // input; treat that case as "no returnTo" and use the /balance default. + const safe = getSafeRedirectPath(returnTo); + navigate(returnTo && safe !== '/' ? safe : '/balance', { replace: true }); }, [navigate, returnTo]); // Keyboard: Escape to go back diff --git a/src/utils/safeRedirect.ts b/src/utils/safeRedirect.ts new file mode 100644 index 0000000..00140d6 --- /dev/null +++ b/src/utils/safeRedirect.ts @@ -0,0 +1,31 @@ +/** + * Normalise a user-supplied redirect / returnTo URL down to a safe in-app path. + * + * Returns the input unchanged when it is a plain absolute path (`/foo/bar`). + * Returns `/` otherwise — for protocol-relative URLs (`//evil.com`), absolute + * URLs (`https://…`), exotic schemes (`javascript:`, `data:`), or anything + * that smuggles a host through URL encoding (`%2F%2Fevil.com`). + * + * Even with react-router's navigate() — which only treats inputs as paths + * and won't trigger an external nav — pasted absolute URLs would still + * produce ugly path artifacts. Centralising the check matches what + * TelegramRedirect already did and gives every returnTo entry the same + * shape. + */ +export function getSafeRedirectPath(url: string | null | undefined): string { + if (!url) return '/'; + // Only allow relative paths starting with / + if (!url.startsWith('/') || url.startsWith('//')) { + return '/'; + } + // Catch the encoded forms (//evil.com → %2F%2Fevil.com, scheme://…) + try { + const decoded = decodeURIComponent(url); + if (!decoded.startsWith('/') || decoded.startsWith('//') || decoded.includes('://')) { + return '/'; + } + } catch { + return '/'; + } + return url; +}