Skip to content

CWE-93: Improper Neutralization of CRLF Sequences ('CRLF Injection') - C# / ASP.NET

Overview

CRLF (Carriage Return Line Feed) Injection occurs when attackers inject \r\n characters to manipulate HTTP headers, log files, or other line-based formats. This can lead to HTTP Response Splitting, log forgery, or header manipulation attacks.

Primary Defence: Reject - do not strip - newline characters (\r, \n) in user input before it reaches an HTTP header or a log line. ASP.NET Core's built-in header validation rejects invalid header values on write; validate header values against strict patterns (alphanumeric and safe characters), and use structured logging (JSON format) so an injected newline cannot forge a log entry.

Common Vulnerable Patterns

Direct Header Manipulation

// VULNERABLE - CRLF in headers
public IActionResult Download(string filename)
{
    Response.Headers.Add("Content-Disposition", $"attachment; filename={filename}");
    return File(fileBytes, "application/octet-stream");
}

// Attack: filename = "test.pdf\r\nContent-Type: text/html\r\n\r\n<script>alert('xss')</script>"

Why this is vulnerable: A CR/LF pair terminates the header block, so a value containing one lets the caller write headers the application did not, and past a blank line, a body of their choosing. That is how one parameter becomes a response-splitting or cache-poisoning primitive rather than a formatting bug.

Check what the server does before assuming exploitability. RFC 9110 forbids CR and LF in field values, and Kestrel validates header values on write, so on a current ASP.NET Core deployment this often surfaces as an exception rather than as an injection - while the same code behind a different server, or writing to a header collection that is serialised elsewhere, does not get that. Record the finding accurately either way: relying on the host to reject what the application should not have produced makes the safety a property of the deployment.

For this header specifically there is a correct API rather than a filter. File(fileBytes, contentType, fileDownloadName) builds Content-Disposition itself, and ContentDispositionHeaderValue with FileNameStar applies the RFC 6266 encoding - which also handles the non-ASCII filenames that concatenation gets wrong for reasons unrelated to security.

Unvalidated Redirects

// VULNERABLE
public IActionResult Redirect(string url)
{
    return Redirect(url);  // Can contain CRLF
}

// Attack: url = "/page\r\nSet-Cookie: session=evil"

Why this is vulnerable: The redirect target is two weaknesses in one string. Control characters in it split the response, which is the CRLF issue this page covers; the location itself being attacker-chosen is an open redirect, and that half survives even after every control character is stripped.

They need separate fixes, which is why stripping the CR and LF and closing the ticket is the common mistake. LocalRedirect() answers the second half - it throws for anything that is not a local URL, including the protocol-relative //evil.com that a StartsWith("/") test admits. See CWE-601 for the case where redirecting off-site is a genuine requirement and an allowlist of hosts is needed instead.

Log Injection

// VULNERABLE - Log forgery
public void LogUserAction(string username, string action)
{
    logger.LogInformation($"User {username} performed {action}");
}

// Attack: username = "admin\r\nUser hacker performed GRANT ADMIN"

Why this is vulnerable: A log line is delimited by a newline, so a value containing one produces two lines and the second is indistinguishable from a genuine entry. The result is not a leak but a loss of accountability: an attacker writes plausible records attributing their actions to someone else, and the audit trail that would identify them becomes evidence for the wrong conclusion.

The interpolated string is the specific mistake, and it costs more than the injection. ILogger treats its first argument as a message template, so LogInformation("User {Username} performed {Action}", username, action) records the values as named properties a sink can encode; interpolating them into the string first collapses that to one opaque line, which is what Roslyn's CA2254 warns about. Structured sinks then close the hole as a side effect - Serilog's compact JSON formatter writes one object per line and escapes the newline inside it, so a forged line cannot exist. A plain text or console sink renders the message verbatim and does not.

Downstream matters too: a line-oriented shipper parses the forged line as its own event, so the fabrication arrives in the SIEM with a valid timestamp and severity.

Secure Patterns

Safe Header Manipulation

// SECURE - reject control characters, then let the framework build the header
using System.IO;
using System.Linq;

public IActionResult Download(string filename)
{
    // Reject rather than repair - a download name carrying a control
    // character is not a name this application should serve. The double quote
    // is rejected with them: ContentDispositionHeaderValue throws on one
    // rather than encoding it, which would be a 500 at write time.
    if (string.IsNullOrEmpty(filename) || filename.Any(char.IsControl) || filename.Contains('"'))
    {
        return BadRequest();
    }

    // A path component is a separate weakness - keep only the file name
    string safeName = Path.GetFileName(filename);

    // Use ContentDispositionHeaderValue for proper encoding
    var contentDisposition = new System.Net.Http.Headers.ContentDispositionHeaderValue("attachment")
    {
        FileName = safeName
    };

    Response.Headers.Add("Content-Disposition", contentDisposition.ToString());
    return File(fileBytes, "application/octet-stream");
}

