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

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

commit 4286db7407efffc69745fb85cf22800b376b42b4
Author: Jan Friedrich <[email protected]>
AuthorDate: Fri Sep 4 00:06:35 2026 +0200

    decide filter matches deterministically #317
    
    - Substring matching was culture sensitive, so a NUL, soft hyphen or
      zero-width space inside StringToMatch still matched. Ordinal now.
    - An abandoned regex match counted as a non-match, so content that
      forced the timeout could suppress its own record. TimeoutDecision
      decides it, still Neutral by default.
    - Default deadline 1000ms to 50ms. The match holds the appender lock.
    - StringMatchFilter and PropertyFilter had near-identical Decide
      bodies, which is why each finding existed twice. Shared now.
    - filters.adoc documented the old deadline and outcome, and had an
      example restoring the old default. MdcFilter and NdcFilter derive
      from PropertyFilter and need no change.
    
    audit da18b6fd-f017, da18b6fd-f018, da18b6fd-f041
---
 .../3.5.0/317-filter-ordinal-substring.xml         |  12 ++
 .../3.5.0/317-filter-timeout-decision.xml          |  13 ++
 src/changelog/3.5.0/317-filter-timeout-default.xml |  12 ++
 src/log4net.Tests/Filter/PropertyFilterTest.cs     | 119 +++++++++++++++++
 src/log4net.Tests/Filter/StringMatchFilterTest.cs  |  70 +++++++++-
 src/log4net/Filter/PropertyFilter.cs               |  39 +-----
 src/log4net/Filter/StringMatchFilter.cs            | 146 ++++++++++++---------
 .../ROOT/pages/manual/configuration/filters.adoc   |  34 ++++-
 8 files changed, 340 insertions(+), 105 deletions(-)

