From 05901b5673e63dc3733bfe3044ff0869e7a1cdcc Mon Sep 17 00:00:00 2001 From: sdhfsl Date: Sat, 19 Sep 2026 10:54:23 +0800 Subject: [PATCH] fix(security): guard subscription/message regex filters against ReDoS (#10185) Filter patterns (subscription subFilter, policy-group Filter, message MsgFilter) accept arbitrary user input while the tested text (remarks from subscriptions, log lines) is attacker-influenced. Regex.IsMatch without timeout hangs on evil patterns like (a+)+$ - a malicious subscription can freeze the UI/log pipeline on every update. Add Utils.IsRegexMatch with a 2s timeout; fail open (match) with a log so no node or message is silently dropped. Apply to all four call sites. Co-authored-by: sdhfsl --- .../Helper/RegexGuardTests.cs | 36 +++++++++++++++++++ v2rayN/ServiceLib/Common/Utils.cs | 34 ++++++++++++++++++ v2rayN/ServiceLib/Handler/ConfigHandler.cs | 4 +-- .../ServiceLib/Manager/GroupProfileManager.cs | 2 +- v2rayN/ServiceLib/ViewModels/MsgViewModel.cs | 2 +- 5 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 v2rayN/ServiceLib.Tests/Helper/RegexGuardTests.cs 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; }