From cc74ddec10665c761cff5c735d5c8b033e00bf63 Mon Sep 17 00:00:00 2001 From: BADtochka Date: Sat, 6 Jun 2026 00:37:03 +0300 Subject: [PATCH] Log trusted forwarded client IPs --- backend/bot/app/web/web_server.py | 43 +++++++++++++++++++++++++++++-- tests/test_security.py | 22 ++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/backend/bot/app/web/web_server.py b/backend/bot/app/web/web_server.py index 2b1ae96..797a531 100644 --- a/backend/bot/app/web/web_server.py +++ b/backend/bot/app/web/web_server.py @@ -1,13 +1,16 @@ import asyncio +import functools import hmac import logging from aiogram import Bot, Dispatcher from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application from aiohttp import web +from aiohttp.web_log import AccessLogger, KeyMethod from sqlalchemy.orm import sessionmaker from bot.payment_providers import iter_provider_specs, iter_service_keys +from bot.utils.request_security import request_client_ip from config.settings import Settings @@ -18,6 +21,39 @@ class SecureSimpleRequestHandler(SimpleRequestHandler): return hmac.compare_digest(telegram_secret_token, self.secret_token) +class TrustedProxyAccessLogger(AccessLogger): + """Aiohttp access logger that respects trusted X-Forwarded-For headers.""" + + def compile_format(self, log_format): + methods = [] + for atom in self.FORMAT_RE.findall(log_format): + if atom[1] == "": + format_key = self.LOG_FORMAT_MAP[atom[0]] + method = getattr(type(self), f"_format_{atom[0]}", None) + if method is None: + method = getattr(AccessLogger, f"_format_{atom[0]}") + methods.append(KeyMethod(format_key, method)) + else: + format_key = (self.LOG_FORMAT_MAP[atom[2]], atom[1]) + method = getattr(type(self), f"_format_{atom[2]}", None) + if method is None: + method = getattr(AccessLogger, f"_format_{atom[2]}") + methods.append(KeyMethod(format_key, functools.partial(method, atom[1]))) + + compiled = self.FORMAT_RE.sub(r"%s", log_format) + compiled = self.CLEANUP_RE.sub(r"%\1", compiled) + return compiled, methods + + @staticmethod + def _format_a(request, response, time): + if request is None: + return "-" + settings = request.app.get("settings") if hasattr(request, "app") else None + trusted_proxies = getattr(settings, "trusted_proxies", None) + client_ip = request_client_ip(request, trusted_proxies=trusted_proxies) + return client_ip or "-" + + def _inject_shared_instances( app: web.Application, dp: Dispatcher, @@ -110,7 +146,7 @@ async def build_and_start_web_app( runners = [] - webhooks_runner = web.AppRunner(app) + webhooks_runner = web.AppRunner(app, access_log_class=TrustedProxyAccessLogger) await webhooks_runner.setup() runners.append(webhooks_runner) site = web.TCPSite( @@ -133,7 +169,10 @@ async def build_and_start_web_app( settings, async_session_factory, ) - subscription_runner = web.AppRunner(subscription_app) + subscription_runner = web.AppRunner( + subscription_app, + access_log_class=TrustedProxyAccessLogger, + ) await subscription_runner.setup() runners.append(subscription_runner) subscription_site = web.TCPSite( diff --git a/tests/test_security.py b/tests/test_security.py index fed1dc4..e8b3f58 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -11,6 +11,7 @@ from aiohttp import web from bot.app.web import admin_api, subscription_webapp from bot.app.web.admin_api_impl import settings as admin_settings_routes +from bot.app.web.web_server import TrustedProxyAccessLogger from bot.app.web.webapp import account as account_routes from bot.app.web.webapp_auth import ( create_telegram_oauth_nonce, @@ -47,6 +48,27 @@ class RequestSecurityTests(unittest.IsolatedAsyncioTestCase): "198.51.100.7", ) + async def test_access_logger_uses_forwarded_ip_only_for_trusted_proxy(self): + trusted_request = SimpleNamespace( + remote="172.19.0.7", + headers={"X-Forwarded-For": "203.0.113.10"}, + app={"settings": SimpleNamespace(trusted_proxies=["172.19.0.0/16"])}, + ) + untrusted_request = SimpleNamespace( + remote="172.19.0.7", + headers={"X-Forwarded-For": "203.0.113.10"}, + app={"settings": SimpleNamespace(trusted_proxies=["127.0.0.1"])}, + ) + + self.assertEqual( + TrustedProxyAccessLogger._format_a(trusted_request, object(), 0), + "203.0.113.10", + ) + self.assertEqual( + TrustedProxyAccessLogger._format_a(untrusted_request, object(), 0), + "172.19.0.7", + ) + async def test_yookassa_webhook_rejects_untrusted_ip_before_reading_body(self): request = SimpleNamespace( app={