diff --git a/CloudflareIpModule.cs b/CloudflareIpModule.cs index 3461c89..45f8311 100644 --- a/CloudflareIpModule.cs +++ b/CloudflareIpModule.cs @@ -4,42 +4,26 @@ using System.Configuration; using NetTools; using System.Net; +using System.Collections.Generic; using System.Diagnostics; -using System.Threading; namespace CloudflareProxyTrust { public class CloudflareIpModule : IHttpModule { - private static string[] _cfips; - private static Timer _timer; + private List _cfips = new List(); + private DateTime _lastupdated = DateTime.MinValue; + private int count = 0; - #region IHttpModule implementation - - public void Dispose() - { - if (_timer != null) - { - _timer.Dispose(); - _timer = null; - } - - if (_cfips != null) - { - _cfips = null; - } - } + public void Dispose() { } public void Init(HttpApplication context) { context.BeginRequest += new EventHandler(Begin); LoadCfipsData(); - - // Start a timer to reload cfips data periodically - int reloadInterval = GetReloadInterval(); // Get the reload interval from configuration - if(_timer == null) - _timer = new Timer(ReloadCfipsData, null, TimeSpan.Zero, TimeSpan.FromHours(reloadInterval)); + _lastupdated = DateTime.Now; + WriteDbg($"Init {_lastupdated}"); } public void Begin(Object source, EventArgs e) @@ -48,76 +32,113 @@ public void Begin(Object source, EventArgs e) { HttpApplication app = (HttpApplication)source; HttpRequest request = app.Context.Request; - - if(_cfips == null) - LoadCfipsData(); - - // we may not be proxied, or the path may not be configured for this site - if (_cfips == null || - string.IsNullOrEmpty(request.ServerVariables["HTTP_CF_CONNECTING_IP"])) + // stop the loop caused by defaultdocumentmodule + if (!string.IsNullOrEmpty(request.ServerVariables["CF_SECURE"])) { + WriteDbg("we handled this already"); return; } + // maybe we arent proxied + var cfConnectingIp = request.ServerVariables["HTTP_CF_CONNECTING_IP"]; + if(AllowNonProxyRemote) + if (string.IsNullOrEmpty(cfConnectingIp)) + return; + + // or maybe we just havent been configured for this site yet + if (_cfips.Count == 0) + return; + + // perhaps iis has been reusing this instance for some time + // since we have no idea how many times or for how long IIS may reuse an instance, we will keep track of when we last loaded the list + if (_lastupdated.AddMinutes(20) < DateTime.Now) + { + WriteDbg($"refreshing iplist {request.UserHostAddress}"); + LoadCfipsData(); + } +#if DEBUG + else + // some verification for our debug logging that we are indeed reusing instances across requests + count++; +#endif IPAddress remoteaddr = IPAddress.Parse(request.ServerVariables["REMOTE_ADDR"]); - string cf_connecting_ip = request.ServerVariables["HTTP_CF_CONNECTING_IP"]; bool trusted = false; - foreach (string cfip in _cfips) + foreach (IPAddressRange cfip in _cfips) { - // Dont try to parse empty lines - if (string.IsNullOrWhiteSpace(cfip)) - continue; - - IPAddressRange IPRange = IPAddressRange.Parse(cfip); - if (IPRange.Contains(remoteaddr)) + if (cfip.Contains(remoteaddr)) { trusted = true; - // only if trusted we will forward the real IP in REMOTE_ADDR - request.ServerVariables.Set("REMOTE_ADDR", cf_connecting_ip); + // only if trusted, forward the real IP in REMOTE_ADDR + request.ServerVariables.Set("REMOTE_ADDR", cfConnectingIp); + // maybe you want the cloudflare IP? keep it here request.ServerVariables.Set("HTTP_X_ORIGINAL_ADDR", remoteaddr.ToString()); + + // we cant rely on checking if X-Original-Addr exists since a malicious client could send this + // it is also useful to stop the defaultdocumentmodule looping + request.ServerVariables.Set("CF_SECURE", "true"); } } -#if DEBUG - Debug.WriteLine($"[CloudflareProxyTrust]: trusted: {trusted.ToString()} remoteaddr: {remoteaddr} cfip: {cf_connecting_ip} url: {request.RawUrl} host: {request.ServerVariables["HTTP_HOST"]}"); -#endif + if(!trusted) + { + // spoofing attempt + request.ServerVariables.Set("CF_SECURE", "false"); + // this ends up logged twice, unsure how to prevent this one + EventLog.WriteEntry(".NET Runtime", + $"[CloudflareProxyTrust]: possible spoofing attempt\r\n" + + $"ip:[{request.ServerVariables["REMOTE_ADDR"]}]\r\n" + + $"host:[{request.ServerVariables["SERVER_NAME"]}]\r\n" + + $"url:[{request.RawUrl}]\r\n" + + $"useragent:[{request.UserAgent}]", + EventLogEntryType.Information, + 1000 + ); + } + if (!trusted && DenyUntrusted) + { + app.Response.StatusCode = DenyCode; + app.Response.StatusDescription = DenyDescription; + app.Response.Flush(); + app.CompleteRequest(); + } + + WriteDbg($"trusted: {trusted} remoteaddr: {remoteaddr} cfip: {cfConnectingIp} url: {request.RawUrl} host: {request.ServerVariables["HTTP_HOST"]} reused {count} times"); } catch (Exception ex) { -#if DEBUG - Debug.WriteLine("[CloudflareProxyTrust]: " + ex.Message); -#endif + WriteDbg("Exception occured " + ex.Message); } } - private static void LoadCfipsData() - { - var path = ConfigurationManager.AppSettings["CF_IP_Path"]; + // this one to deny if untrusted + private static bool DenyUntrusted => Convert.ToBoolean(ConfigurationManager.AppSettings["CF_DenyUntrusted"]); + // this one to allow direct connections to the server, only has effect if were denying untrusted. + private static bool AllowNonProxyRemote => Convert.ToBoolean(ConfigurationManager.AppSettings["CF_AllowNonProxyRemote"] ?? "true"); - if (!string.IsNullOrEmpty(path)) - { - // Read lines into string array and store in cfips - _cfips = File.ReadAllLines(path); - } - } + private static int DenyCode => Convert.ToInt16(ConfigurationManager.AppSettings["CF_DenyCode"] ?? "400"); + + private static string DenyDescription => ConfigurationManager.AppSettings["CF_DenyDescription"] ?? "Bad Request"; - private static int GetReloadInterval() + private void LoadCfipsData() { - // Get the reload interval from configuration, default to 24 hours - int interval; - if (!int.TryParse(ConfigurationManager.AppSettings["CF_IP_ReloadInterval"], out interval)) + var path = ConfigurationManager.AppSettings["CF_IP_Path"]; + if (!string.IsNullOrEmpty(path) && File.Exists(path)) { - interval = 24; // Default reload interval (in hours) + string[] filelines = File.ReadAllText(path).Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + foreach (string line in filelines) + { + if (IPAddressRange.TryParse(line, out IPAddressRange ipRange)) + _cfips.Add(ipRange); + } } - return interval; } - private static void ReloadCfipsData(object state) + private static void WriteDbg(string msg) { - // Reload cfips data periodically - LoadCfipsData(); +#if DEBUG + Debug.WriteLine($"[CloudflareProxyTrust]: {msg}"); +#endif } - #endregion } } diff --git a/CloudflareProxyTrust.csproj b/CloudflareProxyTrust.csproj index c1b5797..e884d4d 100644 --- a/CloudflareProxyTrust.csproj +++ b/CloudflareProxyTrust.csproj @@ -62,6 +62,7 @@ + diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs index 354d4bf..d2becb5 100644 --- a/Properties/AssemblyInfo.cs +++ b/Properties/AssemblyInfo.cs @@ -10,7 +10,7 @@ [assembly: AssemblyConfiguration("")] [assembly: AssemblyCompany("")] [assembly: AssemblyProduct("CloudflareProxyTrust")] -[assembly: AssemblyCopyright("Copyright © 2023")] +[assembly: AssemblyCopyright("Copyright © kimboslice99 2025")] [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] [assembly: AssemblyKeyFile("CloudflareProxyTrust.snk")] @@ -33,5 +33,5 @@ // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("1.0.0.0")] -[assembly: AssemblyFileVersion("1.0.0.0")] +[assembly: AssemblyVersion("1.1.0.0")] +[assembly: AssemblyFileVersion("1.1.0.0")] diff --git a/README.md b/README.md index 2c05aa0..e6564cf 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,11 @@ This module addresses security concerns related to Cloudflare by ensuring that t ## Purpose -Utilizing URL rewrite to replace `REMOTE_ADDR` with `HTTP_CF_CONNECTING_IP` might seem like a solution. However, it's important to note that `Cf-Connecting-Ip` is a header that can be spoofed by anyone. Therefore, it should only be relied upon when the source is verifiable. +Utilizing URL rewrite to replace `REMOTE_ADDR` with `HTTP_CF_CONNECTING_IP` might seem like a solution. However, it's important to note that `CF-Connecting-IP` is a header that can be spoofed by anyone. Therefore, it should only be relied upon when the source is verifiable. -This module serves to replace the `REMOTE_ADDR` variable with `HTTP_CF_CONNECTING_IP` exclusively if the request originates from a trusted IP address. +This module serves to replace the `REMOTE_ADDR` variable with `HTTP_CF_CONNECTING_IP` exclusively if the request originates from a trusted IP address. + +Optionally you may configure this module to allow only connections through CF proxy, or to just block any spoofing attempts. ## Setup @@ -17,6 +19,7 @@ This module serves to replace the `REMOTE_ADDR` variable with `HTTP_CF_CONNECTIN Run the following script, perhaps scheduled to execute once a day, to download Cloudflare IPs and save them into a file: ```powershell + # note that the use of -UseBasicParsing allows limited user accounts to run irm/iwr! $v4 = irm https://www.cloudflare.com/ips-v4 -UseBasicParsing $v6 = irm https://www.cloudflare.com/ips-v6 -UseBasicParsing "$v4`n$v6" | Out-File -NoNewLine CF_IPs.txt @@ -25,7 +28,13 @@ Run the following script, perhaps scheduled to execute once a day, to download C ## Deployment - For deployment to a single site, copy `CloudflareProxyTrust.dll` into your bin folder. - - For deployment to the entire webserver, add the DLL to the Global Assembly Cache (GAC) and `%SystemDrive%\Windows\System32\inetsrv`. + - For deployment to the entire webserver, add the DLL to the Global Assembly Cache (GAC). You can do so with gacutil or the following (admin) powershell commands. +```powershell +$dllpath = "C:\full\path\to\CloudflareProxyTrust.dll" +[System.Reflection.Assembly]::Load("System.EnterpriseServices, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a") | Out-Null +$publish = New-Object System.EnterpriseServices.Internal.Publish +$publish.GacInstall($dllpath) +``` **Configure IIS** @@ -34,11 +43,33 @@ Run the following script, perhaps scheduled to execute once a day, to download C ```xml - + ``` -**Application Setting** +**Settings** + +Create an Application Setting key named `CF_IP_Path` pointing to the file containing Cloudflare's proxy IPs, and ensure IIS_IUSRS has read access to this file. This is the only required setting for the module to function. + +All of the following configuation choices set REMOTE_ADDR, but only some of them block. +Note that for CF_AllowNonProxyRemote=false to function CF_DenyUntrusted must be true + +Scenario 1. (blocking mode) + +You have a site that is proxied, and it should be the only route a legitimate client takes +Set CF_DenyUntrusted to true and CF_AllowNonProxyRemote to false. This will block all clients that arent coming through the CF proxy. + +Scenario 2. (blocking mode) + +You have a site that is proxied but also has an unproxied name, so clients can utilize CF or not if chosen. +You wish to block any request that comes with CF headers that is not a CF proxy IP (spoofing attempt). +Set CF_DenyUntrusted to true and CF_AllowNonProxyRemote to true + +Scenario 3. (default/silent/non-blocking mode) + +You have a site that is proxied and may have unproxied names. +You would like to handle REMOTE_ADDR replacement silently, if it exists and is trusted. +Set CF_DenyUntrusted to false and CF_AllowNonProxyRemote to true (these are the defaults) - Create an Application Setting key named `CF_IP_Path` pointing to the file containing Cloudflare's proxy IPs. +Application setting key CF_DenyCode and CF_DenyDescription will allow you to customize the deny response from the default 400 Bad Request.