Why this works:

This pattern refuses the value rather than repairing it. char.IsControl covers CR and LF along with every other control character, and answering with a 400 keeps the evidence of what was sent - where a strip would hand the rest of the method a name that no check ever saw in its original form. Path.GetFileName() then isolates the filename from any directory component, which is a separate weakness (CWE-22) that a newline check does nothing about.

ContentDispositionHeaderValue then builds the header, rather than string concatenation doing it. Measured on .NET 10, it quotes a filename containing a space, a semicolon or a comma, and renders a non-ASCII one as an RFC 2047 encoded-word - filename="=?utf-8?B?csOpc3Vtw6kucGRm?=" for resume.pdf spelled with accents. One character it does not encode is the double quote: assigning a FileName that contains one throws ArgumentException, so it has to be refused at the guard above rather than left for the header API to fail on halfway through the response. Those quoting rules are the edge cases a hand-written filter tends to miss, which is the argument for using the header API at all - but it is not a validator, and it does not make one unnecessary.

URL Encoding for Redirects

// SECURE - Validate and encode redirect URLs
using System.Text.Encodings.Web;

public IActionResult SafeRedirect(string returnUrl)
{
    // IsLocalUrl rejects control characters as well as off-site targets,
    // so nothing carrying a CR or LF gets past this line
    if (!Url.IsLocalUrl(returnUrl))
    {
        return RedirectToAction("Index", "Home");
    }

    // Redirect to the value that was checked, unmodified
    return Redirect(returnUrl);
}

// SECURE - Use UrlHelper for encoding
public IActionResult RedirectWithParam(string param)
{
    string safeParam = UrlEncoder.Default.Encode(param);
    return Redirect($"/page?value={safeParam}");
}

Why this works:

These redirect patterns prevent HTTP response splitting with a single check and no repair step. Url.IsLocalUrl() is doing two jobs at once: it ensures the redirect stays inside the application's own origin, which closes the open redirect an attacker would otherwise chain with CRLF injection, and it refuses control characters outright. Rejecting any URL that does not start with /, and any that starts with // (which browsers read as protocol-relative), keeps the target on the application's own host.

Nothing is stripped afterwards, and adding a strip would be worse than redundant. Measured on ASP.NET Core 10, Url.IsLocalUrl returns false for any value containing a control character - /a\rb, /a\nb and /a\tb are all rejected while /a b is accepted - so a value that reaches Redirect() has already been refused if it carried a newline, and a Replace("\r", "").Replace("\n", "") on the next line can never change it. The dangerous version is those same two steps in the other order: strip first, and a returnUrl of / + CRLF + /evil.example becomes //evil.example, a protocol-relative URL to the attacker's host, manufactured by the sanitizer and then handed to a check that never saw what the user actually sent. Reject, then use the value you checked.

What does survive IsLocalUrl is the percent-encoded form: /a%0d%0ab is a well-formed local URL and is accepted. It is inert in the Location header, and only becomes a newline if something decodes the value a second time - so the fix for that is to remove the double decode rather than to add a filter here.

The second pattern uses UrlEncoder.Default.Encode() on a query parameter, which percent-encodes per RFC 3986 and turns a CR/LF into %0D%0A so it is no longer a header delimiter. Encode the parameter values, not the whole URL - encoding after the scheme and path leaves the URL structure intact and covers only the user-controlled portion.

Safe Logging

// SECURE - Sanitize log entries
using Microsoft.Extensions.Logging;
using System.Text.RegularExpressions;

public class SecureLogger
{
    private readonly ILogger<SecureLogger> _logger;

    public SecureLogger(ILogger<SecureLogger> logger)
    {
        _logger = logger;
    }

    private string SanitizeForLog(string input)
    {
        if (string.IsNullOrEmpty(input))
            return input;

        // Remove CRLF characters
        return input.Replace("\r", "").Replace("\n", " ").Replace("\t", " ");
    }

    public void LogUserAction(string username, string action)
    {
        string safeUser = SanitizeForLog(username);
        string safeAction = SanitizeForLog(action);

        _logger.LogInformation("User {Username} performed {Action}", safeUser, safeAction);
    }
}

Why this works:

SanitizeForLog() takes the carriage returns, line feeds and tabs out of the value before it reaches the logger. Without that step, an input of "admin\r\nINFO: User hacker performed GRANT ADMIN" writes a second line that log analysis tools and SIEMs read as a genuine entry. The line feed and the tab become spaces rather than disappearing, which keeps the line readable and keeps what the user actually did on the record.

The call site also uses placeholders ({Username}, {Action}) rather than interpolation, which separates the message template from the data: the logs stay parseable programmatically and the message format cannot be altered by user input. The placeholders are not what stops the forgery here - the sanitizer is - and the next pattern covers the case where the sink does that job instead.

Keeping the sanitizer in one encapsulated method is what makes it consistent, so no code path logs a raw value by accident. For production, consider removing or escaping the remaining control characters too, and capping the length of logged user input.

Structured Logging With an Encoding Sink

// SECURE - structured call site, paired with a formatter that encodes control characters
public class UserService
{
    private readonly ILogger<UserService> _logger;

    public UserService(ILogger<UserService> logger)
    {
        _logger = logger;
    }

    public void RecordLogin(string username, string ipAddress)
    {
        _logger.LogInformation(
            "Login successful for user {Username} from {IPAddress}",
            username,
            ipAddress
        );
    }

    public void RecordFailedAttempt(string username, string reason)
    {
        _logger.LogWarning(
            "Login failed for user {Username}. Reason: {Reason}",
            username,
            reason
        );
    }
}
// Program.cs - emit JSON so field values are escaped rather than written verbatim
builder.Logging.ClearProviders();
builder.Logging.AddJsonConsole(options =>
{
    options.IncludeScopes = true;
});

Why this works:

The placeholder form and the sink each close a different half. LogInformation("... {Username} ...", username) passes the template and the value to ILogger as separate arguments, so the value can never be read as template syntax and the logging pipeline can carry it as a named field - which is what makes the entry queryable by username downstream. That separation is real, and string interpolation ($"User {username}") destroys it, which is why the Roslyn rule CA2254 flags it.

What the placeholder does not do is neutralise CR/LF. Whether an injected newline forges an entry depends entirely on the formatter at the end of the pipeline. The default console formatter renders the resolved message as text, so admin\r\nwarn: fake entry still comes out as two lines. AddJsonConsole writes each event as a JSON object and escapes the value inside its field, so the newline becomes the two characters \ and n and the record stays on one line. Serilog's CompactJsonFormatter and the Elastic ECS formatters behave the same way.

Configure this per provider, not per application. A JSON production sink plus a plain-text console sink left on for local development means the same values are rendered both ways, and the text one is still forgeable. Where JSON output is not available at all, encode the control characters explicitly before logging - see CWE-117 for that helper and the full formatter configuration.

// SECURE - Safe cookie handling
public void SetSecureCookie(string name, string value)
{
    // Validate cookie name and value
    if (string.IsNullOrWhiteSpace(name) || name.Contains("\r") || name.Contains("\n"))
    {
        throw new ArgumentException("Invalid cookie name", nameof(name));
    }

    // The value needs no filtering - Append percent-encodes it
    Response.Cookies.Append(name, value ?? "", new CookieOptions
    {
        HttpOnly = true,
        Secure = true,
        SameSite = SameSiteMode.Strict,
        MaxAge = TimeSpan.FromHours(1)
    });
}

Why this works:

This cookie pattern prevents CRLF injection in Set-Cookie headers through explicit validation before cookie creation. The name check rejects carriage return and line feed characters, which attackers could otherwise use to inject additional Set-Cookie or other headers, and throwing rather than repairing means the request fails where you can log it.

The value is deliberately passed through unmodified. Response.Cookies.Append percent-encodes it: measured on ASP.NET Core 10, a value of abc + CRLF + Set-Cookie: admin=true is written as SESSIONID=abc%0D%0ASet-Cookie%3A%20admin%3Dtrue - one cookie, no injected header. Stripping the CR and LF first adds no protection and quietly corrupts any legitimate value that contains them. The same release rejects the name on its own too, with ArgumentException: Invalid cookie name, so the check above duplicates a framework guard rather than standing in for one - keep it for the clearer error and the audit line, not because Append would otherwise let it through.

The CookieOptions settings are separate hardening rather than CRLF protection. HttpOnly keeps JavaScript from reading the cookie, so an XSS bug cannot steal it. Secure restricts it to HTTPS, so it is not readable on an unencrypted connection. SameSite = SameSiteMode.Strict keeps it out of cross-site requests, which is the CSRF defence. Set them here because this is where the cookie is created, not because they affect the injection.

