隐藏

ASP.NET Core中使用拦截器实现一个简单的WAF防火墙

发布:2021/12/18 17:27:13作者:管理员 来源:本站 浏览次数:1342

我的网站一直以来都有一个独特的防火墙,可以拦截指定地区的流量,可以根据请求中是否包含敏感词进行拦截等,这是其他的防火墙没有的功能,也是本站的最后一道防线,今天,就分享一下本站内置的简单web防火墙的实现逻辑。

那先说要实现哪些需求吧:

1. 我想拦截掉所有的北京市的访问流量;

2. 我想拦截请求中包含敏感词的流量;

3. 我想拦截掉黑名单中的IP;

4. 我想拦截特定的IP地址段;

5. 我想限制同一IP每分钟的最大请求数为300次/分钟,若一分钟内请求数超过300则认为是恶意请求,冻结该IP 1分钟;

6. 如果这个IP被冻结了还在不停地请求,那就将这个IP上报给cloudflare进行永久封禁。

创建项目

闲话少说,直接上代码吧,我们先创建一个简单的web应用项目。代码结构如下,非常基础的一个项目结构:

我们需要引用以下nuget组件,以方便我们后续的实现:

Autofac.Extensions.DependencyInjection CacheManager.Microsoft.Extensions.Caching.Memory CacheManager.Serialization.Json IP2Region Polly 

因为我们需要用到属性依赖注入,缓存,当然,用.NET Core自带的依赖注入和MemoryCache也是可以的,只是我觉得Autofac和CacheManager比自带的更好用而已。

创建一个过滤器

创建一个名为FirewallAttribute的过滤器,继承自ActionFilterAttribute,并重写OnActionExecuting方法:

 public class FirewallAttribute : ActionFilterAttribute
    {
        /// <inheritdoc />
        public override void OnActionExecuting(ActionExecutingContext context)
        {
            
        }
    }

如何拦截指定的地区

要识别地区当然得知道访客当前是在哪个地区,这就只能通过客户端的请求IP来判断了,.NET Core中可以通过HttpContext.Connection.RemoteIpAddress来获取客户端的IP地址,但是,如果网站应用进行了负载均衡或者挂了CDN,那么通过HttpContext.Connection.RemoteIpAddress获取到的IP地址将是CDN或nginx服务器的地址,这就导致了网站应用获取到的IP不准确,那如何才能获取到真正的客户端IP呢?这就需要配置.NET Core的几个中间件以支持让网站应用挂在CDN或nginx后依然能够获取到真实的客户端IP:

public void ConfigureServices(IServiceCollection services)
{
        // 配置CacheManager
        services.AddSingleton(typeof(ICacheManager<>), typeof(BaseCacheManager<>));
        services.AddSingleton(new ConfigurationBuilder().WithJsonSerializer().WithMicrosoftMemoryCacheHandle().WithExpiration(ExpirationMode.Absolute, TimeSpan.FromMinutes(5)).Build());
    services.AddMvc();
    
    services.Configure<ForwardedHeadersOptions>(options =>
    {
        options.ForwardLimit = null;// 限制所处理的标头中的条目数
        options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; // X-Forwarded-For:保存代理链中关于发起请求的客户端和后续代理的信息。X-Forwarded-Proto:原方案的值 (HTTP/HTTPS)    
        options.KnownNetworks.Clear(); // 从中接受转接头的已知网络的地址范围。 使用无类别域际路由选择 (CIDR) 表示法提供 IP 范围。使用CDN时应清空
        options.KnownProxies.Clear(); // 从中接受转接头的已知代理的地址。 使用 KnownProxies 指定精确的 IP 地址匹配。使用CDN时应清空
    });
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    app.UseForwardedHeaders().UseCertificateForwarding(); // 转发请求头和证书
    app.UseStaticFiles();
    app.UseMvc();
} 

通过上面的简单配置,再使用HttpContext.Connection.RemoteIpAddress获取到的IP地址就是客户端的真实IP了。

关于.NET Core中的代理服务器和负载均衡器的更多内容,参阅微软官方文档:

好,现在已经拿到客户端的IP了,那怎么知道这个IP是哪个地方的呢,调第三方API?基于性能考虑,调第三方API肯定是不行的,毕竟每次请求都要通过这个过滤器进行流量清洗,第三方API有诸多的不确定因素,所以我们只能考虑使用本地数据库,那就得用到ip2region这个包和IP数据库文件了。

ip2region数据库下载:

作者也会不定时更新IP数据库,不算很精确,但想要实现拦截指定城市的流量还是够用的,如果你是人民币玩家,可以考虑购买ipip家的更精确的离线数据库。

