A lightweight ASP.NET Core middleware that safely logs incoming HTTP requests by sanitizing all user-controlled data to prevent log injection, forging, and oversized payloads. It provides configurable, high-performance request logging with secure defaults suitable for production environments.
// Log Sanitizer
//
// A lightweight ASP.NET Core middleware that safely logs incoming HTTP requests
// by sanitizing all user-controlled data to prevent log injection, forging, and
// oversized payloads. It provides configurable, high-performance request
// logging with secure defaults suitable for production environments.
//
// Security Notes (Important)
// - Prevents newline injection
// - Prevents log poisoning
// - Prevents oversized payload logging
// - Preserves forensic visibility via redaction
//
// Logging/
// ├── LogSanitizer.cs
// ├── LogSanitizeOptions.cs
// ├── RequestLoggingMiddleware.cs
// └── RequestLoggingMiddlewareExtensions.cs
// ----------------------------------------------------------------------------
//
// LogSanitizeOptions.cs
public sealed class LogSanitizeOptions
{
public bool RemoveNewLines { get; init; } = true;
public bool ReplaceNewLinesWithSpace { get; init; } = false;
public bool RemoveControlCharacters { get; init; } = true;
public bool RedactControlCharacters { get; init; } = false;
public int? MaxLength { get; init; } = 1024;
public string NullReplacement { get; init; } = string.Empty;
public static LogSanitizeOptions Default => new();
}
//
// LogSanitizer.cs
using System.Text;
public static class LogSanitizer
{
/// <summary>
/// Sanitizes user input for safe logging using configurable policies.
/// </summary>
public static string SanitizeForLogging(
string? input,
LogSanitizeOptions? options = null)
{
options ??= LogSanitizeOptions.Default;
if (input is null)
return options.NullReplacement;
if (input.Length == 0)
return string.Empty;
var builder = new StringBuilder(input.Length);
foreach (var c in input)
{
// Newline handling
if (c == '\r' || c == '\n')
{
if (options.RemoveNewLines)
{
if (options.ReplaceNewLinesWithSpace)
builder.Append(' ');
continue;
}
}
// Control character handling
if (char.IsControl(c))
{
if (options.RemoveControlCharacters)
{
if (options.RedactControlCharacters)
builder.Append(GetRedactionToken(c));
continue;
}
}
builder.Append(c);
// Enforce max length
if (options.MaxLength.HasValue &&
builder.Length >= options.MaxLength.Value)
{
break;
}
}
return builder.ToString();
}
private static string GetRedactionToken(char c)
{
// Unicode "Control Pictures" block
return c switch
{
'\n' => "",
'\r' => "␍",
'\t' => "␉",
_ => $"␀"
};
}
}
//
// Unit tests
using Xunit;
public class LogSanitizerTests
{
[Fact]
public void NullInput_ReturnsNullReplacement()
{
var options = new LogSanitizeOptions { NullReplacement = "<null>" };
var result = LogSanitizer.SanitizeForLogging(null, options);
Assert.Equal("<null>", result);
}
[Fact]
public void RemovesNewLinesByDefault()
{
var result = LogSanitizer.SanitizeForLogging("a\nb\r\nc");
Assert.Equal("abc", result);
}
[Fact]
public void ReplacesNewLinesWithSpace()
{
var options = new LogSanitizeOptions
{
RemoveNewLines = true,
ReplaceNewLinesWithSpace = true
};
var result = LogSanitizer.SanitizeForLogging("a\nb", options);
Assert.Equal("a b", result);
}
[Fact]
public void RedactsControlCharacters()
{
var options = new LogSanitizeOptions
{
RemoveControlCharacters = true,
RedactControlCharacters = true
};
var result = LogSanitizer.SanitizeForLogging("\tadmin", options);
Assert.Contains("␉", result);
}
[Fact]
public void EnforcesMaxLength()
{
var options = new LogSanitizeOptions { MaxLength = 5 };
var result = LogSanitizer.SanitizeForLogging("abcdefgh", options);
Assert.Equal("abcde", result);
}
[Fact]
public void AllowsPrintableCharacters()
{
var result = LogSanitizer.SanitizeForLogging("hello world");
Assert.Equal("hello world", result);
}
}
//
// RequestLoggingMiddleware.cs
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Threading.Tasks;
public sealed class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestLoggingMiddleware> _logger;
private readonly LogSanitizeOptions _LogsanitizeOptions;
public RequestLoggingMiddleware(
RequestDelegate next,
ILogger<RequestLoggingMiddleware> logger,
LogSanitizeOptions LogsanitizeOptions)
{
_next = next;
_logger = logger;
_LogsanitizeOptions = LogsanitizeOptions;
}
public async Task Invoke(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
try
{
await _next(context);
}
finally
{
stopwatch.Stop();
LogRequest(context, stopwatch.ElapsedMilliseconds);
}
}
private void LogRequest(HttpContext context, long elapsedMs)
{
var request = context.Request;
var method = LogSanitizer.SanitizeForLogging(request.Method, _LogsanitizeOptions);
var path = LogSanitizer.SanitizeForLogging(request.Path, _LogsanitizeOptions);
var query = LogSanitizer.SanitizeForLogging(request.QueryString.Value, _LogsanitizeOptions);
var userAgent = LogSanitizer.SanitizeForLogging(
request.Headers.UserAgent.ToString(),
_LogsanitizeOptions);
var remoteIp = LogSanitizer.SanitizeForLogging(
context.Connection.RemoteIpAddress?.ToString(),
_LogsanitizeOptions);
_logger.LogInformation(
"HTTP {Method} {Path}{Query} responded {StatusCode} in {ElapsedMs} ms | IP={IP} UA={UserAgent}",
method,
path,
query,
context.Response.StatusCode,
elapsedMs,
remoteIp,
userAgent);
}
}
//
// RequestLoggingMiddlewareExtensions.cs
using Microsoft.AspNetCore.Builder;
public static class RequestLoggingMiddlewareExtensions
{
public static IApplicationBuilder UseSanitizedRequestLogging(
this IApplicationBuilder app,
LogSanitizeOptions? options = null)
{
options ??= LogSanitizeOptions.Default;
return app.UseMiddleware<RequestLoggingMiddleware>(options);
}
}
//
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(new LogSanitizeOptions
{
ReplaceNewLinesWithSpace = true,
RedactControlCharacters = true,
MaxLength = 512
});
var app = builder.Build();
app.UseSanitizedRequestLogging();
app.MapControllers();
app.Run();