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 9f5c955787ddeedf49792a4dd89156c40af87576 Author: Jan Friedrich <[email protected]> AuthorDate: Mon Aug 17 21:29:30 2026 +0200 contain per-event failures in AdoNetAppender.SendBuffer Events are removed from the CyclicBuffer by PopAll before SendBuffer runs, so they cannot be retried later. Neither ExecuteNonQuery loop contained per-event failures, and with UseTransactions (the default) a single event the provider rejects - npgsql refuses U+0000, for example - rolled back the whole batch of up to 512 events, including the ones logged before it. An attacker who gets one such byte logged per flush window could suppress the database audit trail indefinitely. Without a transaction each event is now reported and skipped individually. In transaction mode the exception still has to propagate, so the events are retried one by one after the rollback; only the events the database actually rejects are lost. This makes delivery at-least-once: if the batch failed after the database had already applied some statements, those events are written again. Duplicates are preferred over losing the whole buffer. Log4NetTransaction.Dispose threw NotImplementedException, which log4net swallowed in DoAppend, so no test ever exercised the rollback path. Real providers roll back on Dispose rather than throwing. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> --- .../309-contain-per-event-adonet-failures.xml | 15 +++ .../Appender/AdoNet/Log4NetCommand.cs | 34 ++++++ .../Appender/AdoNet/Log4NetTransaction.cs | 16 +-- src/log4net.Tests/Appender/AdoNetAppenderTest.cs | 67 +++++++++++ src/log4net/Appender/AdoNetAppender.cs | 130 ++++++++++++++++----- 5 files changed, 228 insertions(+), 34 deletions(-) diff --git a/src/changelog/3.4.0/309-contain-per-event-adonet-failures.xml b/src/changelog/3.4.0/309-contain-per-event-adonet-failures.xml new file mode 100644 index 00000000..6cef7661 --- /dev/null +++ b/src/changelog/3.4.0/309-contain-per-event-adonet-failures.xml @@ -0,0 +1,15 @@ +<?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="309" link="https://github.com/apache/logging-log4net/pull/309"/> + <description format="asciidoc"> + stop a single logging event that the database rejects from discarding the whole buffer in + `AdoNetAppender`. The events have already been removed from the buffer when they are sent, so a + rolled back transaction lost up to `BufferSize` unrelated events, which an attacker could trigger + repeatedly with content the provider refuses, such as `U+0000` on npgsql (CWE-778). Delivery + becomes at-least-once: events already applied by a batch that then failed may be written again + (audit 1231d72-f004) + </description> +</entry> diff --git a/src/log4net.Tests/Appender/AdoNet/Log4NetCommand.cs b/src/log4net.Tests/Appender/AdoNet/Log4NetCommand.cs index 161d4923..fee95a5d 100644 --- a/src/log4net.Tests/Appender/AdoNet/Log4NetCommand.cs +++ b/src/log4net.Tests/Appender/AdoNet/Log4NetCommand.cs @@ -20,6 +20,7 @@ */ using System; +using System.Collections.Generic; using System.Data; namespace log4net.Tests.Appender.AdoNet; @@ -42,12 +43,45 @@ public void Dispose() public int ExecuteNonQuery() { + string? payload = null; + foreach (object? parameter in Parameters) + { + if (parameter is IDataParameter { Value: string value }) + { + payload = value; + break; + } + } + payload ??= CommandText; + + if (ExceptionTrigger is not null + && payload?.IndexOf(ExceptionTrigger, StringComparison.Ordinal) >= 0) + { + throw new InvalidOperationException($"Simulated database rejection of [{payload}]"); + } + ExecuteNonQueryCount++; + if (payload is not null) + { + ExecutedPayloads.Add(payload); + } return 0; } public int ExecuteNonQueryCount { get; private set; } + /// <summary> + /// When set, <see cref="ExecuteNonQuery"/> throws for every command whose payload + /// contains this string, simulating a database that rejects specific content. + /// </summary> + public static string? ExceptionTrigger { get; set; } + + /// <summary> + /// The payload - the first string parameter value, or the command text when there are no + /// parameters - of every successful <see cref="ExecuteNonQuery"/> across all instances. + /// </summary> + public static List<string> ExecutedPayloads { get; } = []; + public IDbDataParameter CreateParameter() => new Log4NetParameter(); #pragma warning disable CS8766 // Nullability of reference types in return type doesn't match implicitly implemented member (possibly because of nullability attributes). diff --git a/src/log4net.Tests/Appender/AdoNet/Log4NetTransaction.cs b/src/log4net.Tests/Appender/AdoNet/Log4NetTransaction.cs index 27b3bb9c..81afb218 100644 --- a/src/log4net.Tests/Appender/AdoNet/Log4NetTransaction.cs +++ b/src/log4net.Tests/Appender/AdoNet/Log4NetTransaction.cs @@ -26,19 +26,21 @@ namespace log4net.Tests.Appender.AdoNet; internal sealed class Log4NetTransaction : IDbTransaction { + /// <inheritdoc/> public void Commit() - { - // empty - } + { } + /// <inheritdoc/> public void Rollback() - { - // empty - } + { } + /// <inheritdoc/> public IDbConnection Connection => throw new NotImplementedException(); + /// <inheritdoc/> public IsolationLevel IsolationLevel => throw new NotImplementedException(); - public void Dispose() => throw new NotImplementedException(); + /// <inheritdoc/> + public void Dispose() + { } } diff --git a/src/log4net.Tests/Appender/AdoNetAppenderTest.cs b/src/log4net.Tests/Appender/AdoNetAppenderTest.cs index 17b4119e..c42560d4 100644 --- a/src/log4net.Tests/Appender/AdoNetAppenderTest.cs +++ b/src/log4net.Tests/Appender/AdoNetAppenderTest.cs @@ -264,6 +264,73 @@ public void BufferingWebsiteExample() Assert.That(param.Value, Is.Empty); } + /// <summary> + /// An event the database rejects must only lose itself. The other events of the flushed + /// buffer have already been removed from it and cannot be retried later, so they have to + /// be written even though they shared a transaction with the rejected event. + /// </summary> + [Test] + [NonParallelizable] + public void RejectedEventDoesNotDiscardTheRestOfTheBuffer() + { + try + { + Log4NetCommand.ExceptionTrigger = "POISON"; + Log4NetCommand.ExecutedPayloads.Clear(); + + XmlDocument log4NetConfig = new(); + log4NetConfig.LoadXml( + """ + <log4net> + <appender name="AdoNetAppender" type="log4net.Appender.AdoNetAppender"> + <bufferSize value="3" /> + <useTransactions value="true" /> + <connectionType value="log4net.Tests.Appender.AdoNet.Log4NetConnection" /> + <connectionString value="data source=[database server]" /> + <commandText value="INSERT INTO Log ([Message]) VALUES (@message)" /> + <parameter> + <parameterName value="@message" /> + <dbType value="String" /> + <size value="4000" /> + <layout type="log4net.Layout.PatternLayout"> + <conversionPattern value="%message" /> + </layout> + </parameter> + </appender> + <root> + <level value="ALL" /> + <appender-ref ref="AdoNetAppender" /> + </root> + </log4net> + """); + + ILoggerRepository rep = LogManager.CreateRepository(Guid.NewGuid().ToString()); + XmlConfigurator.Configure(rep, log4NetConfig["log4net"]!); + ILog log = LogManager.GetLogger(rep.Name, "RejectedEventDoesNotDiscardTheRestOfTheBuffer"); + + // The appender reports the rejected event through its ErrorHandler; that is expected + // here and should not clutter the test output. + LogLog.ExecuteWithoutEmittingInternalMessages(() => + { + log.Debug("before"); + log.Debug("a POISON message"); + log.Debug("after one"); + // The fourth event overflows the buffer of 3 and flushes all four events. + log.Debug("after two"); + }); + + Assert.That(Log4NetCommand.ExecutedPayloads, Has.Member("before")); + Assert.That(Log4NetCommand.ExecutedPayloads, Has.Member("after one")); + Assert.That(Log4NetCommand.ExecutedPayloads, Has.Member("after two")); + Assert.That(Log4NetCommand.ExecutedPayloads, Has.No.Member("a POISON message")); + } + finally + { + Log4NetCommand.ExceptionTrigger = null; + Log4NetCommand.ExecutedPayloads.Clear(); + } + } + [Test] public void NullPropertyXmlConfig() { diff --git a/src/log4net/Appender/AdoNetAppender.cs b/src/log4net/Appender/AdoNetAppender.cs index 65d3e546..886c975d 100644 --- a/src/log4net/Appender/AdoNetAppender.cs +++ b/src/log4net/Appender/AdoNetAppender.cs @@ -394,6 +394,8 @@ protected override void OnClose() /// </remarks> protected override void SendBuffer(LoggingEvent[] events) { + events.EnsureNotNull(); + if (ReconnectOnError && (Connection is null || Connection.State != ConnectionState.Open)) { LogLog.Debug(_declaringType, $"Attempting to reconnect to database. Current Connection State: {((Connection is null) ? SystemInfo.NullText : Connection.State.ToString())}"); @@ -406,30 +408,45 @@ protected override void SendBuffer(LoggingEvent[] events) { if (UseTransactions) { + bool retryPerEvent = false; + // Create transaction // NJC - Do this on 2 lines because it can confuse the debugger - using IDbTransaction dbTran = Connection.BeginTransaction(); - try - { - SendBuffer(dbTran, events); - - // commit transaction - dbTran.Commit(); - } - catch (Exception ex) when (!ex.IsFatal()) + using (IDbTransaction dbTran = Connection.BeginTransaction()) { - // rollback the transaction try { - dbTran.Rollback(); + SendBuffer(dbTran, events); + + // commit transaction + dbTran.Commit(); } - catch (Exception inner) when (!inner.IsFatal()) + catch (Exception ex) when (!ex.IsFatal()) { - // Ignore exception + // rollback the transaction + try + { + dbTran.Rollback(); + } + catch (Exception inner) when (!inner.IsFatal()) + { + // Ignore exception + } + + // Can't insert into the database. That's a bad thing + ErrorHandler.Error("Exception while writing to database", ex); + + retryPerEvent = true; } + } - // Can't insert into the database. That's a bad thing - ErrorHandler.Error("Exception while writing to database", ex); + // The events have already been removed from the buffer, so a rolled back + // transaction would lose all of them - including the events logged before the one + // the database rejected. Retry them one by one, outside the failed transaction, + // so that only the events the database actually rejects are lost. + if (retryPerEvent) + { + SendBufferPerEvent(events); } } else @@ -493,15 +510,26 @@ protected virtual void SendBuffer(IDbTransaction? dbTran, LoggingEvent[] events) // run for all events foreach (LoggingEvent e in events) { - // No need to clear dbCmd.Parameters, just use existing. - // Set the parameter values - foreach (AdoNetAppenderParameter param in m_parameters) + try { - param.FormatValue(dbCmd, e); - } + // No need to clear dbCmd.Parameters, just use existing. + // Set the parameter values + foreach (AdoNetAppenderParameter param in m_parameters) + { + param.FormatValue(dbCmd, e); + } - // Execute the query - dbCmd.ExecuteNonQuery(); + // Execute the query + dbCmd.ExecuteNonQuery(); + } + catch (Exception ex) when (dbTran is null && !ex.IsFatal()) + { + // Without a transaction every event stands alone, so an event the database + // rejects must not stop the remaining events from being written. In transaction + // mode the exception has to propagate - the transaction is in a failed state - + // and SendBuffer retries the events individually after the rollback. + ErrorHandler.Error("Exception while writing a logging event to the database. Continuing with the remaining events.", ex); + } } } else @@ -515,13 +543,61 @@ protected virtual void SendBuffer(IDbTransaction? dbTran, LoggingEvent[] events) // run for all events foreach (LoggingEvent e in events) { - // Get the command text from the Layout - string logStatement = GetLogStatement(e); + try + { + // Get the command text from the Layout + string logStatement = GetLogStatement(e); - LogLog.Debug(_declaringType, $"LogStatement [{logStatement}]"); + LogLog.Debug(_declaringType, $"LogStatement [{logStatement}]"); - dbCmd.CommandText = logStatement; - dbCmd.ExecuteNonQuery(); + dbCmd.CommandText = logStatement; + dbCmd.ExecuteNonQuery(); + } + catch (Exception ex) when (dbTran is null && !ex.IsFatal()) + { + // See the parameterized path above: contain per-event failures outside transactions. + ErrorHandler.Error("Exception while writing a logging event to the database. Continuing with the remaining events.", ex); + } + } + } + } + + /// <summary> + /// Writes each event with its own command, so that an event the database rejects only + /// loses itself. + /// </summary> + /// <param name="events">The events to insert into the database.</param> + /// <remarks> + /// <para> + /// Used as the fallback after a transactional batch failed and was rolled back. The + /// events are sent without a transaction and failures are reported to the + /// <see cref="AppenderSkeleton.ErrorHandler"/> without affecting the remaining events. + /// </para> + /// <para> + /// Note that this makes delivery at-least-once rather than exactly-once: if the batch + /// failed after the database had already applied some of its statements - for example + /// when the commit itself failed but the rollback did not take effect - those events are + /// written a second time here. Duplicated events are preferred over silently losing the + /// whole buffer. + /// </para> + /// </remarks> + private void SendBufferPerEvent(LoggingEvent[] events) + { + foreach (LoggingEvent e in events) + { + if (Connection is not { State: ConnectionState.Open }) + { + // The connection failed rather than a single event - nothing more can be written. + return; + } + + try + { + SendBuffer(null, [e]); + } + catch (Exception ex) when (!ex.IsFatal()) + { + ErrorHandler.Error("Exception while writing a logging event to the database. The event has been dropped.", ex); } } }