获取IP地址是否是禁区的方法封装:

 private static readonly DbSearcher Searcher = new DbSearcher(Path.Combine(AppContext.BaseDirectory + "App_Data", "ip2region.db"));
        /// <summary>
        /// 是否是禁区
        /// </summary>
        /// <param name="ip"></param>
        /// <returns></returns>
        public static bool IsInDenyArea(string ip)
        {
            var pos = Searcher.MemorySearch(ip).Region;
            return pos.Contains("北京");
        } 

现在,我们再实现一个自定义异常,如果检测到是禁区的IP,我们抛出这个自定义异常,做统一的拦截处理:

 public class AccessDenyException : Exception
    {
        public AccessDenyException(string msg) : base(msg)
        {
        }
    }

FirewallAttribute已实现如下:

 public class FirewallAttribute : ActionFilterAttribute
    {
        /// <inheritdoc />
        public override void OnActionExecuting(ActionExecutingContext context)
        {
            var request = context.HttpContext.Request;
            var ip = context.HttpContext.Connection.RemoteIpAddress.MapToIPv4().ToString();
            if (IsInDenyArea(ip))
            {
                // todo:记录日志
                throw new AccessDenyException("访问地区限制");
            }
        }
        private static readonly DbSearcher Searcher = new DbSearcher(Path.Combine(AppContext.BaseDirectory + "App_Data", "ip2region.db"));
        /// <summary>
        /// 是否是禁区
        /// </summary>
        /// <param name="ip"></param>
        /// <returns></returns>
        public static bool IsInDenyArea(string ip)
        {
            var pos = Searcher.MemorySearch(ip).Region;
            return pos.Contains("北京");
        }
    } 

不过,由于X-Forwarded-For标头可能会被客户端篡改进行伪造,所以我们还需要用CDN提供的一个转发客户端真实IP并且不可篡改的标头进行双重检查,比如cloudflare的CF-Connecting-IP标头是不可被篡改的标头。

我们改造上面的代码:

var trueip = request.Headers["CF-Connecting-IP"].ToString();
if (!string.IsNullOrEmpty(trueip) && ip != trueip)
{
    context.Result = new BadRequestObjectResult("客户端请求不合法,伪造IP:" + ip);
    return;
} 

好,现在已经实现清洗指定地区的流量了,第一个需求已经实现完成,接下来我们实现第二个需求,拦截请求中包含敏感词的流量

拦截请求中包含敏感词的流量

这需要本地做一个敏感词库,比如政治类、虚假广告、色情类,这里简单做几个敏感词进行演示(以下敏感词汇仅仅用于文章内容演示,知乎的审查官别误判了哦):

彩票|办证|AV女优

敏感词文件我们存放在:/App_Data/ban.txt中。

我们先获取到客户端的请求路径,

var path = HttpUtility.UrlDecode(request.Path + request.QueryString, Encoding.UTF8);

然后根据请求路径判断是否匹配敏感词,通过正则表达式进行匹配,若匹配,我们则阻止继续访问,

 if (Regex.Match(path ?? "", "彩票|办证|AV女优").Length > 0) // 写死的敏感词,实际项目请从本地库中动态读取
            {
                // todo:记录拦截日志
                context.Result = new BadRequestObjectResult("参数不合法!");
                return;
            } 

这个规则实现就是这么简单。好,继续实现第三个规则,拦截掉黑名单中的IP

拦截掉黑名单中的IP

同样,这需要一个本地文件来存储黑名单,为方便演示,这里也写死几个IP地址,实际项目也从文件里动态读取。

如果黑名单中包含客户端IP,也直接Block掉:

 if (new[] { "1.2.3.4", "5.6.7.8" }.Contains(ip))
            {
                // todo:记录日志
                context.Result = new BadRequestObjectResult("您当前所在的网络环境不支持访问本站!");
                return;
            } 

拦截特定的IP地址段

同样有一个专门存储IP地址段的文件,比如我想拦截36.149.0.0~36.149.15.255这个地址段,那怎么判断一个IP是否在这个地址段内呢?学过网络的同学肯定知道,IP地址实际上是一段32位的二进制编码,我们可以将IP地址转换成十进制后,就可以判断是否在地址段内了,这里Masuit.Tools工具库已经封装有IP地址转十进制的方法,可直接调用字符串扩展方法:IpAddressInRange,如:

"36.149.12.100".IpAddressInRange("36.149.0.0","36.149.15.255"); // 结果将是true

IP地址段的文件我们定义为一行一段,通过代码读取地址段文件,转换成字典Dictionary:

