Skip to main content

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();