This is an automated email from the ASF dual-hosted git repository.

FreeAndNil pushed a commit to branch Feature/security-audit-hardening
in repository https://gitbox.apache.org/repos/asf/logging-log4net.git

commit 394fd3dc8316de38bf013b23b86fa07fbfdf0e71
Author: Jan Friedrich <[email protected]>
AuthorDate: Mon Aug 17 22:41:45 2026 +0200

    bound regular expression matching in the string match filters
    
    RegexToMatch was compiled with Regex.InfiniteMatchTimeout, and the match
    runs while the appender lock is held, so a pattern that backtracks could
    stall everything logging through the appender on some inputs.
    
    Matching now stops after MatchTimeoutMillis, 1000 by default, and 0 restores
    the previous unbounded behaviour. An abandoned match counts as no match, so
    the event is left to the rest of the filter chain rather than having its
    decision changed.
    
    The pattern comes from configuration and is trusted, so this is hardening
    against a pattern that turns out to be expensive, not protection against
    untrusted input.
    
    StringMatchFilter and PropertyFilter both matched the regex themselves, so
    the handling lives in one protected IsRegexMatch used by both, which also
    covers MdcFilter and NdcFilter. It reports an abandoned match once per
    filter rather than once per event, since a warning per event would be a
    problem of its own.
    
    regexToMatch was missing from the manual entirely and is documented now.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---
 .../3.4.0/309-bound-filter-regex-matching.xml      |  17 +++
 src/log4net.Tests/Filter/StringMatchFilterTest.cs  | 128 +++++++++++++++++++++
 src/log4net/Filter/PropertyFilter.cs               |   2 +-
 src/log4net/Filter/StringMatchFilter.cs            |  87 +++++++++++++-
 .../ROOT/pages/manual/configuration/filters.adoc   |  23 ++++
 5 files changed, 252 insertions(+), 5 deletions(-)

