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 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"}),
+31 -11
View File
@@ -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
+1
View File
@@ -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
+131
View File
@@ -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