security: harden Mini App auth, CSRF, and validation

This commit is contained in:
3252a8
2026-04-26 20:33:54 +03:00
parent 53dcc59770
commit d60952718e
6 changed files with 460 additions and 112 deletions
+218 -22
View File
@@ -1,8 +1,11 @@
import asyncio
import hashlib
import hmac
import ipaddress
import json
import logging
import re
import secrets
import socket
import time
from collections import deque
@@ -14,6 +17,7 @@ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from aiohttp import ClientSession, ClientTimeout, web
from aiogram import Bot, Dispatcher
from aiogram.types import LabeledPrice
from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
@@ -58,6 +62,44 @@ DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->"
WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
WEBAPP_SESSION_COOKIE_NAME = "rw_webapp_session"
WEBAPP_CSRF_COOKIE_NAME = "rw_webapp_csrf"
WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token"
WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
WEBAPP_CSRF_EXEMPT_PATHS = {
"/api/auth/token",
"/api/auth/email/request",
"/api/auth/email/verify",
"/api/auth/logout",
}
class WebAppEmailPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
email: EmailStr
@field_validator("email")
@classmethod
def _normalize_and_limit_email(cls, value: EmailStr) -> str:
normalized = normalize_email(str(value))
if len(normalized) > 254:
raise ValueError("email_too_long")
return normalized
class WebAppEmailCodePayload(WebAppEmailPayload):
code: str = ""
class WebAppPaymentCreatePayload(BaseModel):
model_config = ConfigDict(extra="ignore")
method: str = ""
months: Any = None
description: Optional[constr(max_length=4096)] = None
comment: Optional[constr(max_length=4096)] = None
note: Optional[constr(max_length=4096)] = None
_SHARED_HTTP_SESSION: Optional[ClientSession] = None
_SHARED_HTTP_SESSION_LOCK = asyncio.Lock()
@@ -69,7 +111,7 @@ def create_subscription_webapp_application(
settings: Settings,
async_session_factory: sessionmaker,
) -> web.Application:
app = web.Application(middlewares=[_security_headers_middleware])
app = web.Application(middlewares=[_security_headers_middleware, _csrf_protection_middleware])
app["bot"] = bot
app["dp"] = dp
app["settings"] = settings
@@ -123,6 +165,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_post("/api/auth/token", auth_token_route)
app.router.add_post("/api/auth/email/request", email_auth_request_route)
app.router.add_post("/api/auth/email/verify", email_auth_verify_route)
app.router.add_post("/api/auth/logout", logout_route)
app.router.add_get("/api/me", me_route)
app.router.add_post("/api/account/email/request", account_email_request_route)
app.router.add_post("/api/account/email/verify", account_email_verify_route)
@@ -326,6 +369,32 @@ async def _security_headers_middleware(request: web.Request, handler):
return response
@web.middleware
async def _csrf_protection_middleware(request: web.Request, handler):
settings: Settings = request.app["settings"]
header = request.headers.get("Authorization", "")
prefix = "Bearer "
if header.startswith(prefix):
if verify_webapp_session_token(settings, header[len(prefix):].strip()):
return await handler(request)
if (
request.method in WEBAPP_STATE_CHANGING_METHODS
and request.path not in WEBAPP_CSRF_EXEMPT_PATHS
and request.cookies.get(WEBAPP_SESSION_COOKIE_NAME)
):
csrf_cookie = request.cookies.get(WEBAPP_CSRF_COOKIE_NAME, "")
csrf_header = request.headers.get(WEBAPP_CSRF_HEADER_NAME, "")
if (
not csrf_cookie
or not csrf_header
or not hmac.compare_digest(csrf_header, csrf_cookie)
):
return _json_error(403, "csrf_failed", "Invalid CSRF token")
return await handler(request)
def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
settings: Settings = request.app["settings"]
cache = request.app["webapp_settings_cache"]
@@ -700,13 +769,22 @@ async def auth_token_route(request: web.Request) -> web.Response:
return _json_error(500, "auth_failed", "Auth failed")
token = create_webapp_session_token(settings, int(authenticated_user_id))
return web.json_response({"ok": True, "token": token})
return _build_webapp_auth_response(settings, {"ok": True}, token=token)
async def logout_route(request: web.Request) -> web.Response:
response = web.json_response({"ok": True})
_clear_webapp_auth_cookies(response)
return response
async def email_auth_request_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
payload = await _read_json(request)
email = normalize_email(str(payload.get("email") or ""))
email_payload, validation_error = _validate_model_payload(WebAppEmailPayload, payload)
if validation_error:
return validation_error
email = email_payload.email
lang = _normalize_language(str(payload.get("language") or settings.DEFAULT_LANGUAGE))
return await _request_email_code(
request,
@@ -720,8 +798,11 @@ async def email_auth_request_route(request: web.Request) -> web.Response:
async def email_auth_verify_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
payload = await _read_json(request)
email = normalize_email(str(payload.get("email") or ""))
code = str(payload.get("code") or "")
email_payload, validation_error = _validate_model_payload(WebAppEmailCodePayload, payload)
if validation_error:
return validation_error
email = email_payload.email
code = str(email_payload.code or "")
referral_param = str(payload.get("referral_code") or payload.get("start_param") or "")
email_service: EmailAuthService = request.app["email_auth_service"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
@@ -792,13 +873,14 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
return _json_error(500, "auth_failed", "Auth failed")
token = create_webapp_session_token(settings, int(db_user.user_id))
return web.json_response(
return _build_webapp_auth_response(
settings,
{
"ok": True,
"token": token,
"user_id": int(db_user.user_id),
"telegram_id": _telegram_id_for_user(db_user),
}
},
token=token,
)
@@ -806,7 +888,10 @@ async def account_email_request_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
email = normalize_email(str(payload.get("email") or ""))
email_payload, validation_error = _validate_model_payload(WebAppEmailPayload, payload)
if validation_error:
return validation_error
email = email_payload.email
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
@@ -837,8 +922,11 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
return rate_limit_response
payload = await _read_json(request)
email = normalize_email(str(payload.get("email") or ""))
code = str(payload.get("code") or "")
email_payload, validation_error = _validate_model_payload(WebAppEmailCodePayload, payload)
if validation_error:
return validation_error
email = email_payload.email
code = str(email_payload.code or "")
email_service: EmailAuthService = request.app["email_auth_service"]
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
@@ -952,11 +1040,11 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
return _json_error(500, "link_failed", "Link failed")
token = create_webapp_session_token(settings, int(final_user_id))
response_payload: Dict[str, Any] = {"ok": True, "token": token}
response_payload: Dict[str, Any] = {"ok": True}
if merge_notice:
response_payload["account_merge"] = merge_notice
response_payload["user_id"] = final_user_id
return web.json_response(response_payload)
return _build_webapp_auth_response(settings, response_payload, token=token)
async def account_telegram_link_route(request: web.Request) -> web.Response:
@@ -1078,15 +1166,12 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
token = create_webapp_session_token(settings, int(final_user_id))
response_payload: Dict[str, Any] = {
"ok": True,
"token": token,
"user_id": int(final_user_id),
"telegram_id": final_telegram_id,
}
if merge_notice:
response_payload["account_merge"] = merge_notice
return web.json_response(
response_payload
)
return _build_webapp_auth_response(settings, response_payload, token=token)
async def me_route(request: web.Request) -> web.Response:
@@ -1150,9 +1235,12 @@ async def create_payment_route(request: web.Request) -> web.Response:
return rate_limit_response
payload = await _read_json(request)
method = str(payload.get("method") or "").strip().lower()
payment_payload, validation_error = _validate_model_payload(WebAppPaymentCreatePayload, payload)
if validation_error:
return validation_error
method = str(payment_payload.method or "").strip().lower()
try:
months = int(float(payload.get("months")))
months = int(float(payment_payload.months))
except (TypeError, ValueError):
return _json_error(400, "invalid_plan", "Invalid subscription period")
@@ -1220,12 +1308,120 @@ def _json_error(status: int, code: str, message: str) -> web.Response:
)
def _require_user_id(request: web.Request) -> int:
def _validation_error_response(exc: ValidationError) -> web.Response:
for error in exc.errors():
loc = error.get("loc") or ()
field = str(loc[0]) if loc else ""
error_type = str(error.get("type") or "")
message = str(error.get("msg") or "")
message_lower = message.lower()
if field == "email":
if ("too_long" in message_lower or "too long" in message_lower or error_type == "string_too_long"):
return _json_error(400, "email_too_long", "Email is too long")
return _json_error(400, "invalid_email", "Invalid email")
if field in {"description", "comment", "note"} and error_type == "string_too_long":
return _json_error(400, f"{field}_too_long", f"{field.capitalize()} is too long")
if error_type == "string_too_long":
return _json_error(400, "text_too_long", "Text is too long")
return _json_error(400, "invalid_request", "Invalid request")
def _validate_model_payload(
model_cls: type[BaseModel],
payload: Dict[str, Any],
) -> tuple[Optional[BaseModel], Optional[web.Response]]:
try:
return model_cls.model_validate(payload), None
except ValidationError as exc:
return None, _validation_error_response(exc)
def _set_webapp_auth_cookies(
response: web.StreamResponse,
settings: Settings,
session_token: str,
csrf_token: str,
) -> None:
max_age = max(60, int(settings.WEBAPP_SESSION_TTL_SECONDS))
response.set_cookie(
WEBAPP_SESSION_COOKIE_NAME,
session_token,
httponly=True,
secure=True,
samesite="None",
path="/",
max_age=max_age,
)
response.set_cookie(
WEBAPP_CSRF_COOKIE_NAME,
csrf_token,
httponly=False,
secure=True,
samesite="None",
path="/",
max_age=max_age,
)
def _clear_webapp_auth_cookies(response: web.StreamResponse) -> None:
response.set_cookie(
WEBAPP_SESSION_COOKIE_NAME,
"",
httponly=True,
secure=True,
samesite="None",
path="/",
max_age=0,
)
response.set_cookie(
WEBAPP_CSRF_COOKIE_NAME,
"",
httponly=False,
secure=True,
samesite="None",
path="/",
max_age=0,
)
def _build_webapp_auth_response(
settings: Settings,
payload: Dict[str, Any],
*,
token: str,
csrf_token: Optional[str] = None,
) -> web.Response:
response_payload = dict(payload)
response_payload["ok"] = True
response_payload["token"] = token
csrf_value = csrf_token or secrets.token_hex(32)
response_payload["csrf_token"] = csrf_value
response = web.json_response(response_payload)
_set_webapp_auth_cookies(response, settings, token, csrf_value)
return response
def _extract_authenticated_user_id(request: web.Request) -> Optional[int]:
settings: Settings = request.app["settings"]
header = request.headers.get("Authorization", "")
prefix = "Bearer "
token = header[len(prefix):].strip() if header.startswith(prefix) else ""
user_id = verify_webapp_session_token(settings, token)
if header.startswith(prefix):
user_id = verify_webapp_session_token(settings, header[len(prefix):].strip())
if user_id:
return user_id
cookie_token = request.cookies.get(WEBAPP_SESSION_COOKIE_NAME, "")
if cookie_token:
return verify_webapp_session_token(settings, cookie_token)
return None
def _require_user_id(request: web.Request) -> int:
user_id = _extract_authenticated_user_id(request)
if not user_id:
raise web.HTTPUnauthorized(
text=json.dumps({"ok": False, "error": "unauthorized"}),
+31 -11
View File
@@ -93,6 +93,7 @@ const MOCK = (() => {
const TELEGRAM_LOGIN_WIDGET_URL = './telegram-widget.js';
const state = {
token: MOCK ? 'local-preview' : (localStorage.getItem('rw_webapp_token') || ''),
csrfToken: MOCK ? '' : (readCookie('rw_webapp_csrf') || ''),
data: null,
selectedPlan: null,
referralParam: readReferralParam(),
@@ -514,7 +515,7 @@ const MOCK = (() => {
} catch (e) { }
}
if (state.token) {
if (state.token || readCookie('rw_webapp_csrf')) {
try {
await loadData();
return;
@@ -584,7 +585,7 @@ const MOCK = (() => {
});
const data = await response.json();
if (response.ok && data.ok && data.token) {
setToken(data.token);
setToken(data.token, data.csrf_token);
clearTelegramLoginWidgetQuery();
try {
setAuthStatus('');
@@ -905,7 +906,7 @@ const MOCK = (() => {
referral_code: state.referralParam || ''
});
if (!data.ok || !data.token) throw data;
setToken(data.token);
setToken(data.token, data.csrf_token);
closeEmailLoginCodeModal();
await loadData();
} catch (e) {
@@ -1746,7 +1747,7 @@ const MOCK = (() => {
body: JSON.stringify({email, code})
});
if (!data.ok) throw data;
if (data.token) setToken(data.token);
if (data.token) setToken(data.token, data.csrf_token);
mergeNotice = data.account_merge && data.account_merge.merged ? data.account_merge : null;
state.accountMergeNotice = mergeNotice;
showToast(mergeNotice ? t('account_merge_toast') : t('email_linked'));
@@ -1824,7 +1825,7 @@ const MOCK = (() => {
}
async function finishTelegramLink(data, fallbackTelegramId) {
if (data.token) setToken(data.token);
if (data.token) setToken(data.token, data.csrf_token);
markTelegramLinked(data.telegram_id || fallbackTelegramId);
setTelegramLinkStatus('');
state.telegramLinkRendered = false;
@@ -2106,7 +2107,15 @@ const MOCK = (() => {
return mockApi(path, options);
}
const headers = Object.assign({'Authorization': 'Bearer ' + state.token}, options.headers || {});
const method = String(options.method || 'GET').toUpperCase();
const headers = Object.assign({}, options.headers || {});
if (state.token) {
headers.Authorization = 'Bearer ' + state.token;
}
const csrfToken = state.csrfToken || readCookie('rw_webapp_csrf') || '';
if (csrfToken && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
headers['X-CSRF-Token'] = csrfToken;
}
if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
const response = await fetch(CFG.apiBase + path, Object.assign({}, options, {headers}));
if (response.status === 401) {
@@ -2141,17 +2150,20 @@ const MOCK = (() => {
return {ok: true};
}
if (path === '/auth/email/verify') {
return {ok: true, token: 'local-preview'};
return {ok: true, token: 'local-preview', csrf_token: 'local-preview-csrf'};
}
if (path === '/account/email/verify') {
MOCK.data.user.email = 'preview@example.com';
MOCK.data.user.email_verified = true;
return {ok: true, token: 'local-preview'};
return {ok: true, token: 'local-preview', csrf_token: 'local-preview-csrf'};
}
if (path === '/account/telegram/link') {
MOCK.data.user.telegram_linked = true;
MOCK.data.user.telegram_id = 100200300;
return {ok: true, token: 'local-preview'};
return {ok: true, token: 'local-preview', csrf_token: 'local-preview-csrf'};
}
if (path === '/auth/logout') {
return {ok: true};
}
if (path === '/promo/apply') {
return {ok: true, end_date_text: '20.05.2026 12:00'};
@@ -2175,16 +2187,23 @@ const MOCK = (() => {
return {ok: false, error: 'not_found'};
}
function setToken(token) {
function setToken(token, csrfToken = '') {
state.token = token;
localStorage.setItem('rw_webapp_token', token);
state.csrfToken = csrfToken || readCookie('rw_webapp_csrf') || state.csrfToken || '';
}
function clearToken() {
state.token = '';
state.csrfToken = '';
localStorage.removeItem('rw_webapp_token');
}
function readCookie(name) {
const prefix = name + '=';
const cookie = document.cookie.split('; ').find(part => part.startsWith(prefix));
return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : '';
}
function normalizeEmail(value) {
return String(value || '').trim().toLowerCase();
}
@@ -2210,6 +2229,7 @@ const MOCK = (() => {
function logout() {
toggleUserMenu(false);
void publicApi('/auth/logout', {}).catch(() => {});
clearToken();
closePaymentFlow();
closeEmailLoginCodeModal();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long