发布: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家的更精确的离线数据库。
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,也直接Block掉:
if (new[] { "1.2.3.4", "5.6.7.8" }.Contains(ip))
{
// todo:记录日志
context.Result = new BadRequestObjectResult("您当前所在的网络环境不支持访问本站!");
return;
}
同样有一个专门存储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;
}
下一步,限流。
我们需要记录每个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或上游防火墙进行永久封禁。
本教程以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个规则的防火墙代码已经完整实现,不过,有些页面或接口会不受防火墙规则约束,所以我们让这些接口跳过防火墙。
自定义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; }
我们再将上面所有的规则梳理,并按一定的优先级进行组合
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(); } }
public class HomeController : Controller
{
[Firewall] // 防火墙规则内
public IActionResult Index()
{
return View();
}
[AllowAccessFirewall] // 防火墙规则外
public ActionResult Error()
{
return View();
}
}
一个简单的web防火墙实现完毕,本站防火墙源码:
拦截效果:
Autofac依赖注入:
异常统一拦截:
Masuit.Tools:
ip2region:
CacheManager:
配置 http://ASP.NET Core 以使用代理服务器和负载均衡器:
转自原文:
© Copyright 2014 - 2024 柏港建站平台 ejk5.com. 渝ICP备16000791号-4