diff --git a/src/changelog/3.5.0/317-filter-ordinal-substring.xml 
b/src/changelog/3.5.0/317-filter-ordinal-substring.xml
new file mode 100644
index 00000000..46f85331
--- /dev/null
+++ b/src/changelog/3.5.0/317-filter-ordinal-substring.xml
@@ -0,0 +1,12 @@
+<?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="fixed">
+  <issue id="317" link="https://github.com/apache/logging-log4net/pull/317"/>
+  <description format="asciidoc">Compare `StringToMatch` ordinally in 
`StringMatchFilter` and `PropertyFilter`.
+  The substring search was culture sensitive, and a linguistic search skips 
ignorable characters, so
+  content holding a NUL, a soft hyphen or a zero-width space between the 
letters of the configured
+  text still matched it, and the decision varied with the host culture. The 
filter now decides the
+  same way a reader of the log would (audit da18b6fd-f018, fixed by 
@FreeAndNil)</description>
+</entry>
diff --git a/src/changelog/3.5.0/317-filter-timeout-decision.xml 
b/src/changelog/3.5.0/317-filter-timeout-decision.xml
new file mode 100644
index 00000000..1e6e88c2
--- /dev/null
+++ b/src/changelog/3.5.0/317-filter-timeout-decision.xml
@@ -0,0 +1,13 @@
+<?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="added">
+  <issue id="317" link="https://github.com/apache/logging-log4net/pull/317"/>
+  <description format="asciidoc">`TimeoutDecision` on `StringMatchFilter` and 
`PropertyFilter`, deciding an
+  event whose regular expression match was abandoned. An abandoned match was 
treated as a non-match,
+  but the content decides whether the deadline is reached, so in an 
`AcceptOnMatch` allowlist
+  followed by a deny-all it let content suppress its own record. The default 
stays `Neutral`;
+  `Accept` makes such a chain fail towards logging (audit da18b6fd-f017, 
implemented by
+  @FreeAndNil)</description>
+</entry>
diff --git a/src/changelog/3.5.0/317-filter-timeout-default.xml 
b/src/changelog/3.5.0/317-filter-timeout-default.xml
new file mode 100644
index 00000000..aed27a53
--- /dev/null
+++ b/src/changelog/3.5.0/317-filter-timeout-default.xml
@@ -0,0 +1,12 @@
+<?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="317" link="https://github.com/apache/logging-log4net/pull/317"/>
+  <description format="asciidoc">The default `MatchTimeoutMillis` on 
`StringMatchFilter` and
+  `PropertyFilter` is now 50 instead of 1000. The match runs while the 
appender lock is held, so the
+  deadline is the bound on how long one crafted event can stall everything 
logging through the
+  appender. A legitimate match over an event takes a fraction of 50ms; raise 
the property if a
+  pattern genuinely needs longer (audit da18b6fd-f041, implemented by 
@FreeAndNil)</description>
+</entry>
diff --git a/src/log4net.Tests/Filter/PropertyFilterTest.cs 
b/src/log4net.Tests/Filter/PropertyFilterTest.cs
new file mode 100644
index 00000000..c77b447b
--- /dev/null
+++ b/src/log4net.Tests/Filter/PropertyFilterTest.cs
@@ -0,0 +1,119 @@
+#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 log4net.Core;
+using log4net.Filter;
+using log4net.Repository;
+using log4net.Util;
+
+using NUnit.Framework;
+
+namespace log4net.Tests.Filter;
+
+/// <summary>
+/// Tests for <see cref="PropertyFilter"/>, which decides a property value 
through the same
+/// matching as <see cref="StringMatchFilter"/>.
+/// </summary>
+[TestFixture]
+public class PropertyFilterTest
+{
+  private const string Key = "user";
+
+  /// <summary>
+  /// A pattern that backtracks, with a value that makes it do so.
+  /// </summary>
+  private const string CatastrophicPattern = "^(a+)+$";
+
+  private const string CraftedValue = 
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!";
+
+  /// <summary>
+  /// A property value is content too, so an ignorable character must not 
force a match here
+  /// either.
+  /// </summary>
+  [TestCase("\0", TestName = "ANulDoesNotForceAPropertyMatch")]
+  [TestCase("­", TestName = "ASoftHyphenDoesNotForceAPropertyMatch")]
+  public void AnIgnorableCharacterDoesNotForceAPropertyMatch(string ignorable)
+  {
+    PropertyFilter filter = new() { Key = Key, StringToMatch = "admin", 
AcceptOnMatch = true };
+    filter.ActivateOptions();
+
+    Assert.That(filter.Decide(CreateEvent($"adm{ignorable}in")), 
Is.EqualTo(FilterDecision.Neutral));
+  }
+
+  /// <summary>
+  /// The exact value still decides, so the ordinal comparison has not broken 
the filter.
+  /// </summary>
+  [Test]
+  public void AnExactPropertyValueStillMatches()
+  {
+    PropertyFilter filter = new() { Key = Key, StringToMatch = "admin", 
AcceptOnMatch = false };
+    filter.ActivateOptions();
+
+    Assert.That(filter.Decide(CreateEvent("admin")), 
Is.EqualTo(FilterDecision.Deny));
+    Assert.That(filter.Decide(CreateEvent("guest")), 
Is.EqualTo(FilterDecision.Neutral));
+  }
+
+  /// <summary>
+  /// An abandoned match is decided the same way as in the base filter.
+  /// </summary>
+  [Test]
+  public void AnAbandonedPropertyMatchHonoursTimeoutDecision()
+  {
+    PropertyFilter filter = new()
+    {
+      Key = Key,
+      RegexToMatch = CatastrophicPattern,
+      MatchTimeoutMillis = 50,
+      TimeoutDecision = FilterDecision.Accept
+    };
+    filter.ActivateOptions();
+
+    FilterDecision decision = FilterDecision.Neutral;
+    LogLog.ExecuteWithoutEmittingInternalMessages(() => decision = 
filter.Decide(CreateEvent(CraftedValue)));
+
+    Assert.That(decision, Is.EqualTo(FilterDecision.Accept));
+  }
+
+  /// <summary>
+  /// Without a key there is nothing to look up, so the chain continues.
+  /// </summary>
+  [Test]
+  public void AFilterWithoutAKeyIsNeutral()
+  {
+    PropertyFilter filter = new() { StringToMatch = "admin" };
+    filter.ActivateOptions();
+
+    Assert.That(filter.Decide(CreateEvent("admin")), 
Is.EqualTo(FilterDecision.Neutral));
+  }
+
+  /// <summary>
+  /// Builds an event carrying <paramref name="value"/> under <see 
cref="Key"/>. The filter renders
+  /// the property through the repository, so the event needs one.
+  /// </summary>
+  private static LoggingEvent CreateEvent(string value)
+  {
+    ILoggerRepository repository = 
LogManager.CreateRepository(Guid.NewGuid().ToString());
+    LoggingEventData data = new() { Level = Level.Info, Message = 
"TestMessage", LoggerName = "TestLogger" };
+    LoggingEvent loggingEvent = new(null, repository, data);
+    loggingEvent.Properties[Key] = value;
+    return loggingEvent;
+  }
+}
diff --git a/src/log4net.Tests/Filter/StringMatchFilterTest.cs 
b/src/log4net.Tests/Filter/StringMatchFilterTest.cs
index ff3eced6..641d58fa 100644
--- a/src/log4net.Tests/Filter/StringMatchFilterTest.cs
+++ b/src/log4net.Tests/Filter/StringMatchFilterTest.cs
@@ -50,7 +50,7 @@ public class StringMatchFilterTest
   [Test]
   public void AMatchThatBacktracksIsAbandoned()
   {
-    StringMatchFilter filter = new() { RegexToMatch = CatastrophicPattern, 
MatchTimeoutMillis = 100 };
+    StringMatchFilter filter = new() { RegexToMatch = CatastrophicPattern, 
MatchTimeoutMillis = 50 };
     filter.ActivateOptions();
 
     Stopwatch stopwatch = Stopwatch.StartNew();
@@ -70,7 +70,7 @@ public void AMatchThatBacktracksIsAbandoned()
   [NonParallelizable]
   public void AnAbandonedMatchIsReportedOnce()
   {
-    StringMatchFilter filter = new() { RegexToMatch = CatastrophicPattern, 
MatchTimeoutMillis = 100 };
+    StringMatchFilter filter = new() { RegexToMatch = CatastrophicPattern, 
MatchTimeoutMillis = 50 };
     filter.ActivateOptions();
 
     List<LogLog> messages = [];
@@ -101,12 +101,72 @@ public void AMatchingPatternStillDecides()
   }
 
   /// <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.
+  /// The deadline bounds how long one crafted event holds the appender lock, 
so it has to be
+  /// finite by default and short enough to matter.
   /// </summary>
   [Test]
   public void MatchTimeoutMillisDefaultsToAFiniteValue()
-    => Assert.That(new StringMatchFilter().MatchTimeoutMillis, 
Is.EqualTo(1000));
+    => Assert.That(new StringMatchFilter().MatchTimeoutMillis, Is.EqualTo(50));
+
+  /// <summary>
+  /// An abandoned match leaves the chain to decide unless the operator says 
otherwise, so the
+  /// default has to stay Neutral.
+  /// </summary>
+  [Test]
+  public void TimeoutDecisionDefaultsToNeutral()
+    => Assert.That(new StringMatchFilter().TimeoutDecision, 
Is.EqualTo(FilterDecision.Neutral));
+
+  /// <summary>
+  /// The content decides whether the deadline is reached, so an allowlist 
that treats an abandoned
+  /// match as a non-match lets content suppress its own record. 
TimeoutDecision is how such a
+  /// chain fails towards logging instead.
+  /// </summary>
+  [TestCase(FilterDecision.Deny, TestName = 
"AnAbandonedMatchIsDecidedByTimeoutDecisionDeny")]
+  [TestCase(FilterDecision.Accept, TestName = 
"AnAbandonedMatchIsDecidedByTimeoutDecisionAccept")]
+  [TestCase(FilterDecision.Neutral, TestName = 
"AnAbandonedMatchIsDecidedByTimeoutDecisionNeutral")]
+  public void AnAbandonedMatchIsDecidedByTimeoutDecision(FilterDecision 
timeoutDecision)
+  {
+    StringMatchFilter filter = new()
+    {
+      RegexToMatch = CatastrophicPattern,
+      MatchTimeoutMillis = 50,
+      TimeoutDecision = timeoutDecision
+    };
+    filter.ActivateOptions();
+
+    FilterDecision decision = FilterDecision.Accept;
+    LogLog.ExecuteWithoutEmittingInternalMessages(() => decision = 
filter.Decide(CreateEvent(CraftedMessage)));
+
+    Assert.That(decision, Is.EqualTo(timeoutDecision));
+  }
+
+  /// <summary>
+  /// A linguistic search skips ignorable characters, so content could 
otherwise force a substring
+  /// match that no ordinal reader of the same log would make.
+  /// </summary>
+  [TestCase("\0", TestName = "ANulDoesNotForceASubstringMatch")]
+  [TestCase("­", TestName = "ASoftHyphenDoesNotForceASubstringMatch")]
+  [TestCase("​", TestName = "AZeroWidthSpaceDoesNotForceASubstringMatch")]
+  public void AnIgnorableCharacterDoesNotForceASubstringMatch(string ignorable)
+  {
+    StringMatchFilter filter = new() { StringToMatch = "password", 
AcceptOnMatch = true };
+    filter.ActivateOptions();
+
+    Assert.That(filter.Decide(CreateEvent($"pass{ignorable}word")), 
Is.EqualTo(FilterDecision.Neutral));
+  }
+
+  /// <summary>
+  /// The ordinal comparison must not break the match it exists to protect.
+  /// </summary>
+  [Test]
+  public void AnExactSubstringStillMatches()
+  {
+    StringMatchFilter filter = new() { StringToMatch = "password", 
AcceptOnMatch = true };
+    filter.ActivateOptions();
+
+    Assert.That(filter.Decide(CreateEvent("a password here")), 
Is.EqualTo(FilterDecision.Accept));
+    Assert.That(filter.Decide(CreateEvent("nothing here")), 
Is.EqualTo(FilterDecision.Neutral));
+  }
 
   /// <summary>
   /// 0 is the documented opt-out that restores unbounded matching; a negative 
deadline has no
diff --git a/src/log4net/Filter/PropertyFilter.cs 
b/src/log4net/Filter/PropertyFilter.cs
index c487682b..d22638ed 100644
--- a/src/log4net/Filter/PropertyFilter.cs
+++ b/src/log4net/Filter/PropertyFilter.cs
@@ -76,46 +76,11 @@ public override FilterDecision Decide(LoggingEvent 
loggingEvent)
       return FilterDecision.Neutral;
     }
 
