diff --git a/v2rayN/ServiceLib.Tests/Helper/RegexGuardTests.cs b/v2rayN/ServiceLib.Tests/Helper/RegexGuardTests.cs new file mode 100644 index 00000000..261599c4 --- /dev/null +++ b/v2rayN/ServiceLib.Tests/Helper/RegexGuardTests.cs @@ -0,0 +1,36 @@ +namespace ServiceLib.Tests.Helper; + +public class RegexGuardTests +{ + [Test] + public async Task IsRegexMatch_NormalPattern_ShouldMatch() + { + await Utils.IsRegexMatch("HK-node-01", "HK|香港").Should().BeTrue(); + await Utils.IsRegexMatch("JP-node-01", "HK|香港").Should().BeFalse(); + } + + [Test] + public async Task IsRegexMatch_EmptyPattern_ShouldPassThrough() + { + await Utils.IsRegexMatch("anything", "").Should().BeTrue(); + await Utils.IsRegexMatch("anything", null).Should().BeTrue(); + } + + [Test] + public async Task IsRegexMatch_InvalidPattern_ShouldFailOpen() + { + await Utils.IsRegexMatch("node-01", "([unclosed").Should().BeTrue(); + } + + [Test] + public async Task IsRegexMatch_EvilPattern_ShouldTimeoutAndFailOpen() + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + var result = Utils.IsRegexMatch(new string('a', 30) + "!", "(a+)+$"); + sw.Stop(); + + await result.Should().BeTrue(); + await (sw.Elapsed < TimeSpan.FromSeconds(30)).Should().BeTrue().Because( + $"evil pattern must be cut off by timeout, took {sw.Elapsed}"); + } +} diff --git a/v2rayN/ServiceLib/Common/Utils.cs b/v2rayN/ServiceLib/Common/Utils.cs index daecda93..8a2e4c8b 100644 --- a/v2rayN/ServiceLib/Common/Utils.cs +++ b/v2rayN/ServiceLib/Common/Utils.cs @@ -726,6 +726,40 @@ public class Utils return false; } + /// + /// Regex match with a timeout guard. Filter patterns can come from user + /// input or subscription content while the tested text (remarks, log + /// messages) is attacker-influenced, so an evil pattern like (a+)+$ + /// would otherwise hang the caller (ReDoS). On timeout or invalid + /// pattern, fail open (return true) so no node/message is silently + /// dropped; the incident is logged. + /// + public static bool IsRegexMatch(string? input, string? pattern, int timeoutSeconds = 2) + { + if (pattern.IsNullOrEmpty()) + { + return true; + } + if (input.IsNullOrEmpty()) + { + return false; + } + try + { + return Regex.IsMatch(input, pattern, RegexOptions.None, TimeSpan.FromSeconds(timeoutSeconds)); + } + catch (RegexMatchTimeoutException ex) + { + Logging.SaveLog("IsRegexMatch timeout", ex); + return true; + } + catch (ArgumentException ex) + { + Logging.SaveLog("IsRegexMatch invalid pattern", ex); + return true; + } + } + #endregion Data Checks #region Speed Test diff --git a/v2rayN/ServiceLib/Handler/ConfigHandler.cs b/v2rayN/ServiceLib/Handler/ConfigHandler.cs index 94b87f10..b9b27dbb 100644 --- a/v2rayN/ServiceLib/Handler/ConfigHandler.cs +++ b/v2rayN/ServiceLib/Handler/ConfigHandler.cs @@ -1528,7 +1528,7 @@ public static class ConfigHandler p != null && p.IsValid() && (!p.ConfigType.IsComplexType() || p.ConfigType == EConfigType.Outbound) && - (extraItem.Filter.IsNullOrEmpty() || Regex.IsMatch(p.Remarks, extraItem.Filter)) + Utils.IsRegexMatch(p.Remarks, extraItem.Filter) ) .ToList() ?? []; if (matchedChildProfiles.Count == 0) @@ -1667,7 +1667,7 @@ public static class ConfigHandler //exist sub items //filter if (isSub && subid.IsNotEmpty() && subFilter.IsNotEmpty()) { - if (!Regex.IsMatch(profileItem.Remarks, subFilter)) + if (!Utils.IsRegexMatch(profileItem.Remarks, subFilter)) { continue; } diff --git a/v2rayN/ServiceLib/Manager/GroupProfileManager.cs b/v2rayN/ServiceLib/Manager/GroupProfileManager.cs index 0a6f711b..97cbec78 100644 --- a/v2rayN/ServiceLib/Manager/GroupProfileManager.cs +++ b/v2rayN/ServiceLib/Manager/GroupProfileManager.cs @@ -119,7 +119,7 @@ public class GroupProfileManager p != null && p.IsValid() && (!p.ConfigType.IsComplexType() || p.ConfigType == EConfigType.Outbound) && - (extra.Filter.IsNullOrEmpty() || Regex.IsMatch(p.Remarks, extra.Filter)) + Utils.IsRegexMatch(p.Remarks, extra.Filter) ) .ToList() ?? []; } diff --git a/v2rayN/ServiceLib/ViewModels/MsgViewModel.cs b/v2rayN/ServiceLib/ViewModels/MsgViewModel.cs index 5f244766..2a1b2fea 100644 --- a/v2rayN/ServiceLib/ViewModels/MsgViewModel.cs +++ b/v2rayN/ServiceLib/ViewModels/MsgViewModel.cs @@ -77,7 +77,7 @@ public partial class MsgViewModel : MyReactiveObject { try { - if (!Regex.IsMatch(msg, MsgFilter)) + if (!Utils.IsRegexMatch(msg, MsgFilter)) { return; }