diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index 68808b8..711655a 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -63,10 +63,8 @@ settings: "/settings", }; const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js"; - const TELEGRAM_OAUTH_AVAILABILITY_URL = "https://oauth.telegram.org/"; const TELEGRAM_SDK_BOOT_TIMEOUT_MS = 900; const TELEGRAM_SDK_ACTION_TIMEOUT_MS = 1800; - const TELEGRAM_OAUTH_AVAILABILITY_TIMEOUT_MS = 15000; const TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS = 15000; const DEV_MOCK = { @@ -214,7 +212,7 @@ let telegramSdkStatus = tg ? "ready" : "idle"; let telegramSdkPromise = null; let telegramLaunchParamsDetected = false; - let telegramOAuthUnavailable = false; + let telegramMiniAppInitData = ""; let mode = isPreviewBoard ? "preview" : "loading"; let activeTab = "home"; @@ -363,12 +361,9 @@ $: supportUrl = String(appSettings?.support_url || CFG.supportUrl || "").trim(); $: telegramLoginBotId = Number(CFG.telegramLoginBotId || 0); $: telegramOAuthClientId = Number(CFG.telegramOAuthClientId || telegramLoginBotId || 0); - $: telegramMiniAppAuthAvailable = Boolean(tg?.initData); - $: telegramMiniAppSdkUnavailable = - telegramLaunchParamsDetected && !telegramMiniAppAuthAvailable && telegramSdkStatus === "unavailable"; + $: telegramMiniAppInitData = tg?.initData || readTelegramMiniAppInitDataFromLocation(); + $: telegramMiniAppAuthAvailable = Boolean(telegramMiniAppInitData); $: telegramLoginUnavailable = - telegramOAuthUnavailable || - telegramMiniAppSdkUnavailable || (!telegramMiniAppAuthAvailable && !telegramOAuthClientId && telegramSdkStatus !== "loading"); $: telegramLoginChecking = telegramLoginBusy || (authBusy && authStatus === t("wa_auth_checking_telegram")); $: telegramLoginLabel = telegramLoginUnavailable @@ -377,9 +372,7 @@ ? t("wa_auth_checking_telegram") : t("wa_login_telegram_button"); $: telegramLoginUnavailableMessage = - telegramOAuthUnavailable - ? t("wa_auth_telegram_timeout") - : telegramLoginUnavailable && telegramSdkStatus === "unavailable" + telegramLoginUnavailable && telegramSdkStatus === "unavailable" ? t("wa_auth_telegram_unavailable") : telegramLoginUnavailable ? t("wa_auth_telegram_not_configured") @@ -577,13 +570,26 @@ function refreshTelegramWebApp() { tg = resolveTelegramWebApp(); if (tg) telegramSdkStatus = "ready"; - if (tg?.initData) telegramLaunchParamsDetected = true; + telegramMiniAppInitData = tg?.initData || readTelegramMiniAppInitDataFromLocation(); + if (telegramMiniAppInitData) telegramLaunchParamsDetected = true; return tg; } + function readTelegramMiniAppInitDataFromLocation() { + const queryText = window.location.search.replace(/^\?/, ""); + const hashText = window.location.hash.replace(/^#/, ""); + for (const text of [queryText, hashText]) { + if (!text) continue; + const params = new URLSearchParams(text); + const initData = params.get("tgWebAppData"); + if (initData) return initData; + } + return ""; + } + function hasTelegramLaunchParams() { refreshTelegramWebApp(); - if (telegramLaunchParamsDetected || tg?.initData) { + if (telegramLaunchParamsDetected || telegramMiniAppInitData) { telegramLaunchParamsDetected = true; return true; } @@ -658,27 +664,6 @@ return await loadTelegramSdk(TELEGRAM_SDK_ACTION_TIMEOUT_MS); } - async function ensureTelegramOAuthAvailable() { - telegramOAuthUnavailable = false; - const controller = typeof AbortController === "undefined" ? null : new AbortController(); - const timeoutId = window.setTimeout(() => controller?.abort(), TELEGRAM_OAUTH_AVAILABILITY_TIMEOUT_MS); - try { - await fetch(`${TELEGRAM_OAUTH_AVAILABILITY_URL}?rw_check=${Date.now()}`, { - method: "GET", - mode: "no-cors", - cache: "no-store", - signal: controller?.signal, - }); - return true; - } catch { - telegramSdkStatus = "unavailable"; - telegramOAuthUnavailable = true; - return false; - } finally { - window.clearTimeout(timeoutId); - } - } - function createTelegramMiniAppAuthTimeout() { const controller = typeof AbortController === "undefined" ? null : new AbortController(); let timedOut = false; @@ -761,9 +746,10 @@ const widgetAuthData = readTelegramLoginWidgetAuthData(); if (widgetAuthData && (await finalizeTelegramAuth(widgetAuthData, "auth_data"))) return; - if (tg?.initData) { + const initData = telegramMiniAppInitData || tg?.initData || readTelegramMiniAppInitDataFromLocation(); + if (initData) { try { - if (await finalizeTelegramAuth(tg.initData, "init_data")) return; + if (await finalizeTelegramAuth(initData, "init_data")) return; } catch {} } @@ -1182,11 +1168,10 @@ if (attemptId !== telegramLoginAttemptId) return; telegramLoginWatchdogTimer = null; telegramSdkStatus = "unavailable"; - telegramOAuthUnavailable = true; telegramLoginBusy = false; authBusy = false; setAuthStatus(t("wa_auth_telegram_timeout"), true); - }, TELEGRAM_OAUTH_AVAILABILITY_TIMEOUT_MS); + }, TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS); return attemptId; } @@ -1209,29 +1194,10 @@ const isTelegramMiniAppAttempt = hasTelegramLaunchParams(); if (!isTelegramMiniAppAttempt && telegramOAuthClientId) { telegramLoginBusy = true; - const attemptId = startTelegramLoginWatchdog(); - try { - const available = await ensureTelegramOAuthAvailable(); - if (!isActiveTelegramLoginAttempt(attemptId)) return; - if (!available) { - setAuthStatus(t("wa_auth_telegram_timeout"), true); - return; - } - stopTelegramLoginWatchdog(attemptId); + window.location.assign(buildTelegramOAuthStartUrl("login")); + window.setTimeout(() => { telegramLoginBusy = false; - authBusy = false; - window.location.assign(buildTelegramOAuthStartUrl("login")); - } catch { - if (!isActiveTelegramLoginAttempt(attemptId)) return; - telegramSdkStatus = "unavailable"; - telegramOAuthUnavailable = true; - setAuthStatus(t("wa_auth_telegram_timeout"), true); - } finally { - if (isActiveTelegramLoginAttempt(attemptId)) { - stopTelegramLoginWatchdog(attemptId); - telegramLoginBusy = false; - } - } + }, 1500); return; } @@ -1243,22 +1209,23 @@ (async () => { await ensureTelegramSdkForAction(); if (!isActiveTelegramLoginAttempt(attemptId)) return; - if (tg?.initData) { - await finalizeTelegramAuth(tg.initData, "init_data", { signal: loginTimeout.signal }); + const initData = telegramMiniAppInitData || tg?.initData || readTelegramMiniAppInitDataFromLocation(); + if (initData) { + await finalizeTelegramAuth(initData, "init_data", { signal: loginTimeout.signal }); return; } - if (!telegramOAuthClientId || isTelegramMiniAppAttempt) { + if (!telegramOAuthClientId) { setAuthStatus( telegramSdkStatus === "unavailable" ? t("wa_auth_telegram_unavailable") - : isTelegramMiniAppAttempt - ? t("wa_auth_telegram_not_confirmed") - : t("wa_auth_telegram_not_configured"), + : t("wa_auth_telegram_not_configured"), true, ); return; } + + window.location.assign(buildTelegramOAuthStartUrl("login")); })(), loginTimeout.promise, ]); @@ -1391,8 +1358,9 @@ async function linkTelegramAccount() { if (linkTelegramBusy) return; if (shouldWaitForTelegramSdkBeforeOAuth()) await ensureTelegramSdkForAction(); - if (tg?.initData) { - await linkTelegramAccountWithPayload({ init_data: tg.initData }); + const initData = telegramMiniAppInitData || tg?.initData || readTelegramMiniAppInitDataFromLocation(); + if (initData) { + await linkTelegramAccountWithPayload({ init_data: initData }); return; } if (!telegramOAuthClientId) { diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index 882f97f..96901bd 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -70,6 +70,7 @@ WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60 WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4 WEBAPP_SESSION_COOKIE_NAME = "rw_webapp_session" WEBAPP_CSRF_COOKIE_NAME = "rw_webapp_csrf" +WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME = "rw_tg_oauth_state" WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token" WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"} WEBAPP_CSRF_EXEMPT_PATHS = { @@ -293,6 +294,51 @@ def _telegram_oauth_redirect_url(path: str = "/", *, status: Optional[str] = Non return f"{target_path}{separator}telegram_auth={status}" +def _set_telegram_oauth_state_cookie( + response: web.StreamResponse, + settings: Settings, + payload: Dict[str, Any], +) -> None: + max_age = max(60, int(settings.WEBAPP_LOGIN_TOKEN_TTL_SECONDS)) + response.set_cookie( + WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME, + create_signed_telegram_oauth_state(settings, payload, ttl_seconds=max_age), + httponly=True, + secure=True, + samesite="Lax", + path="/auth/telegram", + max_age=max_age, + ) + + +def _clear_telegram_oauth_state_cookie(response: web.StreamResponse) -> None: + response.set_cookie( + WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME, + "", + httponly=True, + secure=True, + samesite="Lax", + path="/auth/telegram", + max_age=0, + ) + + +def _read_telegram_oauth_state_payload( + request: web.Request, + state_token: str, +) -> Optional[Dict[str, Any]]: + settings: Settings = request.app["settings"] + signed_payload = request.cookies.get(WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME, "") + payload = verify_signed_telegram_oauth_state(settings, signed_payload) + if not payload: + return None + + expected_state = str(payload.get("state") or "") + if not expected_state or not hmac.compare_digest(expected_state, state_token): + return None + return payload + + def _urlsafe_sha256(value: str) -> str: digest = hashlib.sha256(value.encode("ascii")).digest() return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") @@ -737,23 +783,18 @@ async def telegram_oauth_start_route(request: web.Request) -> web.Response: if purpose == "link" and not current_user_id: raise web.HTTPFound(_telegram_oauth_redirect_url("/", status="unauthorized")) - code_verifier = secrets.token_urlsafe(64) + code_verifier = secrets.token_urlsafe(32) code_challenge = _urlsafe_sha256(code_verifier) - nonce = create_telegram_oauth_nonce( - settings, - ttl_seconds=settings.WEBAPP_LOGIN_TOKEN_TTL_SECONDS, - ) - state = create_signed_telegram_oauth_state( - settings, - { - "purpose": purpose, - "user_id": int(current_user_id) if current_user_id else None, - "referral_code": str(request.query.get("referral_code") or "")[:128], - "code_verifier": code_verifier, - "nonce": nonce, - }, - ttl_seconds=settings.WEBAPP_LOGIN_TOKEN_TTL_SECONDS, - ) + nonce = secrets.token_urlsafe(16) + state = secrets.token_urlsafe(16) + state_payload = { + "state": state, + "purpose": purpose, + "user_id": int(current_user_id) if current_user_id else None, + "referral_code": str(request.query.get("referral_code") or "")[:128], + "code_verifier": code_verifier, + "nonce": nonce, + } scopes = ["openid", "profile"] for permission in _resolve_telegram_oauth_request_access(settings): @@ -774,19 +815,27 @@ async def telegram_oauth_start_route(request: web.Request) -> web.Response: "code_challenge_method": "S256", } ) - raise web.HTTPFound(f"https://oauth.telegram.org/auth?{auth_query}") + response = web.HTTPFound(f"https://oauth.telegram.org/auth?{auth_query}") + _set_telegram_oauth_state_cookie(response, settings, state_payload) + raise response async def telegram_oauth_callback_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] + + def redirect(path: str = "/", status: Optional[str] = None) -> web.HTTPFound: + response = web.HTTPFound(_telegram_oauth_redirect_url(path, status=status)) + _clear_telegram_oauth_state_cookie(response) + return response + error = str(request.query.get("error") or "") if error: - raise web.HTTPFound(_telegram_oauth_redirect_url("/", status="cancelled")) + raise redirect("/", "cancelled") code = str(request.query.get("code") or "") - state = verify_signed_telegram_oauth_state(settings, str(request.query.get("state") or "")) + state = _read_telegram_oauth_state_payload(request, str(request.query.get("state") or "")) if not code or not state: - raise web.HTTPFound(_telegram_oauth_redirect_url("/", status="invalid_state")) + raise redirect("/", "invalid_state") token_payload = await _exchange_telegram_oauth_code( request, @@ -802,7 +851,7 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response: max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS, ) if not telegram_user: - raise web.HTTPFound(_telegram_oauth_redirect_url("/", status="invalid_token")) + raise redirect("/", "invalid_token") purpose = str(state.get("purpose") or "login") redirect_path = "/settings" if purpose == "link" else "/" @@ -842,7 +891,7 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response: if db_user.is_banned: await session.rollback() - raise web.HTTPFound(_telegram_oauth_redirect_url("/", status="banned")) + raise redirect("/", "banned") final_user_id = int(db_user.user_id) await session.commit() @@ -850,14 +899,15 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response: raise except UserMergeConflictError: await session.rollback() - raise web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="merge_conflict")) + raise redirect(redirect_path, "merge_conflict") except Exception: await session.rollback() logger.exception("Telegram OAuth callback failed") - raise web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="failed")) + raise redirect(redirect_path, "failed") token = create_webapp_session_token(settings, int(final_user_id)) response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success")) + _clear_telegram_oauth_state_cookie(response) _set_webapp_auth_cookies(response, settings, token, secrets.token_hex(32)) raise response diff --git a/tests/test_security.py b/tests/test_security.py index 6545a08..81dea0b 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -3,6 +3,7 @@ import hmac import unittest from types import SimpleNamespace from unittest.mock import AsyncMock +from urllib.parse import parse_qs, urlsplit from aiohttp import web @@ -241,6 +242,46 @@ class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(verify_telegram_oauth_nonce(settings, nonce)) self.assertFalse(verify_telegram_oauth_nonce(settings, nonce + "tampered")) + async def test_telegram_oauth_start_uses_short_public_state(self): + settings = SimpleNamespace( + WEBAPP_ENABLED=True, + BOT_TOKEN="123456789:secret", + TELEGRAM_OAUTH_CLIENT_ID=None, + TELEGRAM_OAUTH_CLIENT_SECRET="client-secret", + TELEGRAM_OAUTH_REQUEST_ACCESS="write", + WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600, + WEBAPP_SESSION_SECRET="session-secret", + WEBAPP_SESSION_TTL_SECONDS=3600, + SUBSCRIPTION_MINI_APP_URL="https://app.example.com/home", + ) + request = SimpleNamespace( + app={"settings": settings}, + query={"purpose": "login", "referral_code": "x" * 128}, + headers={}, + cookies={}, + ) + + with self.assertRaises(web.HTTPFound) as raised: + await subscription_webapp.telegram_oauth_start_route(request) + + redirect = raised.exception + query = parse_qs(urlsplit(redirect.headers["Location"]).query) + state = query["state"][0] + self.assertLessEqual(len(state), 64) + + cookie_name = subscription_webapp.WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME + signed_state = redirect.cookies[cookie_name].value + callback_request = SimpleNamespace( + app={"settings": settings}, + cookies={cookie_name: signed_state}, + ) + payload = subscription_webapp._read_telegram_oauth_state_payload(callback_request, state) + + self.assertIsNotNone(payload) + self.assertEqual(payload["referral_code"], "x" * 128) + self.assertEqual(len(payload["code_verifier"]), 43) + self.assertIsNone(subscription_webapp._read_telegram_oauth_state_payload(callback_request, state + "x")) + def test_telegram_oauth_client_id_defaults_to_bot_id(self): settings = SimpleNamespace( BOT_TOKEN="123456789:secret",