< Summary

Information
Class: OzonOrdersWeb.Middleware.OzonIpWhitelistFilter
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Middleware/OzonIpWhitelistFilter.cs
Line coverage
88%
Covered lines: 67
Uncovered lines: 9
Coverable lines: 76
Total lines: 184
Line coverage: 88.1%
Branch coverage
76%
Covered branches: 32
Total branches: 42
Branch coverage: 76.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
OnActionExecuting(...)78.57%141495.65%
IsTokenValid(...)100%44100%
OnActionExecuted(...)100%210%
IsAllowed(...)100%1010100%
IsInSubnet(...)50%191470.83%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Middleware/OzonIpWhitelistFilter.cs

#LineLine coverage
 1using System;
 2using System.Net;
 3using System.Net.Sockets;
 4using System.Threading;
 5using System.Security.Cryptography;
 6using System.Text;
 7using Microsoft.AspNetCore.Mvc;
 8using Microsoft.AspNetCore.Mvc.Filters;
 9using Microsoft.Extensions.Configuration;
 10using Microsoft.Extensions.Logging;
 11using Services.OzonWebhookServices.Models;
 12
 13namespace OzonOrdersWeb.Middleware
 14{
 15    /// <summary>
 16    /// Action-фильтр, ограничивающий доступ к endpoint'у приёма push-уведомлений Ozon
 17    /// только разрешёнными IP-адресами.
 18    ///
 19    /// Разрешённые диапазоны по документации Ozon:
 20    ///   195.34.21.0/24, 185.73.192.0/22, 91.223.93.0/24
 21    ///
 22    /// Дополнительно разрешены loopback-адреса (127.0.0.1, ::1, 0.0.0.0)
 23    /// для локальной разработки и тестирования.
 24    ///
 25    /// Применяется через [ServiceFilter(typeof(OzonIpWhitelistFilter))] на контроллере,
 26    /// а не как глобальный middleware — чтобы не затрагивать остальные маршруты.
 27    /// Зарегистрирован как Scoped в DI.
 28    /// </summary>
 29    public sealed class OzonIpWhitelistFilter : IActionFilter
 30    {
 31        /// <summary>
 32        /// CIDR-диапазоны IP-адресов, с которых Ozon отправляет уведомления.
 33        /// Источник: https://docs.ozon.ru/api/seller/#tag/push-notifications
 34        /// </summary>
 135        private static readonly string[] OzonCidrRanges =
 136        {
 137            "195.34.21.0/24",
 138            "185.73.192.0/22",
 139            "91.223.93.0/24",
 140            "172.18.0.0/24"
 141        };
 42
 43        /// <summary>Параметр запроса с общим секретом (<c>?token=…</c> в URL, зарегистрированном в Ozon).</summary>
 44        public const string TokenQueryParameter = "token";
 45
 46        /// <summary>Ключ конфигурации с общим секретом; пусто — проверка токена отключена.</summary>
 47        public const string SharedTokenConfigKey = "OzonWebhook:SharedToken";
 48
 49        private static int _missingTokenWarned;
 50
 51        private readonly ILogger<OzonIpWhitelistFilter> _logger;
 52        private readonly string? _sharedToken;
 53
 854        public OzonIpWhitelistFilter(ILogger<OzonIpWhitelistFilter> logger, IConfiguration configuration)
 55        {
 856            _logger = logger;
 857            _sharedToken = configuration[SharedTokenConfigKey];
 858        }
 59
 60        public void OnActionExecuting(ActionExecutingContext context)
 61        {
 62            // Реальный IP Ozon до приложения не доходит: stream-nginx на VPS (SNI-роутер без proxy_protocol)
 63            // подменяет его на 127.0.0.1, а loopback в белом списке. Поэтому список IP сам по себе не защищает —
 64            // дополнительно проверяем общий секрет в URL.
 865            if (!IsTokenValid(context))
 66            {
 367                _logger.LogInformation("Ozon webhook: запрос отклонён — неверный или отсутствующий token");
 368                context.Result = new JsonResult(OzonErrorResponseFactory.Unknown("Доступ запрещён"))
 369                {
 370                    StatusCode = StatusCodes.Status403Forbidden,
 371                    ContentType = "application/json"
 372                };
 373                return;
 74            }
 75
 576            var remoteIp = context.HttpContext.Connection.RemoteIpAddress;
 77
 78            // Нормализуем IPv4-mapped IPv6 (например ::ffff:127.0.0.1 → 127.0.0.1)
 579            if (remoteIp is not null && remoteIp.IsIPv4MappedToIPv6)
 080                remoteIp = remoteIp.MapToIPv4();
 81
 582            if (remoteIp is null || !IsAllowed(remoteIp))
 83            {
 384                var ipString = remoteIp?.ToString() ?? "unknown";
 385                _logger.LogInformation(
 386                    "Ozon webhook: запрос отклонён — IP {Ip} не входит в список разрешённых диапазонов",
 387                    ipString);
 88
 389                context.Result = new JsonResult(
 390                    OzonErrorResponseFactory.Unknown($"Доступ запрещён: IP {ipString} не авторизован"))
 391                {
 392                    StatusCode = StatusCodes.Status403Forbidden,
 393                    ContentType = "application/json"
 394                };
 95            }
 596        }
 97
 98        private bool IsTokenValid(ActionExecutingContext context)
 99        {
 8100            if (string.IsNullOrEmpty(_sharedToken))
 101            {
 2102                if (Interlocked.Exchange(ref _missingTokenWarned, 1) == 0)
 1103                    _logger.LogInformation(
 1104                        "Ozon webhook: {Key} не задан — endpoint защищён только белым списком IP, который за nginx не ра
 1105                        SharedTokenConfigKey);
 2106                return true;
 107            }
 108
 6109            var provided = context.HttpContext.Request.Query[TokenQueryParameter].ToString();
 6110            return CryptographicOperations.FixedTimeEquals(
 6111                Encoding.UTF8.GetBytes(provided), Encoding.UTF8.GetBytes(_sharedToken));
 112        }
 113
 114        public void OnActionExecuted(ActionExecutedContext context)
 115        {
 116            // Действий после выполнения не требуется
 0117        }
 118
 119        // ─────────────────────────────────────────────────────────────────────
 120        // Приватные методы
 121        // ─────────────────────────────────────────────────────────────────────
 122
 123        private bool IsAllowed(IPAddress ip)
 124        {
 125            // Loopback (127.0.0.1, ::1) и Any (0.0.0.0) — для локальной разработки
 5126            if (IPAddress.IsLoopback(ip) ||
 5127                ip.Equals(IPAddress.Any) ||
 5128                ip.Equals(IPAddress.IPv6Any))
 1129                return true;
 130
 33131            foreach (var cidr in OzonCidrRanges)
 132            {
 13133                if (IsInSubnet(ip, cidr))
 1134                    return true;
 135            }
 136
 3137            return false;
 138        }
 139
 140        /// <summary>
 141        /// Проверяет, попадает ли IP-адрес в подсеть, заданную в CIDR-нотации.
 142        /// Поддерживает только IPv4. IPv6-адреса Ozon не использует.
 143        /// </summary>
 144        private bool IsInSubnet(IPAddress ip, string cidr)
 145        {
 146            try
 147            {
 13148                if (ip.AddressFamily != AddressFamily.InterNetwork)
 0149                    return false;
 150
 13151                var parts = cidr.Split('/');
 13152                if (parts.Length != 2)
 0153                    return false;
 154
 13155                if (!IPAddress.TryParse(parts[0], out var networkAddress))
 0156                    return false;
 157
 13158                if (!int.TryParse(parts[1], out var prefixLength) ||
 13159                    prefixLength < 0 || prefixLength > 32)
 0160                    return false;
 161
 13162                var ipBytes        = ip.GetAddressBytes();
 13163                var networkBytes   = networkAddress.GetAddressBytes();
 164
 165                // Маска в виде uint: для /24 → 0xFFFFFF00
 13166                uint mask = prefixLength == 0
 13167                    ? 0u
 13168                    : uint.MaxValue << (32 - prefixLength);
 169
 13170                uint ipInt      = ((uint)ipBytes[0]      << 24) | ((uint)ipBytes[1]      << 16) |
 13171                                  ((uint)ipBytes[2]      <<  8) |   ipBytes[3];
 13172                uint networkInt = ((uint)networkBytes[0] << 24) | ((uint)networkBytes[1] << 16) |
 13173                                  ((uint)networkBytes[2] <<  8) |   networkBytes[3];
 174
 13175                return (ipInt & mask) == (networkInt & mask);
 176            }
 0177            catch (Exception ex)
 178            {
 0179                _logger.LogError(ex, "Ошибка при проверке CIDR {Cidr} для IP {Ip}", cidr, ip);
 0180                return false;
 181            }
 13182        }
 183    }
 184}