security: harden Mini App auth, CSRF, and validation
This commit is contained in:
@@ -1,8 +1,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import secrets
|
||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
@@ -14,6 +17,7 @@ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|||||||
from aiohttp import ClientSession, ClientTimeout, web
|
from aiohttp import ClientSession, ClientTimeout, web
|
||||||
from aiogram import Bot, Dispatcher
|
from aiogram import Bot, Dispatcher
|
||||||
from aiogram.types import LabeledPrice
|
from aiogram.types import LabeledPrice
|
||||||
|
from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, field_validator
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import sessionmaker
|
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_WINDOW_SECONDS = 60
|
||||||
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
|
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
|
||||||
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
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: Optional[ClientSession] = None
|
||||||
_SHARED_HTTP_SESSION_LOCK = asyncio.Lock()
|
_SHARED_HTTP_SESSION_LOCK = asyncio.Lock()
|
||||||
@@ -69,7 +111,7 @@ def create_subscription_webapp_application(
|
|||||||
settings: Settings,
|
settings: Settings,
|
||||||
async_session_factory: sessionmaker,
|
async_session_factory: sessionmaker,
|
||||||
) -> web.Application:
|
) -> web.Application:
|
||||||
app = web.Application(middlewares=[_security_headers_middleware])
|
app = web.Application(middlewares=[_security_headers_middleware, _csrf_protection_middleware])
|
||||||
app["bot"] = bot
|
app["bot"] = bot
|
||||||
app["dp"] = dp
|
app["dp"] = dp
|
||||||
app["settings"] = settings
|
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/token", auth_token_route)
|
||||||
app.router.add_post("/api/auth/email/request", email_auth_request_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/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_get("/api/me", me_route)
|
||||||
app.router.add_post("/api/account/email/request", account_email_request_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)
|
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
|
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]:
|
def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
cache = request.app["webapp_settings_cache"]
|
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")
|
return _json_error(500, "auth_failed", "Auth failed")
|
||||||
|
|
||||||
token = create_webapp_session_token(settings, int(authenticated_user_id))
|
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:
|
async def email_auth_request_route(request: web.Request) -> web.Response:
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
payload = await _read_json(request)
|
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))
|
lang = _normalize_language(str(payload.get("language") or settings.DEFAULT_LANGUAGE))
|
||||||
return await _request_email_code(
|
return await _request_email_code(
|
||||||
request,
|
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:
|
async def email_auth_verify_route(request: web.Request) -> web.Response:
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
payload = await _read_json(request)
|
payload = await _read_json(request)
|
||||||
email = normalize_email(str(payload.get("email") or ""))
|
email_payload, validation_error = _validate_model_payload(WebAppEmailCodePayload, payload)
|
||||||
code = str(payload.get("code") or "")
|
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 "")
|
referral_param = str(payload.get("referral_code") or payload.get("start_param") or "")
|
||||||
email_service: EmailAuthService = request.app["email_auth_service"]
|
email_service: EmailAuthService = request.app["email_auth_service"]
|
||||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
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")
|
return _json_error(500, "auth_failed", "Auth failed")
|
||||||
|
|
||||||
token = create_webapp_session_token(settings, int(db_user.user_id))
|
token = create_webapp_session_token(settings, int(db_user.user_id))
|
||||||
return web.json_response(
|
return _build_webapp_auth_response(
|
||||||
|
settings,
|
||||||
{
|
{
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"token": token,
|
|
||||||
"user_id": int(db_user.user_id),
|
"user_id": int(db_user.user_id),
|
||||||
"telegram_id": _telegram_id_for_user(db_user),
|
"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)
|
user_id = _require_user_id(request)
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
payload = await _read_json(request)
|
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_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||||
|
|
||||||
async with async_session_factory() as session:
|
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
|
return rate_limit_response
|
||||||
|
|
||||||
payload = await _read_json(request)
|
payload = await _read_json(request)
|
||||||
email = normalize_email(str(payload.get("email") or ""))
|
email_payload, validation_error = _validate_model_payload(WebAppEmailCodePayload, payload)
|
||||||
code = str(payload.get("code") or "")
|
if validation_error:
|
||||||
|
return validation_error
|
||||||
|
email = email_payload.email
|
||||||
|
code = str(email_payload.code or "")
|
||||||
email_service: EmailAuthService = request.app["email_auth_service"]
|
email_service: EmailAuthService = request.app["email_auth_service"]
|
||||||
settings: Settings = request.app["settings"]
|
settings: Settings = request.app["settings"]
|
||||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
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")
|
return _json_error(500, "link_failed", "Link failed")
|
||||||
|
|
||||||
token = create_webapp_session_token(settings, int(final_user_id))
|
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:
|
if merge_notice:
|
||||||
response_payload["account_merge"] = merge_notice
|
response_payload["account_merge"] = merge_notice
|
||||||
response_payload["user_id"] = final_user_id
|
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:
|
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))
|
token = create_webapp_session_token(settings, int(final_user_id))
|
||||||
response_payload: Dict[str, Any] = {
|
response_payload: Dict[str, Any] = {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"token": token,
|
|
||||||
"user_id": int(final_user_id),
|
"user_id": int(final_user_id),
|
||||||
"telegram_id": final_telegram_id,
|
"telegram_id": final_telegram_id,
|
||||||
}
|
}
|
||||||
if merge_notice:
|
if merge_notice:
|
||||||
response_payload["account_merge"] = merge_notice
|
response_payload["account_merge"] = merge_notice
|
||||||
return web.json_response(
|
return _build_webapp_auth_response(settings, response_payload, token=token)
|
||||||
response_payload
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def me_route(request: web.Request) -> web.Response:
|
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
|
return rate_limit_response
|
||||||
|
|
||||||
payload = await _read_json(request)
|
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:
|
try:
|
||||||
months = int(float(payload.get("months")))
|
months = int(float(payment_payload.months))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return _json_error(400, "invalid_plan", "Invalid subscription period")
|
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"]
|
settings: Settings = request.app["settings"]
|
||||||
header = request.headers.get("Authorization", "")
|
header = request.headers.get("Authorization", "")
|
||||||
prefix = "Bearer "
|
prefix = "Bearer "
|
||||||
token = header[len(prefix):].strip() if header.startswith(prefix) else ""
|
if header.startswith(prefix):
|
||||||
user_id = verify_webapp_session_token(settings, token)
|
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:
|
if not user_id:
|
||||||
raise web.HTTPUnauthorized(
|
raise web.HTTPUnauthorized(
|
||||||
text=json.dumps({"ok": False, "error": "unauthorized"}),
|
text=json.dumps({"ok": False, "error": "unauthorized"}),
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ const MOCK = (() => {
|
|||||||
const TELEGRAM_LOGIN_WIDGET_URL = './telegram-widget.js';
|
const TELEGRAM_LOGIN_WIDGET_URL = './telegram-widget.js';
|
||||||
const state = {
|
const state = {
|
||||||
token: MOCK ? 'local-preview' : (localStorage.getItem('rw_webapp_token') || ''),
|
token: MOCK ? 'local-preview' : (localStorage.getItem('rw_webapp_token') || ''),
|
||||||
|
csrfToken: MOCK ? '' : (readCookie('rw_webapp_csrf') || ''),
|
||||||
data: null,
|
data: null,
|
||||||
selectedPlan: null,
|
selectedPlan: null,
|
||||||
referralParam: readReferralParam(),
|
referralParam: readReferralParam(),
|
||||||
@@ -514,7 +515,7 @@ const MOCK = (() => {
|
|||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.token) {
|
if (state.token || readCookie('rw_webapp_csrf')) {
|
||||||
try {
|
try {
|
||||||
await loadData();
|
await loadData();
|
||||||
return;
|
return;
|
||||||
@@ -584,7 +585,7 @@ const MOCK = (() => {
|
|||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (response.ok && data.ok && data.token) {
|
if (response.ok && data.ok && data.token) {
|
||||||
setToken(data.token);
|
setToken(data.token, data.csrf_token);
|
||||||
clearTelegramLoginWidgetQuery();
|
clearTelegramLoginWidgetQuery();
|
||||||
try {
|
try {
|
||||||
setAuthStatus('');
|
setAuthStatus('');
|
||||||
@@ -905,7 +906,7 @@ const MOCK = (() => {
|
|||||||
referral_code: state.referralParam || ''
|
referral_code: state.referralParam || ''
|
||||||
});
|
});
|
||||||
if (!data.ok || !data.token) throw data;
|
if (!data.ok || !data.token) throw data;
|
||||||
setToken(data.token);
|
setToken(data.token, data.csrf_token);
|
||||||
closeEmailLoginCodeModal();
|
closeEmailLoginCodeModal();
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1746,7 +1747,7 @@ const MOCK = (() => {
|
|||||||
body: JSON.stringify({email, code})
|
body: JSON.stringify({email, code})
|
||||||
});
|
});
|
||||||
if (!data.ok) throw data;
|
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;
|
mergeNotice = data.account_merge && data.account_merge.merged ? data.account_merge : null;
|
||||||
state.accountMergeNotice = mergeNotice;
|
state.accountMergeNotice = mergeNotice;
|
||||||
showToast(mergeNotice ? t('account_merge_toast') : t('email_linked'));
|
showToast(mergeNotice ? t('account_merge_toast') : t('email_linked'));
|
||||||
@@ -1824,7 +1825,7 @@ const MOCK = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function finishTelegramLink(data, fallbackTelegramId) {
|
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);
|
markTelegramLinked(data.telegram_id || fallbackTelegramId);
|
||||||
setTelegramLinkStatus('');
|
setTelegramLinkStatus('');
|
||||||
state.telegramLinkRendered = false;
|
state.telegramLinkRendered = false;
|
||||||
@@ -2106,7 +2107,15 @@ const MOCK = (() => {
|
|||||||
return mockApi(path, options);
|
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';
|
if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
|
||||||
const response = await fetch(CFG.apiBase + path, Object.assign({}, options, {headers}));
|
const response = await fetch(CFG.apiBase + path, Object.assign({}, options, {headers}));
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
@@ -2141,17 +2150,20 @@ const MOCK = (() => {
|
|||||||
return {ok: true};
|
return {ok: true};
|
||||||
}
|
}
|
||||||
if (path === '/auth/email/verify') {
|
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') {
|
if (path === '/account/email/verify') {
|
||||||
MOCK.data.user.email = 'preview@example.com';
|
MOCK.data.user.email = 'preview@example.com';
|
||||||
MOCK.data.user.email_verified = true;
|
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') {
|
if (path === '/account/telegram/link') {
|
||||||
MOCK.data.user.telegram_linked = true;
|
MOCK.data.user.telegram_linked = true;
|
||||||
MOCK.data.user.telegram_id = 100200300;
|
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') {
|
if (path === '/promo/apply') {
|
||||||
return {ok: true, end_date_text: '20.05.2026 12:00'};
|
return {ok: true, end_date_text: '20.05.2026 12:00'};
|
||||||
@@ -2175,16 +2187,23 @@ const MOCK = (() => {
|
|||||||
return {ok: false, error: 'not_found'};
|
return {ok: false, error: 'not_found'};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setToken(token) {
|
function setToken(token, csrfToken = '') {
|
||||||
state.token = token;
|
state.token = token;
|
||||||
localStorage.setItem('rw_webapp_token', token);
|
state.csrfToken = csrfToken || readCookie('rw_webapp_csrf') || state.csrfToken || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearToken() {
|
function clearToken() {
|
||||||
state.token = '';
|
state.token = '';
|
||||||
|
state.csrfToken = '';
|
||||||
localStorage.removeItem('rw_webapp_token');
|
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) {
|
function normalizeEmail(value) {
|
||||||
return String(value || '').trim().toLowerCase();
|
return String(value || '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -2210,6 +2229,7 @@ const MOCK = (() => {
|
|||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
toggleUserMenu(false);
|
toggleUserMenu(false);
|
||||||
|
void publicApi('/auth/logout', {}).catch(() => {});
|
||||||
clearToken();
|
clearToken();
|
||||||
closePaymentFlow();
|
closePaymentFlow();
|
||||||
closeEmailLoginCodeModal();
|
closeEmailLoginCodeModal();
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,6 +5,7 @@ pydantic==2.12.5
|
|||||||
yookassa==3.9.0
|
yookassa==3.9.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
pydantic_settings==2.12.0
|
pydantic_settings==2.12.0
|
||||||
|
email-validator==2.3.0
|
||||||
sqlalchemy[asyncio]==2.0.45
|
sqlalchemy[asyncio]==2.0.45
|
||||||
asyncpg==0.31.0
|
asyncpg==0.31.0
|
||||||
aiocryptopay==0.4.8
|
aiocryptopay==0.4.8
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import unittest
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from bot.app.web import subscription_webapp
|
||||||
|
from bot.app.web.webapp_auth import create_webapp_session_token
|
||||||
|
from bot.services.crypto_pay_service import CryptoPayService
|
||||||
from bot.handlers.user.payment import yookassa_webhook_route
|
from bot.handlers.user.payment import yookassa_webhook_route
|
||||||
from bot.services.freekassa_service import FreeKassaService
|
from bot.services.freekassa_service import FreeKassaService
|
||||||
from bot.utils.request_security import request_client_ip
|
from bot.utils.request_security import request_client_ip
|
||||||
@@ -97,6 +102,132 @@ class FreeKassaServiceTests(unittest.TestCase):
|
|||||||
request.read.assert_not_awaited()
|
request.read.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoPayServiceTests(unittest.TestCase):
|
||||||
|
def _make_service(self) -> CryptoPayService:
|
||||||
|
service = CryptoPayService.__new__(CryptoPayService)
|
||||||
|
service.token = "cryptopay-token"
|
||||||
|
return service
|
||||||
|
|
||||||
|
def test_validate_webhook_signature_accepts_valid_signature(self):
|
||||||
|
service = self._make_service()
|
||||||
|
raw_body = b'{"payload":"42"}'
|
||||||
|
expected_signature = hmac.new(
|
||||||
|
hashlib.sha256(service.token.encode("utf-8")).digest(),
|
||||||
|
raw_body,
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
self.assertTrue(service._validate_webhook_signature(raw_body, expected_signature))
|
||||||
|
|
||||||
|
def test_validate_webhook_signature_rejects_invalid_signature(self):
|
||||||
|
service = self._make_service()
|
||||||
|
|
||||||
|
self.assertFalse(service._validate_webhook_signature(b"payload", "not-a-signature"))
|
||||||
|
|
||||||
|
|
||||||
|
class WebAppSecurityTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
def test_require_user_id_falls_back_to_cookie_session(self):
|
||||||
|
settings = SimpleNamespace(
|
||||||
|
WEBAPP_SESSION_SECRET="session-secret",
|
||||||
|
WEBAPP_SESSION_TTL_SECONDS=3600,
|
||||||
|
)
|
||||||
|
token = create_webapp_session_token(settings, 321)
|
||||||
|
request = SimpleNamespace(
|
||||||
|
app={"settings": settings},
|
||||||
|
headers={},
|
||||||
|
cookies={"rw_webapp_session": token},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(subscription_webapp._require_user_id(request), 321)
|
||||||
|
|
||||||
|
async def test_csrf_middleware_rejects_mismatched_token_when_cookie_session_exists(self):
|
||||||
|
settings = SimpleNamespace(
|
||||||
|
WEBAPP_SESSION_SECRET="session-secret",
|
||||||
|
WEBAPP_SESSION_TTL_SECONDS=3600,
|
||||||
|
)
|
||||||
|
request = SimpleNamespace(
|
||||||
|
method="POST",
|
||||||
|
path="/api/payments",
|
||||||
|
headers={"X-CSRF-Token": "bad-token"},
|
||||||
|
cookies={"rw_webapp_session": "session-cookie", "rw_webapp_csrf": "good-token"},
|
||||||
|
app={"settings": settings},
|
||||||
|
)
|
||||||
|
handler = AsyncMock(return_value=web.Response(text="ok"))
|
||||||
|
|
||||||
|
response = await subscription_webapp._csrf_protection_middleware(request, handler)
|
||||||
|
|
||||||
|
self.assertEqual(response.status, 403)
|
||||||
|
handler.assert_not_awaited()
|
||||||
|
|
||||||
|
async def test_csrf_middleware_allows_matching_token_when_cookie_session_exists(self):
|
||||||
|
settings = SimpleNamespace(
|
||||||
|
WEBAPP_SESSION_SECRET="session-secret",
|
||||||
|
WEBAPP_SESSION_TTL_SECONDS=3600,
|
||||||
|
)
|
||||||
|
request = SimpleNamespace(
|
||||||
|
method="POST",
|
||||||
|
path="/api/payments",
|
||||||
|
headers={"X-CSRF-Token": "good-token"},
|
||||||
|
cookies={"rw_webapp_session": "session-cookie", "rw_webapp_csrf": "good-token"},
|
||||||
|
app={"settings": settings},
|
||||||
|
)
|
||||||
|
handler = AsyncMock(return_value=web.Response(text="ok"))
|
||||||
|
|
||||||
|
response = await subscription_webapp._csrf_protection_middleware(request, handler)
|
||||||
|
|
||||||
|
self.assertEqual(response.text, "ok")
|
||||||
|
handler.assert_awaited_once()
|
||||||
|
|
||||||
|
async def test_csrf_middleware_allows_valid_bearer_authorization_for_compatibility(self):
|
||||||
|
settings = SimpleNamespace(
|
||||||
|
WEBAPP_SESSION_SECRET="session-secret",
|
||||||
|
WEBAPP_SESSION_TTL_SECONDS=3600,
|
||||||
|
)
|
||||||
|
token = create_webapp_session_token(settings, 321)
|
||||||
|
request = SimpleNamespace(
|
||||||
|
method="POST",
|
||||||
|
path="/api/payments",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"X-CSRF-Token": "bad-token",
|
||||||
|
},
|
||||||
|
cookies={"rw_webapp_session": "session-cookie", "rw_webapp_csrf": "good-token"},
|
||||||
|
app={"settings": settings},
|
||||||
|
)
|
||||||
|
handler = AsyncMock(return_value=web.Response(text="ok"))
|
||||||
|
|
||||||
|
response = await subscription_webapp._csrf_protection_middleware(request, handler)
|
||||||
|
|
||||||
|
self.assertEqual(response.text, "ok")
|
||||||
|
handler.assert_awaited_once()
|
||||||
|
|
||||||
|
def test_email_payload_rejects_overlong_email(self):
|
||||||
|
long_email = ("a" * 245) + "@example.com"
|
||||||
|
|
||||||
|
model, response = subscription_webapp._validate_model_payload(
|
||||||
|
subscription_webapp.WebAppEmailPayload,
|
||||||
|
{"email": long_email},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(model)
|
||||||
|
self.assertEqual(response.status, 400)
|
||||||
|
self.assertIn("email_too_long", response.text)
|
||||||
|
|
||||||
|
def test_payment_payload_rejects_overlong_description(self):
|
||||||
|
model, response = subscription_webapp._validate_model_payload(
|
||||||
|
subscription_webapp.WebAppPaymentCreatePayload,
|
||||||
|
{
|
||||||
|
"method": "platega",
|
||||||
|
"months": 3,
|
||||||
|
"description": "x" * 4097,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(model)
|
||||||
|
self.assertEqual(response.status, 400)
|
||||||
|
self.assertIn("description_too_long", response.text)
|
||||||
|
|
||||||
|
|
||||||
def asyncio_run(coro):
|
def asyncio_run(coro):
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user