-    // Lookup the string to match in from the properties using 
+    // Lookup the string to match in from the properties using
     // the key specified.
     object? msgObj = loggingEvent.LookupProperty(Key);
 
     // Use an ObjectRenderer to convert the property value to a string
-    string? msg = loggingEvent.Repository?.RendererMap.FindAndRender(msgObj);
-
-    // Check if we have been setup to filter
-    if (msg is null || (StringToMatch is null && m_regexToMatch is null))
-    {
-      // We cannot filter so allow the filter chain
-      // to continue processing
-      return FilterDecision.Neutral;
-    }
-
-    // Firstly check if we are matching using a regex
-    if (m_regexToMatch is not null)
-    {
-      // Check the regex
-      if (!IsRegexMatch(msg))
-      {
-        // No match, continue processing
-        return FilterDecision.Neutral;
-      }
-
-      // we've got a match
-      return AcceptOnMatch ? FilterDecision.Accept : FilterDecision.Deny;
-    }
-    else if (StringToMatch is not null)
-    {
-      // Check substring match
-      if (msg.IndexOf(StringToMatch) == -1)
-      {
-        // No match, continue processing
-        return FilterDecision.Neutral;
-      }
-
-      // we've got a match
-      return AcceptOnMatch ? FilterDecision.Accept : FilterDecision.Deny;
-    }
-    return FilterDecision.Neutral;
+    return 
DecideOnValue(loggingEvent.Repository?.RendererMap.FindAndRender(msgObj));
   }
 }
