mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
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:
@@ -7,26 +7,7 @@ import { useShallow } from 'zustand/shallow';
|
|||||||
import { brandingApi } from '../api/branding';
|
import { brandingApi } from '../api/branding';
|
||||||
import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK';
|
import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK';
|
||||||
import { tokenStorage } from '../utils/token';
|
import { tokenStorage } from '../utils/token';
|
||||||
|
import { getSafeRedirectPath } from '../utils/safeRedirect';
|
||||||
// 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 MAX_RETRY_ATTEMPTS = 3;
|
||||||
const RETRY_COUNT_KEY = 'telegram_redirect_retry_count';
|
const RETRY_COUNT_KEY = 'telegram_redirect_retry_count';
|
||||||
@@ -65,7 +46,7 @@ export default function TelegramRedirect() {
|
|||||||
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
|
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
|
||||||
|
|
||||||
// Get redirect target from URL params (validated)
|
// Get redirect target from URL params (validated)
|
||||||
const redirectTo = getSafeRedirectUrl(searchParams.get('redirect'));
|
const redirectTo = getSafeRedirectPath(searchParams.get('redirect'));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// All timers scheduled inside this effect funnel through `timers` so the
|
// All timers scheduled inside this effect funnel through `timers` so the
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
|||||||
import type { PaymentMethod, PaymentMethodOption } from '../types';
|
import type { PaymentMethod, PaymentMethodOption } from '../types';
|
||||||
import BentoCard from '../components/ui/BentoCard';
|
import BentoCard from '../components/ui/BentoCard';
|
||||||
import { saveTopUpPendingInfo } from '../utils/topUpStorage';
|
import { saveTopUpPendingInfo } from '../utils/topUpStorage';
|
||||||
|
import { getSafeRedirectPath } from '../utils/safeRedirect';
|
||||||
import { copyToClipboard } from '@/utils/clipboard';
|
import { copyToClipboard } from '@/utils/clipboard';
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
@@ -145,7 +146,12 @@ export default function TopUpAmount() {
|
|||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const handleSuccess = useCallback(() => {
|
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]);
|
}, [navigate, returnTo]);
|
||||||
|
|
||||||
// Keyboard: Escape to go back
|
// Keyboard: Escape to go back
|
||||||
|
|||||||
31
src/utils/safeRedirect.ts
Normal file
31
src/utils/safeRedirect.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user