var lines=File.ReadAllLines(Path.Combine(AppContext.BaseDirectory + "App_Data", "DenyIPRange.txt"), Encoding.UTF8);
var denyIPRange = new Dictionary<string, string>();
foreach (string line in lines)
{
    try
    {
        var strs = line.Split(' ');
        denyIPRange[strs[0]] = strs[1];
    }
    catch (IndexOutOfRangeException)
    {
    }
} 

FirewallAttribute中实现则是:

 if (new[] { "1.2.3.4", "5.6.7.8" }.Contains(ip) || denyIPRange.AsParallel().Any(kv => kv.Key.StartsWith(ip.Split('.')[0]) && ip.IpAddressInRange(kv.Key, kv.Value)))
            {
                // todo:记录日志
                context.Result = new BadRequestObjectResult("您当前所在的网络环境不支持访问本站!");
                return;
            } 

下一步,限流。

限制请求频次300次/分钟

我们需要记录每个IP的请求次数,所以这里需要用到缓存组件CacheManager,用CacheManager的好处在于可以平滑的在各种持久化库之间切换,也就是可以不动主要代码的情况下,直接从内存缓存切换到Redis或MongoDB之类的这种操作。

通过属性注入的方式,给FirewallAttribute注入:

public ICacheManager<int> CacheManager { get; set; }

限流规则:同一IP每分钟的最大请求数为300次/分钟,若一分钟内请求数超过300则认为是恶意请求,冻结该IP 1分钟,并且是滑动时间。

同样也需要实现一个自定义异常以方便做同一拦截处理:

 public class TempDenyException : Exception
    {
        public TempDenyException(string msg) : base(msg)
        {
        }
    } 

代码实现如下:

 var times = CacheManager.AddOrUpdate("Frequency:" + ip, 1, i => i + 1, 5);
            CacheManager.Expire("Frequency:" + ip, ExpirationMode.Sliding, TimeSpan.FromSeconds(1));
            var limit = 300;
            if (times <= limit)
            {
                return;
            }

            if (times > limit * 1.2)
            {
                CacheManager.Expire("Frequency:" + ip, ExpirationMode.Sliding, TimeSpan.FromMinutes(1));
                // todo:记录日志
            }

            throw new TempDenyException("访问频次限制");

将临时封禁的IP上报cloudflare进行永久封禁

如果这个IP呗临时封禁了,如果是正常用户再使用,他能够在被封禁后暂停使用,而攻击者是机器请求,短时间内大量的,会不停继续请求,因此,识别后直接提交给cloudflare或上游防火墙进行永久封禁。