diff --git a/src/log4net/Filter/StringMatchFilter.cs 
b/src/log4net/Filter/StringMatchFilter.cs
index d0c79c42..06a525da 100644
--- a/src/log4net/Filter/StringMatchFilter.cs
+++ b/src/log4net/Filter/StringMatchFilter.cs
@@ -76,16 +76,18 @@ public override void ActivateOptions()
   /// <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.
+  /// through the appender, and matching is therefore given a deadline. <see 
cref="TimeoutDecision"/>
+  /// decides an event whose match is abandoned.
   /// </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.
+  /// The pattern comes from configuration and is trusted, but the content it 
is matched against is
+  /// not, and the content decides whether the deadline is reached. This is 
therefore the bound on
+  /// how long one crafted event can hold the appender lock.
   /// </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.
+  /// The default value is 50. A legitimate match over an event runs in a 
fraction of that; raise it
+  /// if a pattern genuinely needs longer. 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>
@@ -103,9 +105,34 @@ public int MatchTimeoutMillis
     }
   }
 
-  private int _matchTimeoutMillis = 1000;
+  private int _matchTimeoutMillis = 50;
   private bool _matchTimeoutReported;
 
+  /// <summary>
+  /// Gets or sets the decision for an event whose match was abandoned because 
it reached
+  /// <see cref="MatchTimeoutMillis"/>.
+  /// </summary>
+  /// <remarks>
+  /// <para>
+  /// An abandoned match is not a non-match: the content being matched decides 
whether the deadline
+  /// is reached, so treating it as "did not match" lets content choose its 
own outcome. In an
+  /// <see cref="AcceptOnMatch"/> allowlist followed by a deny-all, that means 
content can suppress
+  /// its own record.
+  /// </para>
+  /// <para>
+  /// The default is <see cref="FilterDecision.Neutral"/>, which leaves the 
rest of the chain to
+  /// decide. Set <see cref="FilterDecision.Accept"/> to make an allowlist 
fail towards logging, or
+  /// <see cref="FilterDecision.Deny"/> in a deny-on-match chain. No one 
direction is right for
+  /// every chain, which is why this is configured rather than chosen here.
+  /// </para>
+  /// </remarks>
+  public FilterDecision TimeoutDecision { get; set; } = FilterDecision.Neutral;
+
+  /// <summary>
+  /// The decision a match produces, per <see cref="AcceptOnMatch"/>.
+  /// </summary>
+  private FilterDecision MatchDecision => AcceptOnMatch ? 
FilterDecision.Accept : FilterDecision.Deny;
+
   /// <summary>
   /// Matches <paramref name="value"/> against <see cref="m_regexToMatch"/>.
   /// </summary>