diff --git a/src/changelog/3.4.0/309-bound-filter-regex-matching.xml 
b/src/changelog/3.4.0/309-bound-filter-regex-matching.xml
new file mode 100644
index 00000000..5b721997
--- /dev/null
+++ b/src/changelog/3.4.0/309-bound-filter-regex-matching.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<entry xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+       xmlns="https://logging.apache.org/xml/ns";
+       xsi:schemaLocation="https://logging.apache.org/xml/ns 
https://logging.apache.org/xml/ns/log4j-changelog-0.xsd";
+       type="changed">
+  <issue id="309" link="https://github.com/apache/logging-log4net/pull/309"/>
+  <description format="asciidoc">
+    give `RegexToMatch` matching a deadline in `StringMatchFilter` and the 
filters deriving from it,
+ `PropertyFilter`, `MdcFilter` and `NdcFilter`. The pattern was matched with
+ `Regex.InfiniteMatchTimeout` while the appender lock was held, so a pattern 
that backtracks could
+ stall everything logging through the appender on some inputs. Matching now 
stops after
+ `MatchTimeoutMillis`, 1000 by default, and an abandoned match is reported 
once and leaves the event
+ to the rest of the filter chain; 0 restores unbounded matching. The pattern 
comes from
+ configuration and is trusted, so this is hardening rather than a 
vulnerability fix
+ (audit 1231d72-f013)
+  </description>
+</entry>
diff --git a/src/log4net.Tests/Filter/StringMatchFilterTest.cs 
b/src/log4net.Tests/Filter/StringMatchFilterTest.cs
new file mode 100644
index 00000000..ff3eced6
--- /dev/null
+++ b/src/log4net.Tests/Filter/StringMatchFilterTest.cs
@@ -0,0 +1,128 @@
+#region Apache License
+//
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to you under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+#endregion
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+
+using log4net.Core;
+using log4net.Filter;
+using log4net.Util;
+
+using NUnit.Framework;
+
+namespace log4net.Tests.Filter;
+
+/// <summary>
+/// Tests for <see cref="StringMatchFilter"/>
+/// </summary>
+[TestFixture]
+public class StringMatchFilterTest
+{
+  /// <summary>
+  /// A pattern that backtracks, with an input that makes it do so. Matching 
this without a deadline
+  /// runs for longer than any test would wait.
+  /// </summary>
+  private const string CatastrophicPattern = "^(a+)+$";
+
+  private const string CraftedMessage = 
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!";
+
+  /// <summary>
+  /// The match runs while the appender lock is held, so one that backtracks 
has to be abandoned
+  /// rather than holding the thread. The event is then left to the rest of 
the filter chain.
+  /// </summary>
+  [Test]
+  public void AMatchThatBacktracksIsAbandoned()
+  {
+    StringMatchFilter filter = new() { RegexToMatch = CatastrophicPattern, 
MatchTimeoutMillis = 100 };
+    filter.ActivateOptions();
+
+    Stopwatch stopwatch = Stopwatch.StartNew();
+    FilterDecision decision = FilterDecision.Accept;
+    LogLog.ExecuteWithoutEmittingInternalMessages(() => decision = 
filter.Decide(CreateEvent(CraftedMessage)));
+    stopwatch.Stop();
+
+    Assert.That(decision, Is.EqualTo(FilterDecision.Neutral));
+    Assert.That(stopwatch.Elapsed, Is.LessThan(TimeSpan.FromSeconds(30)));
+  }
+
+  /// <summary>
+  /// Abandoning the match must not be silent, but it must also not report 
once per event: the
+  /// condition repeats for every event that reaches the filter.
+  /// </summary>
+  [Test]
+  [NonParallelizable]
+  public void AnAbandonedMatchIsReportedOnce()
+  {
+    StringMatchFilter filter = new() { RegexToMatch = CatastrophicPattern, 
MatchTimeoutMillis = 100 };
+    filter.ActivateOptions();
+
+    List<LogLog> messages = [];
+    LogLog.ExecuteWithoutEmittingInternalMessages(() =>
+    {
+      using LogLog.LogReceivedAdapter _ = new(messages);
+      filter.Decide(CreateEvent(CraftedMessage));
+      filter.Decide(CreateEvent(CraftedMessage));
+      filter.Decide(CreateEvent(CraftedMessage));
+    });
+
+    Assert.That(
+      messages.ConvertAll(m => m.Message).FindAll(m => m.IndexOf("was 
abandoned", StringComparison.Ordinal) >= 0),
+      Has.Count.EqualTo(1));
+  }
+
+  /// <summary>
+  /// A pattern that does not backtrack has to keep working, with the decision 
unchanged.
+  /// </summary>
+  [Test]
+  public void AMatchingPatternStillDecides()
+  {
+    StringMatchFilter filter = new() { RegexToMatch = "cat", AcceptOnMatch = 
true };
+    filter.ActivateOptions();
+
+    Assert.That(filter.Decide(CreateEvent("the cat sat")), 
Is.EqualTo(FilterDecision.Accept));
+    Assert.That(filter.Decide(CreateEvent("the dog sat")), 
Is.EqualTo(FilterDecision.Neutral));
+  }
+
+  /// <summary>
+  /// The deadline has to be finite by default, so that an expensive pattern 
cannot hold the
+  /// appender lock indefinitely without the operator having opted into that.
+  /// </summary>
+  [Test]
+  public void MatchTimeoutMillisDefaultsToAFiniteValue()
+    => Assert.That(new StringMatchFilter().MatchTimeoutMillis, 
Is.EqualTo(1000));
+
+  /// <summary>
+  /// 0 is the documented opt-out that restores unbounded matching; a negative 
deadline has no
+  /// meaning and is rejected rather than being reinterpreted.
+  /// </summary>
+  [Test]
+  public void MatchTimeoutMillisRejectsNegativeValuesButAllowsZero()
+  {
+    StringMatchFilter filter = new();
+
+    Assert.That(() => filter.MatchTimeoutMillis = -1, 
Throws.TypeOf<ArgumentOutOfRangeException>());
+
+    filter.MatchTimeoutMillis = 0;
+    Assert.That(filter.MatchTimeoutMillis, Is.EqualTo(0));
+  }
+
+  private static LoggingEvent CreateEvent(string message)
+    => new(new LoggingEventData { Level = Level.Info, Message = message, 
LoggerName = "TestLogger" });
+}
diff --git a/src/log4net/Filter/PropertyFilter.cs 
b/src/log4net/Filter/PropertyFilter.cs
index cef49c75..c487682b 100644
--- a/src/log4net/Filter/PropertyFilter.cs
+++ b/src/log4net/Filter/PropertyFilter.cs
@@ -95,7 +95,7 @@ public override FilterDecision Decide(LoggingEvent 
loggingEvent)
     if (m_regexToMatch is not null)
     {
       // Check the regex
-      if (m_regexToMatch.Match(msg).Success == false)
+      if (!IsRegexMatch(msg))
       {
         // No match, continue processing
         return FilterDecision.Neutral;
diff --git a/src/log4net/Filter/StringMatchFilter.cs 
b/src/log4net/Filter/StringMatchFilter.cs
index e7819913..d0c79c42 100644
--- a/src/log4net/Filter/StringMatchFilter.cs
+++ b/src/log4net/Filter/StringMatchFilter.cs
@@ -17,6 +17,7 @@
 //
 #endregion
 
+using System;
 using System.Text.RegularExpressions;
 
 using log4net.Core;
@@ -53,14 +54,92 @@ public class StringMatchFilter : FilterSkeleton
   /// <see cref="ActivateOptions"/> must be called again.
   /// </para>
   /// </remarks>
-  public override void ActivateOptions() 
+  public override void ActivateOptions()
   {
     if (RegexToMatch is not null)
     {
-      m_regexToMatch = new(RegexToMatch, RegexOptions.Compiled);
+      m_regexToMatch = new(RegexToMatch, RegexOptions.Compiled,
+        _matchTimeoutMillis == 0
+          ? Regex.InfiniteMatchTimeout
+          : TimeSpan.FromMilliseconds(_matchTimeoutMillis));
     }
   }
 
+  /// <summary>
+  /// Gets or sets the time, in milliseconds, that matching <see 
cref="RegexToMatch"/> against a
+  /// single event may take before the match is abandoned.
+  /// </summary>
+  /// <value>
+  /// A positive number of milliseconds, or 0 to let a match run for as long 
as it takes.
+  /// </value>
+  /// <remarks>
+  /// <para>
+  /// A regular expression that backtracks can take a very long time on some 
inputs. The pattern is
+  /// matched while the appender lock is held, so an unbounded match would 
stall everything logging
+  /// through the appender, and matching is therefore given a deadline. A 
match that reaches it is
+  /// treated as no match, leaving the rest of the filter chain to decide.
+  /// </para>
+  /// <para>
+  /// The pattern comes from configuration and is trusted, so this is a guard 
against a pattern that
+  /// turns out to be expensive rather than protection against untrusted input.
+  /// </para>
+  /// <para>
+  /// The default value is 1000 (one second). Setting the value to 0 restores 
unbounded matching and
+  /// is not recommended. Changing it takes effect when <see 
cref="ActivateOptions"/> is called.
+  /// </para>
+  /// </remarks>
+  /// <exception cref="ArgumentOutOfRangeException">The value specified is 
negative.</exception>
+  public int MatchTimeoutMillis
+  {
+    get => _matchTimeoutMillis;
+    set
+    {
+      if (value < 0)
+      {
+        throw SystemInfo.CreateArgumentOutOfRangeException(nameof(value), 
value,
+          "The value specified for MatchTimeoutMillis is negative.");
+      }
+      _matchTimeoutMillis = value;
+    }
+  }
+
+  private int _matchTimeoutMillis = 1000;
+  private bool _matchTimeoutReported;
+
+  /// <summary>
+  /// Matches <paramref name="value"/> against <see cref="m_regexToMatch"/>.
+  /// </summary>
+  /// <param name="value">The text to match.</param>
+  /// <returns>
+  /// <see langword="true"/> when the pattern matches, and <see 
langword="false"/> when it does not
+  /// or when matching took longer than <see cref="MatchTimeoutMillis"/>.
+  /// </returns>
+  protected bool IsRegexMatch(string value)
+  {
+    try
+    {
+      return m_regexToMatch!.IsMatch(value);
+    }
+    catch (RegexMatchTimeoutException)
+    {
+      if (!_matchTimeoutReported)
+      {
+        // Once per filter. The condition repeats for every event that reaches 
it, and a warning
+        // per event would be a denial of service of its own.
+        _matchTimeoutReported = true;
+        LogLog.Warn(_declaringType,
+          $"Matching the pattern [{RegexToMatch}] took longer than 
{MatchTimeoutMillis}ms and was abandoned, so the event was not filtered by it. "
+          + "A pattern that backtracks can take arbitrarily long on some 
inputs; consider rewriting it or raising MatchTimeoutMillis.");
+      }
+      return false;
+    }
+  }
+
+  /// <summary>
+  /// The fully qualified type of the <see cref="StringMatchFilter"/> class.
+  /// </summary>
+  private static readonly Type _declaringType = typeof(StringMatchFilter);
+
   /// <summary>
   /// <see cref="FilterDecision.Accept"/> when matching <see 
cref="StringToMatch"/> or <see cref="RegexToMatch"/>
   /// </summary>
@@ -144,11 +223,11 @@ public override FilterDecision Decide(LoggingEvent 
loggingEvent)
     if (m_regexToMatch is not null)
     {
       // Check the regex
-      if (m_regexToMatch.Match(msg).Success == false)
+      if (!IsRegexMatch(msg))
       {
         // No match, continue processing
         return FilterDecision.Neutral;
-      } 
+      }
 
       // we've got a match
       if (AcceptOnMatch) 
diff --git 
a/src/site/antora/modules/ROOT/pages/manual/configuration/filters.adoc 
b/src/site/antora/modules/ROOT/pages/manual/configuration/filters.adoc
index 5f2e07a6..0c34f84c 100644
--- a/src/site/antora/modules/ROOT/pages/manual/configuration/filters.adoc
+++ b/src/site/antora/modules/ROOT/pages/manual/configuration/filters.adoc
@@ -94,3 +94,26 @@ The following filters are defined in the log4net package:
 |log4net.Filter.StringMatchFilter
 |Matches events containing a specific substring in the message.
 |===
+
+[#filters-regex-timeout]
+== Matching with a regular expression
+
+`StringMatchFilter`, `PropertyFilter`, `MdcFilter` and `NdcFilter` accept a 
`regexToMatch` instead
+of a `stringToMatch`.
+
+A regular expression that backtracks can take a very long time on some inputs, 
and the match runs
+while the appender lock is held.
+Matching is therefore given a deadline of one second, configurable with 
`matchTimeoutMillis`.
+A match that reaches the deadline is abandoned, the filter reports it once and 
returns `Neutral`,
+and the remaining filters decide the event.
+Prefer a pattern that cannot backtrack; the deadline is a safety net, not a 
substitute.
+
+[source,xml]
+----
+<filter type="log4net.Filter.StringMatchFilter">
+  <regexToMatch value="user=[a-z0-9._-]+@example\.com" />
+  <matchTimeoutMillis value="1000" />
+</filter>
+----
+
+Setting `matchTimeoutMillis` to `0` lets a match run for as long as it takes 
and is not recommended.

Reply via email to