Response.Cookies.Append() is the framework's safe API for setting cookies, formatting the Set-Cookie header and its attributes according to RFC 6265 so the name, value and attributes stay correctly delimited.

Alternative Encoding Methods

For URL encoding, logging contexts, and other scenarios beyond HTTP headers:

using System.Text.Encodings.Web;
using System.Web;
using System.Net;

// URL encoding (prevents CRLF in URLs/headers)
string safe = UrlEncoder.Default.Encode(userInput);
string safe2 = HttpUtility.UrlEncode(userInput);
string safe3 = WebUtility.UrlEncode(userInput);

// HTML encoding (for log output display)
string safeHtml = HtmlEncoder.Default.Encode(userInput);
string safeHtml2 = HttpUtility.HtmlEncode(userInput);

// JavaScript encoding (for logs displayed in web contexts)
string safeJs = JavaScriptEncoder.Default.Encode(userInput);
string safeJs2 = HttpUtility.JavaScriptStringEncode(userInput);

Why this works: Each encoder covers a different context, so pick by where the value lands rather than by which one is nearest. URL encoding turns CR/LF into %0D%0A and keeps the URL valid, which is what redirects and Location headers need. HTML encoding converts special characters to entities, for log output displayed in a web UI. JavaScript encoding is for logs embedded in client-side diagnostic tools, where the value would otherwise land in a script context.

Note: The legacy Microsoft AntiXSS library (Microsoft.Security.Application) is unmaintained and unnecessary on modern .NET - the System.Text.Encodings.Web encoders shown above (UrlEncoder.Default, HtmlEncoder.Default, JavaScriptEncoder.Default) are the current, actively maintained equivalents and should be used instead.

Input Validation

public class CrlfValidator
{
    private static readonly Regex CrlfPattern = new Regex(@"[\r\n]");

    public static bool ContainsCrlf(string input)
    {
        return !string.IsNullOrEmpty(input) && CrlfPattern.IsMatch(input);
    }

    public static string RemoveCrlf(string input)
    {
        if (string.IsNullOrEmpty(input))
            return input;

        return CrlfPattern.Replace(input, "");
    }

    public static void ValidateNoCrlf(string input, string paramName)
    {
        if (ContainsCrlf(input))
        {
            throw new ArgumentException(
                "Input contains invalid CRLF characters",
                paramName
            );
        }
    }
}

// Usage
public IActionResult ProcessInput(string userInput)
{
    CrlfValidator.ValidateNoCrlf(userInput, nameof(userInput));

    // Process safely
    return Ok();
}

Why this works:

This centralized utility keeps CRLF handling in one place. CrlfPattern finds any \r or \n in a single pass, and holding it in a static class means callers get the same behaviour everywhere instead of re-deriving the pattern at each site.

The three methods serve different strategies. ContainsCrlf() detects without modifying, which suits conditional logic or a log line recording that the input was malicious. RemoveCrlf() strips the characters and returns a modified string. ValidateNoCrlf() throws if CRLF is present, following the "fail securely" principle - reject the input rather than repair it.

ValidateNoCrlf() is the one to reach for in HTTP headers, redirects and authentication flows, where any CRLF is an attack attempt rather than a formatting accident. Throwing an ArgumentException that names the parameter gives a clear error to debug against, and leaves the original value intact so the caller can log the attempt and answer 400 - RemoveCrlf() discards that evidence. Call the validator at the application's entry points, before the value reaches deeper logic.

Common Pitfalls

  • Calling HttpUtility.HtmlEncode() or HtmlEncoder.Default.Encode() on a header value instead of stripping CRLF or using UrlEncoder - HTML encoding neutralizes <, >, &, and quotes, but leaves \r/\n completely untouched, so the header can still be split.
  • Stripping CRLF with Regex.Replace(input, @"[\r\n]", "") in one method while a different code path builds the same header with plain concatenation (Response.Headers.Add("X-Header", rawValue)) - the sanitizer only protects the call site it was added to, not the header itself.
  • Treating ILogger placeholders as the whole log-forging fix. They keep the template and the value apart, but the console formatter that ships on by default renders the resolved message as plain text, so a \r\n in the value still writes a second line. The JSON formatter, an ECS sink, or explicit encoding is what neutralises it.
  • Sanitizing a route or query value once at the top of an action, then decoding it again later (for example, passing it through HttpUtility.UrlDecode() a second time for an unrelated purpose) - a %0d%0a payload that was inert when the sanitizer ran becomes literal CRLF after the second decode.

Additional Resources