@@ -114,27 +141,74 @@ public int MatchTimeoutMillis
   /// <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)
+  protected bool IsRegexMatch(string value) => TryRegexMatch(value, out bool 
isMatch) && isMatch;
+
+  /// <summary>
+  /// Decides <paramref name="value"/> on the pattern, honouring <see 
cref="TimeoutDecision"/>.
+  /// </summary>
+  /// <param name="value">The text to match.</param>
+  /// <returns>The decision for the event the text came from.</returns>
+  protected FilterDecision DecideRegexMatch(string value)
+    => TryRegexMatch(value, out bool isMatch)
+      ? isMatch 
+        ? MatchDecision
+        : FilterDecision.Neutral
+      : TimeoutDecision;
+
+  /// <summary>
+  /// Matches <paramref name="value"/>, reporting an abandoned match once per 
filter.
+  /// </summary>
+  /// <returns><see langword="false"/> when the match was abandoned.</returns>
+  private bool TryRegexMatch(string value, out bool isMatch)
   {
     try
     {
-      return m_regexToMatch!.IsMatch(value);
+      isMatch = m_regexToMatch!.IsMatch(value);
+      return true;
     }
     catch (RegexMatchTimeoutException)
     {
+      isMatch = false;
       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.");
+          $"Matching the pattern [{RegexToMatch}] took longer than 
{MatchTimeoutMillis}ms and was abandoned, so the event was decided as 
{TimeoutDecision}. "
+          + "A pattern that backtracks can take arbitrarily long on some 
inputs; consider rewriting it, raising MatchTimeoutMillis or setting 
TimeoutDecision.");
       }
       return false;
     }
   }
 
