Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash",
"Powershell",
"Python",
"Write",
"Edit",
"MultiEdit"
],
"defaultMode": "dontAsk"
}
}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ bin/
obj/
package/
packages/
results/
report/
.nuget-packages/
*.suo
*.cachefile
*.user
Expand Down
5 changes: 4 additions & 1 deletion IPBan/IPBan.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
<ServerGarbageCollection>false</ServerGarbageCollection>
<TrimMode>partial</TrimMode>
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
<!-- IL2104: third-party assemblies produce trim warnings internally; suppressed as unfixable -->
<!-- IL2026: runtime-internal COM activation (BuiltInComInteropSupport) is not trim-compatible; unfixable from user code -->
<NoWarn>$(NoWarn);IL2104;IL2026</NoWarn>
</PropertyGroup>

<ItemGroup>
Expand All @@ -45,6 +48,6 @@
<TrimmerRootAssembly Include="System.Runtime" />
<TrimmerRootAssembly Include="mscorlib" />
<TrimmerRootAssembly Include="netstandard" />
</ItemGroup>
</ItemGroup>

</Project>
22 changes: 18 additions & 4 deletions IPBanCore/Core/IPBan/IPBanConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ namespace DigitalRuby.IPBanCore
/// <summary>
/// Configuration for ip ban app
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Configuration XML models are runtime-deserialized and preserved by IPBanCore usage patterns.")]
public sealed class IPBanConfig : IIsWhitelisted
{
/// <summary>
Expand Down Expand Up @@ -134,6 +135,7 @@ public void Dispose()
private readonly string processToRunOnUnban = string.Empty;
private readonly bool useDefaultBannedIPAddressHandler;
private readonly string getUrlUpdate = string.Empty;
private readonly string getUrlUpdateSha256 = string.Empty;
private readonly string getUrlStart = string.Empty;
private readonly string getUrlStop = string.Empty;
private readonly string getUrlConfig = string.Empty;
Expand Down Expand Up @@ -238,6 +240,7 @@ private IPBanConfig(XmlDocument doc, IDnsLookup dns = null, IDnsServerList dnsLi
TryGetConfig<int>("UserNameWhitelistMinimumEditDistance", ref userNameWhitelistMaximumEditDistance);
TryGetConfig<int>("FailedLoginAttemptsBeforeBanUserNameWhitelist", ref failedLoginAttemptsBeforeBanUserNameWhitelist);
TryGetConfig<string>("GetUrlUpdate", ref getUrlUpdate);
TryGetConfig<string>("GetUrlUpdateSha256", ref getUrlUpdateSha256);
TryGetConfig<string>("GetUrlStart", ref getUrlStart);
TryGetConfig<string>("GetUrlStop", ref getUrlStop);
TryGetConfig<string>("GetUrlConfig", ref getUrlConfig);
Expand All @@ -260,16 +263,19 @@ private string GetAppSettingsValue(string key, bool logMissing = true)
{
if (string.IsNullOrWhiteSpace(key))
{
// bad key
Logger.Warn("Ignoring null/empty key");
if (logMissing)
{
// bad key
Logger.Debug("Ignoring null/empty key");
}
return null;
}

if (!appSettings.TryGetValue(key, out var stringValue) || stringValue is null)
{
if (logMissing)
{
Logger.Warn("Ignoring key {0}, not found in appSettings", key);
Logger.Debug("Ignoring key {0}, not found in appSettings", key);
}
return null; // skip trying to convert
}
Expand Down Expand Up @@ -638,7 +644,7 @@ public bool IsUserNameWithinMaximumEditDistanceOfUserNameWhitelist(string userNa
foreach (string userNameToCheckAgainst in userNameWhitelist)
{
int distance = LevenshteinUnsafe.Distance(userName, userNameToCheckAgainst);
if (distance <= userNameWhitelistMaximumEditDistance)
if (distance >= 0 && distance <= userNameWhitelistMaximumEditDistance)
{
return true;
}
Expand Down Expand Up @@ -1152,6 +1158,14 @@ public static string ValidateFirewallUriRules(string firewallUriRules)
/// </summary>
public string GetUrlUpdate { get { return getUrlUpdate; } }

/// <summary>
/// Expected SHA-256 hash (hex, case-insensitive) of the binary returned by GetUrlUpdate.
/// If empty, the auto-update download is fetched but NOT executed β€” this is the safe default
/// and protects against a malicious/MITMed update server. Operators must explicitly set this
/// hash to opt in to automated update execution.
/// </summary>
public string GetUrlUpdateSha256 { get { return getUrlUpdateSha256; } }

/// <summary>
/// A url to get when the service starts, empty for none. See ReplaceUrl of IPBanService for place-holders.
/// </summary>
Expand Down
20 changes: 13 additions & 7 deletions IPBanCore/Core/IPBan/IPBanDB.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,18 +139,24 @@ private static long GetInt64(object value)
/// </summary>
public static IPAddressEntry ParseIPAddressEntry(SqliteDataReader reader)
{
string ipAddress = reader.GetString(0);
long lastFailedLogin = reader.GetInt64(1);
long failedLoginCount = reader.GetInt64(2);
// Older DB schemas (created before BanEndDate / UserName / Source columns were added)
// can return NULL even though the column declarations now have defaults β€” read with
// IsDBNull guards so a stale schema doesn't crash every query that hits a legacy row.
string ipAddress = reader.IsDBNull(0) ? string.Empty : reader.GetString(0);
long lastFailedLogin = reader.IsDBNull(1) ? 0L : reader.GetInt64(1);
long failedLoginCount = reader.IsDBNull(2) ? 0L : reader.GetInt64(2);
object banDateObj = reader.GetValue(3);
IPAddressState state = (IPAddressState)(int)reader.GetInt32(4);
IPAddressState state = reader.IsDBNull(4) ? IPAddressState.Active : (IPAddressState)(int)reader.GetInt32(4);
object banEndDateObj = reader.GetValue(5);
string userName = reader.GetString(6);
string source = reader.GetString(7);
string userName = reader.IsDBNull(6) ? string.Empty : reader.GetString(6);
string source = reader.IsDBNull(7) ? string.Empty : reader.GetString(7);
long banDateLong = GetInt64(banDateObj);
long banEndDateLong = GetInt64(banEndDateObj);
DateTime? banDate = (banDateLong == 0 ? (DateTime?)null : banDateLong.ToDateTimeUnixMilliseconds());
DateTime? banEndDate = (banDateLong == 0 ? (DateTime?)null : banEndDateLong.ToDateTimeUnixMilliseconds());
// Each ban-date column is independent: BanDate may be set while BanEndDate is NULL
// (legacy rows from before BanEndDate existed, or in-flight transitions). Each guard
// must check its own column.
DateTime? banEndDate = (banEndDateLong == 0 ? (DateTime?)null : banEndDateLong.ToDateTimeUnixMilliseconds());
DateTime lastFailedLoginDt = lastFailedLogin.ToDateTimeUnixMilliseconds();
return new IPAddressEntry
{
Expand Down
7 changes: 7 additions & 0 deletions IPBanCore/Core/IPBan/IPBanFirewallUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.IO.Pipelines;
using System.Linq;
Expand Down Expand Up @@ -57,6 +58,7 @@ private static void AppendRange(StringBuilder b, PortRange range)
/// <param name="rulePrefix">Rule prefix or null for default</param>
/// <param name="previousFirewall">Current firewall</param>
/// <returns>Firewall</returns>
[UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "Firewall implementations are selected and activated dynamically at runtime by design.")]
public static IIPBanFirewall CreateFirewall(IReadOnlyCollection<Type> allTypes,
string rulePrefix = null,
IIPBanFirewall previousFirewall = null)
Expand Down Expand Up @@ -611,6 +613,11 @@ public static int RunProcess(string program, object input, object output, params
inputStream.CopyTo(p.StandardInput.BaseStream);
}
}
catch (IOException)
{
// the process may have already exited and closed stdin (broken pipe);
// feeding stdin is best-effort, so ignore the write failure
}
finally
{
try { p.StandardInput.Close(); } catch { /* ignore */ }
Expand Down
22 changes: 17 additions & 5 deletions IPBanCore/Core/IPBan/IPBanIPThreatUploader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;

Expand Down Expand Up @@ -31,6 +32,7 @@ public void Dispose()
}

/// <inheritdoc />
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Anonymous payload shape is fixed and used only for IPThreat API upload.")]
public async Task Update(CancellationToken cancelToken = default)
{
// ready to run?
Expand Down Expand Up @@ -104,12 +106,22 @@ await service.RequestMaker.MakeRequestAsync(ipThreatReportApiUri,
/// <inheritdoc />
public void AddIPAddressLogEvents(IEnumerable<IPAddressLogEvent> events)
{
lock (events)
// Run the filter outside the lock β€” the predicate calls into service.Config which we
// don't want to hold the events lock across. Only the AddRange happens inside.
var filtered = events.Where(e => e.Type == IPAddressEventType.Blocked &&
e.Count > 0 &&
!e.External &&
!service.Config.IsWhitelisted(e.IPAddress, out _)).ToArray();
if (filtered.Length == 0)
{
return;
}
// Qualify with `this.` so the lock targets the field β€” the parameter is also named
// `events` and would otherwise shadow it, locking an unrelated caller-supplied object
// while the field itself stayed unprotected.
lock (this.events)
{
this.events.AddRange(events.Where(e => e.Type == IPAddressEventType.Blocked &&
e.Count > 0 &&
!e.External &&
!service.Config.IsWhitelisted(e.IPAddress, out _)));
this.events.AddRange(filtered);
}
}
}
1 change: 0 additions & 1 deletion IPBanCore/Core/IPBan/IPBanLogManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ public IPBanLogManager(IIPBanService service)
/// <inheritdoc />
public Task Update(CancellationToken cancelToken)
{
UpdateLogFiles(service.Config);
if (service.ManualCycle)
{
foreach (var scanner in logsToParse)
Expand Down
2 changes: 1 addition & 1 deletion IPBanCore/Core/IPBan/IPBanMemoryFirewall.cs
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,7 @@ public override string GetPorts(string ruleName)
{
return ruleRanges.Ports;
}
else if (!allowRuleRanges.TryGetValue(ruleName, out ruleRanges))
else if (allowRuleRanges.TryGetValue(ruleName, out ruleRanges))
{
return ruleRanges.Ports;
}
Expand Down
13 changes: 10 additions & 3 deletions IPBanCore/Core/IPBan/IPBanService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,12 @@ public void AddIPAddressLogEvents(IEnumerable<IPAddressLogEvent> events)
}

/// <summary>
/// Write a new config file
/// Write a new config file. Virtual so tests can intercept the call without touching
/// the on-disk config.
/// </summary>
/// <param name="xml">Xml of the new config file</param>
/// <returns>Task</returns>
public async Task WriteConfigAsync(string xml)
public virtual async Task WriteConfigAsync(string xml)
{
// Ensure valid xml before writing the file
XmlDocument doc = new();
Expand Down Expand Up @@ -488,6 +489,13 @@ public static T CreateAndStartIPBanTestService<T>(string directory = null, strin
ExtensionMethods.FileWriteAllTextWithRetry(configFileOverridePath, configFileOverrideText);
T service = IPBanService.CreateService<T>();
service.ConfigFilePath = configFilePath;
service.ConfigReaderWriter.UseFile = false;
service.ConfigReaderWriter.GlobalConfigString = configFileText;
service.ConfigOverrideReaderWriter.UseFile = false;
service.ConfigOverrideReaderWriter.GlobalConfigString = configFileOverrideText;
service.LocalIPAddressString = "127.0.0.1";
service.RemoteIPAddressString = "127.0.0.1";
service.OtherIPAddressesString = "127.0.0.1";
service.MultiThreaded = false;
service.ManualCycle = true;
service.DnsList = null; // too slow for tests, turn off
Expand Down Expand Up @@ -552,7 +560,6 @@ public static void DisposeIPBanTestService(IPBanService service)
Directory.Delete(appDataCache, true);
}
service.Firewall.Truncate();
service.RunCycleAsync().Sync();
service.IPBanDelegate = null;
service.Dispose();
IPBanService.CleanupIPBanTestFiles();
Expand Down
67 changes: 57 additions & 10 deletions IPBanCore/Core/IPBan/IPBanService_Private.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
using System.Linq;
using System.Net;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -205,7 +206,10 @@ private async Task SetNetworkInfo(CancellationToken cancelToken)
}

// request new config file
await GetUrl(UrlType.Config, cancelToken);
if (!string.IsNullOrWhiteSpace(Config.GetUrlConfig))
{
await GetUrl(UrlType.Config, cancelToken);
}
}

private async Task ProcessPendingFailedLogins(IReadOnlyList<IPAddressLogEvent> ipAddresses, CancellationToken cancelToken)
Expand Down Expand Up @@ -813,6 +817,17 @@ protected virtual void OnFirewallDisposing() { }
/// <returns>Task</returns>
protected virtual Task OnUpdate(CancellationToken cancelToken) => Task.CompletedTask;

/// <summary>
/// Launch the verified update binary. Virtual so tests can intercept the actual
/// process launch without having to spawn a real subprocess on the host.
/// </summary>
/// <param name="tempFile">Path to the (already-written, hash-verified) update binary.</param>
/// <param name="args">Command-line arguments to pass to the binary.</param>
protected virtual void LaunchUpdateBinary(string tempFile, string args)
{
ProcessUtility.CreateDetachedProcess(tempFile, args);
}

/// <summary>
/// Get url from config
/// </summary>
Expand Down Expand Up @@ -857,14 +872,45 @@ protected virtual async Task<bool> GetUrl(UrlType urlType, CancellationToken can
// if the update url sends bytes, we assume a software update, and run the result as an .exe
if (bytes.Length != 0)
{
var tempFile = Path.Combine(TempFile.TempDirectory, "IPBanServiceUpdate.exe");
File.WriteAllBytes(tempFile, bytes);

// however you are doing the update, you must allow -c and -d parameters
// pass -c to tell the update executable to delete itself when done
// pass -d for a directory which tells the .exe where this service lives
string args = "-c \"-d=" + AppContext.BaseDirectory + "\"";
ProcessUtility.CreateDetachedProcess(tempFile, args);
// Verify the downloaded binary against an operator-configured SHA-256
// hash before executing it. The auto-update channel runs the result as
// a privileged process (service account on Windows, root on Linux), so
// any attacker who can MITM this URL or influence the config-supplied
// GetUrlUpdate value would otherwise get arbitrary code execution. If
// no hash is configured the bytes are skipped β€” execution requires an
// explicit operator opt-in.
string expectedHash = (Config.GetUrlUpdateSha256 ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(expectedHash))
{
Logger.Warn("Auto-update download from {0} skipped β€” no GetUrlUpdateSha256 " +
"is configured. Set GetUrlUpdateSha256 to the SHA-256 hex of the expected " +
"update binary to opt in to automatic execution.", url);
}
else
{
byte[] actualHashBytes = SHA256.HashData(bytes);
string actualHash = Convert.ToHexString(actualHashBytes);
if (!string.Equals(actualHash, expectedHash.Replace(" ", string.Empty),
StringComparison.OrdinalIgnoreCase))
{
Logger.Error("Auto-update download from {0} REJECTED β€” hash mismatch. " +
"Expected {1}, got {2}. The binary will not be executed.",
url, expectedHash, actualHash);
}
else
{
var tempFile = Path.Combine(TempFile.TempDirectory, "IPBanServiceUpdate.exe");
File.WriteAllBytes(tempFile, bytes);

// however you are doing the update, you must allow -c and -d parameters
// pass -c to tell the update executable to delete itself when done
// pass -d for a directory which tells the .exe where this service lives
string args = "-c \"-d=" + AppContext.BaseDirectory + "\"";
Logger.Warn("Auto-update download from {0} verified (sha256 {1}); executing.",
url, actualHash);
LaunchUpdateBinary(tempFile, args);
}
}
}
}
else if (urlType == UrlType.Config && bytes.Length != 0)
Expand All @@ -883,7 +929,8 @@ protected virtual async Task<bool> GetUrl(UrlType urlType, CancellationToken can
private async Task UpdateUpdaters(CancellationToken cancelToken)
{
// hit start url if first time, if not first time will be ignored
if (!(await GetUrl(UrlType.Start, cancelToken)))
if ((!string.IsNullOrWhiteSpace(Config.GetUrlStart) || !string.IsNullOrWhiteSpace(Config.GetUrlUpdate)) &&
!(await GetUrl(UrlType.Start, cancelToken)))
{
// send update
await GetUrl(UrlType.Update, cancelToken);
Expand Down
Loading