本教程以cloudflare为例,我们现在appsettings.json里增加如下配置节:

 "FirewallService": { // 防火墙服务上报模块配置 "type": "cloudflare", // cloudflare或none "Cloudflare": { // type为cloudflare时生效 "Scope": "accounts", // 范围:accounts、zones "ZoneId": "区域或账户id", // 区域或账户id,scope为accounts则填账户id,scope为zones则填zoneid "AuthEmail": "授权邮箱账号", // 授权邮箱账号 "AuthKey": "AuthKey" // apikey } }

创建一个IFirewallRepoter接口类:

 public interface IFirewallRepoter
    {
        string ReporterName { get; set; }
        /// <summary>
        /// 上报IP
        /// </summary>
        /// <param name="ip"></param>
        void Report(IPAddress ip);
        /// <summary>
        /// 上报IP
        /// </summary>
        /// <param name="ip"></param>
        /// <returns></returns>
        Task ReportAsync(IPAddress ip);
    }

再分别创建一个DefaultFirewallRepoter和CloudflareRepoter类实现这个接口:

 public class CloudflareRepoter : IFirewallRepoter { private readonly HttpClient _httpClient; private readonly IConfiguration _configuration; public string ReporterName { get; set; } = "cloudflare"; public CloudflareRepoter(HttpClient httpClient, IConfiguration configuration) { _httpClient = httpClient; _configuration = configuration; } public void Report(IPAddress ip) { ReportAsync(ip).Wait(); } public Task ReportAsync(IPAddress ip) { var scope = _configuration["FirewallService:Cloudflare:Scope"]; var zoneid = _configuration["FirewallService:Cloudflare:ZoneId"]; var fallbackPolicy = Policy.HandleInner<HttpRequestException>().FallbackAsync(_ => { LogManager.Info($"cloudflare请求出错,{ip}上报失败!"); return Task.CompletedTask; }); var retryPolicy = Policy.HandleInner<HttpRequestException>().RetryAsync(3); return fallbackPolicy.WrapAsync(retryPolicy).ExecuteAsync(() => _httpClient.PostAsJsonAsync($"https://api.cloudflare.com/client/v4/{scope}/{zoneid}/firewall/access_rules/rules", new { mode = "block", notes = $"恶意请求IP{ip.GetIPLocation()}", configuration = new { target = ip.AddressFamily switch { AddressFamily.InterNetworkV6 => "ip6", _ => "ip" }, value = ip.ToString() } }).ContinueWith(t => { if (!t.Result.IsSuccessStatusCode) { throw new HttpRequestException("请求失败"); } })); } } public class DefaultFirewallRepoter : IFirewallRepoter { public string ReporterName { get; set; } /// <summary>  /// 上报IP  /// </summary>  /// <param name="ip"></param>  public void Report(IPAddress ip) { } /// <summary>  /// 上报IP  /// </summary>  /// <param name="ip"></param>  /// <returns></returns>  public Task ReportAsync(IPAddress ip) { return Task.CompletedTask; } } 

并在Startup.cs中配置依赖注入:

 switch (configuration["FirewallService:type"]) { case "Cloudflare": case "cloudflare": case "cf": services.AddHttpClient<IFirewallRepoter, CloudflareRepoter>().ConfigureHttpClient(c => { c.DefaultRequestHeaders.Add("X-Auth-Email", configuration["FirewallService:Cloudflare:AuthEmail"]); c.DefaultRequestHeaders.Add("X-Auth-Key", configuration["FirewallService:Cloudflare:AuthKey"]); }); break; default: services.AddSingleton<IFirewallRepoter, DefaultFirewallRepoter>(); break; } 

这样程序启动的时候告诉防火墙需要注入哪个实例,我们再给FirewallAttribute添加一个属性:

public IFirewallRepoter FirewallRepoter { get; set; }

为防火墙添加最后一个策略:如果被临时封禁后,还在继续请求达到了1.5倍的阈值,则永久封禁:

 if (times > limit * 1.5) { FirewallRepoter.ReportAsync(IPAddress.Parse(ip)).ConfigureAwait(false); } 


到此为止,以上6个规则的防火墙代码已经完整实现,不过,有些页面或接口会不受防火墙规则约束,所以我们让这些接口跳过防火墙。

AllowAccessFirewallAttribute以跳过防火墙规则

自定义Attribute:

 [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class AllowAccessFirewallAttribute : Attribute, IFilterFactory, IOrderedFilter { public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) { return new AllowAccessFirewallAttribute(); } public bool IsReusable => true; public int Order { get; } } 

FirewallAttribute中检测到Action或Controller有该标记的,则跳过:

 if (context.Filters.Any(m => m.ToString().Contains(nameof(AllowAccessFirewallAttribute)))) { return; } 

FirewallAttribute完整代码如下

我们再将上面所有的规则梳理,并按一定的优先级进行组合

 public class FirewallAttribute : ActionFilterAttribute { public ICacheManager<int> CacheManager { get; set; } public IFirewallRepoter FirewallRepoter { get; set; } /// <inheritdoc />  public override void OnActionExecuting(ActionExecutingContext context) { var request = context.HttpContext.Request; var ip = context.HttpContext.Connection.RemoteIpAddress.MapToIPv4().ToString(); var trueip = request.Headers["CF-Connecting-IP"].ToString(); if (!string.IsNullOrEmpty(trueip) && ip != trueip) { context.Result = new BadRequestObjectResult("客户端请求不合法,伪造IP:" + ip); return; } var path = HttpUtility.UrlDecode(request.Path + request.QueryString, Encoding.UTF8); var lines = File.ReadAllLines(Path.Combine(AppContext.BaseDirectory + "App_Data", "DenyIPRange.txt"), Encoding.UTF8); var denyIPRange = new Dictionary<string, string>(); foreach (string line in lines) { try { var strs = line.Split(' '); denyIPRange[strs[0]] = strs[1]; } catch (IndexOutOfRangeException) { } } if (new[] { "1.2.3.4", "5.6.7.8" }.Contains(ip) || denyIPRange.AsParallel().Any(kv => kv.Key.StartsWith(ip.Split('.')[0]) && ip.IpAddressInRange(kv.Key, kv.Value))) { // todo:记录日志  context.Result = new BadRequestObjectResult("您当前所在的网络环境不支持访问本站!"); return; } if (context.Filters.Any(m => m.ToString().Contains(nameof(AllowAccessFirewallAttribute)))) { return; } if (IsInDenyArea(ip)) { // todo:记录日志  throw new AccessDenyException("访问地区限制"); } if (Regex.Match(path ?? "", "彩票|办证|AV女优").Length > 0) { // todo:记录拦截日志  context.Result = new BadRequestObjectResult("参数不合法!"); return; } if (Regex.IsMatch(request.Method, "OPTIONS|HEAD", RegexOptions.IgnoreCase)) { return; } var times = CacheManager.AddOrUpdate("Frequency:" + ip, 1, i => i + 1, 5); CacheManager.Expire("Frequency:" + ip, ExpirationMode.Sliding, TimeSpan.FromSeconds(1)); var limit = 300; if (times <= limit) { return; } if (times > limit * 1.2) { CacheManager.Expire("Frequency:" + ip, ExpirationMode.Sliding, TimeSpan.FromMinutes(1)); // todo:记录日志  } if (times > limit * 1.5) { FirewallRepoter.ReportAsync(IPAddress.Parse(ip)).ConfigureAwait(false); } throw new TempDenyException("访问频次限制"); } private static readonly DbSearcher Searcher = new DbSearcher(Path.Combine(AppContext.BaseDirectory + "App_Data", "ip2region.db")); /// <summary>  /// 是否是禁区  /// </summary>  /// <param name="ip"></param>  /// <returns></returns>  public static bool IsInDenyArea(string ip) { var pos = Searcher.MemorySearch(ip).Region; return pos.Contains("北京"); } } public class AccessDenyException : Exception { public AccessDenyException(string msg) : base(msg) { } } public class TempDenyException : Exception { public TempDenyException(string msg) : base(msg) { } }  [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class AllowAccessFirewallAttribute : Attribute, IFilterFactory, IOrderedFilter { public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) { return new AllowAccessFirewallAttribute(); } public bool IsReusable => true; public int Order { get; } } 

配置依赖注入

Startup.cs配置Autofac:

public void ConfigureServices(IServiceCollection services) { // 配置CacheManager  services.AddSingleton(typeof(ICacheManager<>), typeof(BaseCacheManager<>)); services.AddSingleton(new ConfigurationBuilder().WithJsonSerializer().WithMicrosoftMemoryCacheHandle().WithExpiration(ExpirationMode.Absolute, TimeSpan.FromMinutes(5)).Build()); services.AddMvc().AddControllersAsServices().AddViewComponentsAsServices().AddTagHelpersAsServices();// 让Controller支持属性注入  services.Configure<ForwardedHeadersOptions>(options => { options.ForwardLimit = null;// 限制所处理的标头中的条目数  options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; // X-Forwarded-For:保存代理链中关于发起请求的客户端和后续代理的信息。X-Forwarded-Proto:原方案的值 (HTTP/HTTPS)  options.KnownNetworks.Clear(); // 从中接受转接头的已知网络的地址范围。 使用无类别域际路由选择 (CIDR) 表示法提供 IP 范围。使用CDN时应清空  options.KnownProxies.Clear(); // 从中接受转接头的已知代理的地址。 使用 KnownProxies 指定精确的 IP 地址匹配。使用CDN时应清空  }); switch (configuration["FirewallService:type"]) { case "Cloudflare": case "cloudflare": case "cf": services.AddHttpClient<IFirewallRepoter, CloudflareRepoter>().ConfigureHttpClient(c => { c.DefaultRequestHeaders.Add("X-Auth-Email", configuration["FirewallService:Cloudflare:AuthEmail"]); c.DefaultRequestHeaders.Add("X-Auth-Key", configuration["FirewallService:Cloudflare:AuthKey"]); }); break; default: services.AddSingleton<IFirewallRepoter, DefaultFirewallRepoter>(); break; } } public void ConfigureContainer(ContainerBuilder builder) { builder.RegisterModule(new AutofacModule()); } public void Configure(IApplicationBuilder app, IHostingEnvironment env) { app.UseForwardedHeaders().UseCertificateForwarding(); // 转发请求头和证书  app.UseStaticFiles(); app.UseMvc(); } public class AutofacModule : Autofac.Module { protected override void Load(ContainerBuilder builder) { builder.RegisterType<FirewallAttribute>().PropertiesAutowired().AsSelf().InstancePerDependency(); } } 

给任意的Controller或Action打上Firewall标记:

 public class HomeController : Controller
    {
        [Firewall] // 防火墙规则内
        public IActionResult Index()
        {
            return View();
        }
        [AllowAccessFirewall] // 防火墙规则外
        public ActionResult Error()
        {
            return View();
        }
    } 

一个简单的web防火墙实现完毕,本站防火墙源码:

拦截效果:


参考

Autofac依赖注入:

异常统一拦截:

Masuit.Tools:

ip2region:

CacheManager:

配置 ASP.NET Core 以使用代理服务器和负载均衡器:

转自原文: