fix(security): extract & reuse getSafeRedirectPath, plug TopUpAmount returnTo

TelegramRedirect already had a local getSafeRedirectUrl helper that
collapsed protocol-relative URLs, absolute URLs, exotic schemes, and
URL-encoded forms down to '/'. TopUpAmount.handleSuccess was navigating
straight to a user-supplied returnTo query param without that filter —
not externally exploitable through react-router's navigate() (it doesn't
trigger an external nav), but a crafted link could produce ugly path
artefacts ('?returnTo=https://evil.com' would land the user at
/balance/top-up/<method>/https://evil.com).

Hoist the helper to src/utils/safeRedirect.ts, rename to
getSafeRedirectPath, reuse it in TelegramRedirect, and wrap TopUpAmount's
returnTo through it before navigate().
This commit is contained in:
c0mrade
2026-05-26 16:13:33 +03:00
parent b6613ae4c9
commit 424a19344d
3 changed files with 40 additions and 22 deletions

View File

@@ -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

View File

@@ -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