+  /// <summary>
+  /// Decides <paramref name="value"/> on <see cref="RegexToMatch"/> or <see 
cref="StringToMatch"/>.
+  /// </summary>
+  /// <param name="value">The text to match, or <see langword="null"/> when 
there is none.</param>
+  /// <returns>
+  /// <see cref="FilterDecision.Neutral"/> when there is nothing to match or 
nothing to match it
+  /// against, otherwise the decision for the event the text came from.
+  /// </returns>
+  protected FilterDecision DecideOnValue(string? value)
+  {
+    if (value is null)
+    {
+      return FilterDecision.Neutral;
+    }
+
+    if (m_regexToMatch is not null)
+    {
+      return DecideRegexMatch(value);
+    }
+
+    // Ordinal: a linguistic search skips ignorable characters, so content 
holding a NUL, a soft
+    // hyphen or a combining mark could otherwise flip the decision.
+    return StringToMatch is not null && value.IndexOf(StringToMatch, 
StringComparison.Ordinal) >= 0
+      ? MatchDecision
+      : FilterDecision.Neutral;
+  }
+
   /// <summary>
   /// The fully qualified type of the <see cref="StringMatchFilter"/> class.
   /// </summary>
@@ -207,52 +281,6 @@ protected bool IsRegexMatch(string value)
   /// <see cref="FilterDecision.Deny"/> is returned.
   /// </para>
   /// </remarks>
-  public override FilterDecision Decide(LoggingEvent loggingEvent) 
-  {
-    string? msg = loggingEvent.EnsureNotNull().RenderedMessage;
-
-    // Check if we have been setup to filter
-    if (msg is null || (StringToMatch is null && m_regexToMatch is null))
-    {
-      // We cannot filter so allow the filter chain
-      // to continue processing
-      return FilterDecision.Neutral;
-    }
-  
-    // Firstly check if we are matching using a regex
-    if (m_regexToMatch is not null)
-    {
-      // Check the regex
-      if (!IsRegexMatch(msg))
-      {
-        // No match, continue processing
-        return FilterDecision.Neutral;
-      }
-
-      // we've got a match
-      if (AcceptOnMatch) 
-      {
-        return FilterDecision.Accept;
-      } 
-      return FilterDecision.Deny;
-    }
-    else if (StringToMatch is not null)
-    {
-      // Check substring match
-      if (msg.IndexOf(StringToMatch) == -1) 
-      {
-        // No match, continue processing
-        return FilterDecision.Neutral;
-      } 
-
-      // we've got a match
-      if (AcceptOnMatch) 
-      {
-        return FilterDecision.Accept;
-      } 
-      return FilterDecision.Deny;
-    }
-
-    return FilterDecision.Neutral;
-  }
+  public override FilterDecision Decide(LoggingEvent loggingEvent)
+    => DecideOnValue(loggingEvent.EnsureNotNull().RenderedMessage);
 }
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 0c34f84c..a68b5496 100644
--- a/src/site/antora/modules/ROOT/pages/manual/configuration/filters.adoc
+++ b/src/site/antora/modules/ROOT/pages/manual/configuration/filters.adoc
@@ -103,17 +103,43 @@ 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.
+The deadline is therefore what bounds how long one event can stall every 
thread logging through the
+appender, and it defaults to 50 milliseconds, configurable with 
`matchTimeoutMillis`.
+A legitimate match over an event takes a fraction of that; raise it only if a 
pattern genuinely
+needs longer.
 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" />
+  <matchTimeoutMillis value="200" />
 </filter>
 ----
 
 Setting `matchTimeoutMillis` to `0` lets a match run for as long as it takes 
and is not recommended.
+
+[#filters-regex-timeout-decision]
+=== Deciding an abandoned match
+
+A match that reaches the deadline is abandoned and the filter reports it once.
+The event is then decided by `timeoutDecision`, which defaults to `Neutral`, 
leaving the remaining
+filters to decide as before.
+
+An abandoned match is not the same as a non-match: the content being matched 
is what decides whether
+the deadline is reached, so treating it as "did not match" lets content choose 
its own outcome.
+In the allowlist arrangement above, where a matching filter accepts and a 
`DenyAllFilter` ends the
+chain, that means an event can suppress its own record.
+Set `timeoutDecision` to `Accept` there so the chain fails towards logging:
+
+[source,xml]
+----
+<filter type="log4net.Filter.StringMatchFilter">
+  <regexToMatch value="user=[a-z0-9._-]+@example\.com" />
+  <timeoutDecision value="Accept" />
+</filter>
+<filter type="log4net.Filter.DenyAllFilter" />
+----
+
+In a chain that denies on match, `Deny` is the equivalent choice.
+No one direction is right for every chain, which is why it is configured 
rather than chosen for you.

Reply via email to