CWE-113: Improper Neutralization of CRLF Sequences in HTTP Headers ('HTTP Request/Response Splitting') - C# / ASP.NET
Overview
HTTP Response Splitting occurs when attackers inject CRLF characters into HTTP headers, potentially adding headers of their own or a response body. This is closely related to CWE-93 (CRLF Injection).
What Actually Happens on ASP.NET Core
On ASP.NET Core behind Kestrel, a CRLF in a header is a 500, not a split response. Measured on .NET 10: Response.Headers["X-Echo"] = "a\r\nX-Injected: evil" throws InvalidOperationException: Invalid non-ASCII or control character in header: 0x000D at the point of assignment, and so does the same value in a header name, in Response.ContentType, and in the Location header written by Redirect(). The check lives in Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpResponseHeaders.
The vulnerable patterns below are still the code to fix, but for the reasons that survive that check rather than the ones usually written up:
- The unhandled
500is itself the finding. An attacker turns any of these endpoints into a server error at will, on every request, with no authentication. - Everything reachable without a newline. An unvalidated
Locationis an open redirect (CWE-601) whether or not CRLF works. An unvalidatedContent-Dispositionfilename can carry"and;. An unvalidatedAccess-Control-Allow-Originis a CORS bypass. None of those need a control character. - Code paths where the check is absent. The guarantee belongs to Kestrel's header collection, not to the framework: a different server, a replaced
IHttpResponseFeature, a middleware that rebuilds headers from raw request data, or bytes written directly toResponse.Bodydo not have it. - .NET Framework and IIS classic behave differently and are not covered by this measurement. Confirm on the runtime in front of you.
Kestrel raising is also the strictest of the mainstream behaviours, not the normal one, which matters if the same finding spans services. A servlet container silently replaces each CR and LF with a space and raises nothing; PHP discards the header entirely and carries on. See the main CWE-113 page for the runtime-by-runtime table - the point of it is that "make the payload fail" means something different on each.
Primary Defence: Use ASP.NET Core's redirect methods (Redirect(), RedirectToAction()), Response.Cookies.Append() with CookieOptions, and ContentDispositionHeaderValue which build the header for you. Validate the value against what the endpoint expects before it reaches a header - Url.IsLocalUrl() for Location, an allowlist for enumerated values - and return 400 when it fails, so the rejection is a decision rather than an exception. Do not add a URL-encoding pass "for safety": UrlEncoder.Default is for embedding a value inside a URL, and applying it to a whole URL breaks it.
Common Vulnerable Patterns
Direct Header Injection
// VULNERABLE - user input concatenated into a header value
Response.Headers.Append("Location", "/page?return=" + userInput);
Response.Headers.Append("Set-Cookie", "session=" + sessionId);
// Input: "value\r\nSet-Cookie: admin=true"
// ASP.NET Core (.NET 10): InvalidOperationException -> 500
// ASP.NET on .NET Framework / IIS classic: injects the additional header
Why this is vulnerable: Neither value is checked against anything before it becomes part of a header, so what happens next is decided entirely by the platform rather than by the code. On .NET Framework this is the textbook split response. On ASP.NET Core it is an unhandled InvalidOperationException and a 500 that any client can trigger on demand - the injection is closed and the availability problem is not. Both readings share a fix and neither is served by leaving the concatenation in place. Note that Response.AddHeader is System.Web, not ASP.NET Core; if a codebase still calls it, it is on the runtime where the CRLF payload does work. Set-Cookie in particular should not be assembled this way on either stack - Response.Cookies.Append with CookieOptions exists for it.
Redirect Without Validation
// VULNERABLE - Redirect without validation
return Redirect(userProvidedUrl);
// Input: "https://evil.example/login" -> open redirect, the live outcome
// Input: "/home\r\nSet-Cookie: session=hijacked" -> 500 on .NET 10, not injection
Why this is vulnerable: The handler makes no decision about where it is sending the user, so https://evil.example/login is emitted as the Location header exactly as asked - a phishing redirect carrying the application's own domain in the link the victim clicked. That is CWE-601 and it is what this line actually gets you on a current runtime. The CRLF payload does not work: measured on .NET 10, Results.Redirect("/home\r\nSet-Cookie: ...") throws InvalidOperationException: Invalid non-ASCII or control character in header: 0x000D out of Kestrel...HttpResponseHeaders.set_Location, so the request answers 500. Fixing this line is still right, but a report describing it as header injection is describing something the platform closed, and the reader who goes looking for a split response will not find one - while the open redirect sitting in the same line goes unfixed.
Content-Type Header Manipulation
// VULNERABLE - user-controlled Content-Type charset, no allowlist
Response.ContentType = "text/html; charset=" + userCharset;
// Input: "utf-8\r\nSet-Cookie: admin=true"
// ASP.NET Core (.NET 10): InvalidOperationException -> 500
// older runtimes: the CRLF ends the Content-Type header and the
// Set-Cookie is read as a header of its own
Why this is vulnerable: charset reads as harmless, which is why this one survives review, but nothing constrains it - the set of legal charsets is a short fixed list and the code accepts any string at all. On .NET 10 the assignment goes through the same Kestrel check as Response.Headers, so a CRLF payload is an InvalidOperationException and a 500; measured, Response.ContentType = "text/html; charset=utf-8\r\nX-Injected: evil" throws Invalid non-ASCII or control character in header: 0x000D. What survives the check is everything a Content-Type can be made to say without a newline: a charset the browser decodes the body as, and a ; starting a parameter of the attacker's choosing. An allowlist of the three or four charsets the endpoint actually serves closes all of it and needs no reasoning about encoding.
Custom API Headers with User Data
// VULNERABLE - reflecting user data into diagnostic headers unchecked
Response.Headers["X-User-Agent"] = Request.Headers["User-Agent"];
Response.Headers["X-Request-Id"] = requestId; // from user input
// Input: requestId = "abc\r\nX-Admin: true"
// ASP.NET Core (.NET 10): InvalidOperationException -> 500
// older runtimes: X-Admin: true is emitted as a header of its own
Why this is vulnerable: A diagnostic header is still a header, and neither of these values is checked against anything. On .NET 10 both assignments hit Kestrel's check and a CRLF answers 500, so the injection is closed and an unauthenticated denial of service on the endpoint is not. The User-Agent line has a second problem worth separating out: a request header cannot itself carry a raw CRLF - the server would have parsed it as two headers - so what reaches Request.Headers["User-Agent"] is at worst a long string of legal header characters. The reason to constrain it anyway is that it is unbounded attacker-controlled text going into a response other systems parse. X-Request-Id is the live one: it comes from a query string or body, where a CRLF arrives intact after one decode. Give it a format - a GUID, or a bounded [A-Za-z0-9-] allowlist - and return 400 when it does not match.
CORS Header Injection
// VULNERABLE - reflecting any Origin back as Access-Control-Allow-Origin
string origin = Request.Headers["Origin"];
Response.Headers["Access-Control-Allow-Origin"] = origin;
// Input: Origin: https://evil.example
// Result: any site can read this endpoint's responses - no newline needed
Why this is vulnerable: This one is a real vulnerability today and it has nothing to do with CRLF, which is why filing it under CWE-113 tends to get it fixed the wrong way. Reflecting the request's Origin back means the allowlist is "whatever the attacker sent": a page on https://evil.example can call this endpoint cross-origin and read the response, and if Access-Control-Allow-Credentials: true is set anywhere nearby it reads it with the victim's session attached. The payload is a perfectly ordinary origin string. The CRLF variant is the part that does not work - a request header cannot carry a raw CRLF, since the server would have parsed it as two headers, and on .NET 10 the reflected value would hit Kestrel's check anyway. Fix it with an exact-match allowlist of origins, which is also the only thing that closes the case that is live.
Secure Patterns
Use Framework Redirect Methods (Best Practice)
// SECURE - IsLocalUrl decides, Redirect builds the header
public IActionResult SafeRedirect(string returnUrl)
{
if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
{
return Redirect(returnUrl);
}
return RedirectToAction("Index", "Home");
}
Why this works: Url.IsLocalUrl() is the control here, not the redirect method, and it is doing both jobs at once. Measured on .NET 10 it returns false for protocol-relative (//evil.example), absolute (https://evil.example) and scheme URLs (javascript:alert(1)), which is the open-redirect half (CWE-601), and false for any value containing a control character - verified across \r, \n, \r\n, tab and NUL - which is the splitting half. It returns true for /account, /account?x=1, /reports/2026-q1 and (worth knowing) /account x. Redirect() then builds the Location header from the value rather than from a header line you assembled.
What Redirect() does not do is encode a CRLF away. Left unvalidated it hands the value to Kestrel's header collection, which throws - Results.Redirect("/home\r\nSet-Cookie: ...") answers 500, as the vulnerable pattern above shows. So the check is not defence-in-depth behind a framework guarantee; it is the thing standing between this endpoint and an unauthenticated denial of service. And it is a property of IsLocalUrl specifically, so it travels only with values actually passed through it - a hand-rolled reimplementation of "is this a local URL" has whichever behaviour it was written with.
Setting the Header Manually, Outside MVC
// SECURE - defence in depth when framework APIs are unavailable
public IActionResult CustomHeaderRedirect(string returnUrl)
{
// 1. Validate format - this rejects control characters too, so there is
// nothing left to strip afterwards
if (!Url.IsLocalUrl(returnUrl))
return RedirectToAction("Index", "Home");
// 2. Set the header with the validated value, unencoded.
// Do NOT run it through UrlEncoder.Default.Encode() here - that encoder
// is for embedding a value *inside* a URL, so it percent-encodes "/" and
// "?" as well: "/account?x=1" becomes "%2Faccount%3Fx%3D1", which the
// browser resolves as a relative path segment and every redirect 404s.
Response.Headers["Location"] = returnUrl;
Response.StatusCode = 302;
return new EmptyResult();
}
Why this works: Url.IsLocalUrl() is doing more here than the open-redirect check it is usually credited with. On .NET 10 it returns false for any value containing a control character, so /account\r\nSet-Cookie: admin=true, /account\nX: y, /account\r, a tab and a NUL are all rejected by it (a space is not - IsLocalUrl("/account x") is true). Together with the protocol-relative and absolute-URL rejection that is the whole check, and the manual CRLF strip that older versions of this pattern carried is now removing characters that cannot be present.
Do not add a URL-encoding step at the end. It reads as free defence-in-depth and it breaks every legitimate redirect: UrlEncoder.Default.Encode("/account?x=1") returns %2Faccount%3Fx%3D1, so the emitted Location is a relative segment rather than a path and the browser lands on /%2Faccount%3Fx%3D1. Every rejection test still passes, because a rejected URL never reaches the encoder. The only test that catches it is following an accepted redirect to its destination.
Note also that this pattern is rarely needed on ASP.NET Core - Redirect(returnUrl) after the same IsLocalUrl check does the same thing and is the version above. Reach for the manual form only where the response is being built outside MVC.
Use CookieOptions for Setting Cookies
// SECURE - Use CookieOptions for cookie management
Response.Cookies.Append("session", value, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict
});
Why this works: Response.Cookies.Append percent-encodes the value before it becomes header text, which is the one place on this page where a framework really does encode rather than raise or drop. Measured on .NET 10 against a running Kestrel, a value of light\r\nSet-Cookie: admin=true is emitted as session=light%0D%0ASet-Cookie%3A%20admin%3Dtrue; path=/; secure; samesite=strict; httponly, and light; Domain=example.com as session=light%3B%20Domain%3Dexample.com; path=/; secure; samesite=strict; httponly - so neither the newline nor the ; reaches the cookie grammar, and no exception fires. CookieOptions is also what gets HttpOnly, Secure and SameSite onto the cookie at all; a hand-built Set-Cookie string routinely omits them.
Read the encoding accurately, though. It is applied to the value, so a value that arrives back through Request.Cookies is decoded again and is whatever the client stored - the cookie API stops the header from being split, not the cookie from holding attacker-controlled text. Validate the value for the same reasons you would validate anything else you are about to store and read back, and note that a legitimate value with a space in it round-trips as %20 in the header, which is worth asserting rather than assuming.
Give a Custom Header Value a Format
using System.Text.RegularExpressions;
// SECURE - the header value has a declared shape, and anything else is a 400.
// \A and \z, not ^ and $: in .NET, $ also matches before a trailing newline.
private static readonly Regex RequestIdPattern =
new(@"\A[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z", RegexOptions.Compiled);
public IActionResult ApiEndpoint(string requestId)
{
if (string.IsNullOrEmpty(requestId) || !RequestIdPattern.IsMatch(requestId))
return BadRequest();
Response.Headers["X-Request-Id"] = requestId;
return Ok();
}
Why this works: The allowlist says what the header value is allowed to be rather than listing characters to take out of it, so CR, LF, NUL, ", ; and everything else that means something in a header grammar are excluded as a consequence rather than one at a time. The \A...\z anchors are load-bearing, for the same reason re.fullmatch() is in Python: measured on .NET 10, ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ returns true for "abc\n", because $ also matches immediately before a trailing newline - so the ^...$ spelling admits a line terminator, which is the one character class the pattern exists to exclude. \A...\z returns false for it and still accepts req-42, abc123 and a.b_c-d. Bounding the length in the same expression handles the other half: a header value has no length limit in the application, and proxies disagree about where they stop accepting one. Returning 400 makes the rejection a decision that appears in the logs, instead of a 500 out of Kestrel.
Do not put a UrlEncoder.Default.Encode() pass on the end of this. It reads as free defence-in-depth and it is the wrong encoder for the job in both directions. On a value that already passed the allowlist it does nothing - Encode("req-42") returns req-42 - so it is not adding a control. On a value that did not, it produces an unreadable header rather than a rejection: measured on .NET 10, Encode("Mozilla/5.0 (Windows NT 10.0; Win64; x64)") returns Mozilla%2F5.0%20(Windows%20NT%2010.0;%20Win64;%20x64), which whatever consumes X-User-Agent now has to know to decode. UrlEncoder percent-encodes for embedding a value inside a URL; a header value is not a URL, and the pair of .Replace() calls usually written in front of it is the strip this page is telling you not to do.
Use ContentDispositionHeaderValue for File Downloads
// SECURE - the typed header value rejects what it cannot represent, and the
// rejection is answered rather than left to escape as a 500
try
{
var contentDisposition = new System.Net.Http.Headers.ContentDispositionHeaderValue("attachment")
{
FileName = filename // no pre-strip - the property validates on assignment
};
Response.Headers["Content-Disposition"] = contentDisposition.ToString();
}
catch (Exception ex) when (ex is FormatException or ArgumentException)
{
return BadRequest("Invalid filename.");
}
Why this works: ContentDispositionHeaderValue formats the header per RFC 6266 and validates the filename as it assigns it, so there is nothing left for a hand-written filter to catch. Measured on .NET 10: FileName = "a\r\nX: y" throws FormatException, FileName = "a\"; filename*=UTF-8''evil.html" throws ArgumentException, and an ordinary "q3 report 2026.pdf" is quoted for you and comes out as attachment; filename="q3 report 2026.pdf". That last case is the one worth checking before trusting any replacement for this class, because a filter strict enough to exclude CRLF usually excludes the space as well.
The .Replace("\r", "").Replace("\n", "") that older versions of this pattern put on the filename is worse than redundant. Redundant because the property rejects those characters anyway; worse because it prevents the rejection: "a\r\nX: y" arrives as "aX: y", which is a legal filename, so the exception never fires and the download is served under a name the user did not ask for, with nothing in the log.
The try/catch is not optional, which is why it is in the example above. A property that validates on assignment converts a bad filename into an exception, and an unhandled exception in an action is a 500 - the outcome this page spends its length telling you to avoid. Catching the two types the property actually throws and answering 400 is what turns the rejection into a decision that appears in the logs. Validating the filename against a character class first is the alternative and composes with it; what does not work is doing neither and relying on the property, because the property's whole contract is to throw.
Validate Content-Type Components
// SECURE - Allowlist validation for Content-Type
private static readonly HashSet<string> AllowedCharsets = new HashSet<string>
{
"utf-8", "utf-16", "iso-8859-1"
};
public IActionResult ServeContent(string charset)
{
// Validate against allowlist
if (!AllowedCharsets.Contains(charset?.ToLower()))
{
charset = "utf-8"; // Safe default
}
Response.ContentType = $"text/html; charset={charset}";
return Content(htmlContent);
}
Why this works: The charset is an enumerated value - the endpoint serves three of them - so membership in a fixed set is the whole check, and nothing about encoding has to be reasoned about. A CRLF payload, a bogus charset and a ; starting an extra parameter all fail the same test.
Substituting utf-8 here is not the "repair" this page tells you not to do, and the difference is worth naming because the two look alike. Repairing derives a third value from the attacker's input - "utf-8\r\nX: y" becomes "utf-8X: y", which nobody chose - whereas this discards the input entirely and uses a value the application defined. Where the endpoint has no sensible default, or where silently serving something other than what was asked for would confuse a legitimate caller, return 400 instead.
Validate CORS Origins with Allowlist
// SECURE - Allowlist-based CORS origin validation
private static readonly HashSet<string> AllowedOrigins = new HashSet<string>
{
"https://trusted.com",
"https://app.trusted.com"
};
public IActionResult CorsEndpoint()
{
string origin = Request.Headers["Origin"];
if (!string.IsNullOrEmpty(origin) && AllowedOrigins.Contains(origin))
{
Response.Headers["Access-Control-Allow-Origin"] = origin;
}
return Ok();
}
Why this works: The header is set only when the request's Origin is one of a fixed set of strings the application chose, so the header the browser sees names a site the application trusts rather than the one that asked. Exact string matching, not StartsWith or a regex, is what makes it hold - https://trusted.com.evil.com and https://trusted.com.attacker.io both fail it, and both pass a substring check. The CRLF question does not arise, which is the point: the value never comes from anywhere except the set above.
Testing
Re-running the scanner is not verification here. Kestrel raises on a CRLF whether or not your validation ran, so "no injected header appeared" is true of a fixed endpoint and of an unfixed one that answered 500. Assert on the status code and on the emitted header.
- The accepted value first, and follow the redirect:
GET /SafeRedirect?returnUrl=/reports/2026-q1must return302and the browser must land on/reports/2026-q1- not on/%2Freports%2F2026-q1. That last assertion is the only one that catches a strayUrlEncoderpass, because a rejected URL never reaches the encoder and every malicious-input test passes either way. Do the same for a download with a space in its filename and for a cookie value you read back. - A rejected value is never a
500, and never reachesLocation: sendreturnUrl=/home%0d%0aSet-Cookie:%20admin=true. Assert two things - the status is not500, andLocationis the application's own value rather than anything derived from the input. The redirect example above answers302withLocation: /Home/Index, because discarding a badreturnUrland sending the user somewhere sensible is better behaviour on a login flow than refusing the request; the header-value examples answer400, because there is no sensible default for anX-Request-Id. Both are correct and the test has to match the one your endpoint chose - assert the substituted value, or assert400, but not "either", or the test passes against an endpoint that does neither. What is wrong in every case is a500, which means the value reached Kestrel's header collection and the validation did not run: a working denial of service even though nothing was injected. - A trailing newline on its own:
requestId=abc%0a. Assert400. This is the input a^...$-anchored .NET pattern accepts and\A...\zrejects, and it is the one case that distinguishes a correct anchor from an incorrect one. - The header name, if any is derived from input, read off a socket rather than through
WebApplicationFactory- a test client shows the framework's parsed header map, not the serialised bytes. - The payloads that carry no newline, because nothing in the runtime will object to them for you:
filename=a.pdf%22;%20filename*=UTF-8%27%27evil.htmlon the download endpoint, an unlistedOriginon the CORS endpoint, and acharsetoutside the allowlist. Assert400or the safe default, as the endpoint specifies.
Common Pitfalls
- Assuming Kestrel's header validation is a universal safety net: on .NET 10,
Response.Headers["X"] = "a\r\nb"throwsInvalidOperationException: Invalid non-ASCII or control character in header: 0x000Dimmediately, for the header name as well as the value, andResponse.ContentTypegoes through the same check - so it is tempting to skip explicit validation for "one-off" header writes. Two problems. The check belongs to Kestrel'sHttpResponseHeaders, not toHttpResponse, so a component holding a plainHeaderDictionary-IHttpResponseFeature.Headersreplaced by test infrastructure, a reverse-proxy or logging middleware that reconstructs headers from raw request data, content written straight toResponse.Body, or a different server implementation - does not have it. And where it does fire, the result is an unhandled500on an attacker-chosen request rather than a fix; validate first so the answer is a deliberate400. - Carrying "the runtime throws" across to another ecosystem: Kestrel's behaviour here is the strictest of the mainstream servers and it is not the norm. A servlet container does not throw - measured on Tomcat 10.1.59, Tomcat 11.0.25 and Jetty 12.1.12, each CR and LF in a header value - along with the other control characters - is silently replaced with a space as the header is written, with no exception and no log line. PHP 8.5 discards the header entirely and carries on. A reviewer who learned this weakness on ASP.NET Core will look for a
500as the tell that the sink is live, and on those platforms there is no tell at all. See the main CWE-113 page for the runtime-by-runtime table. - Encoding a value that is not a URL component:
UrlEncoder.Default.Encode()is for embedding a value inside a URL, and applying it to a whole URL or to a header value breaks the thing it was supposed to protect.Encode("/account?x=1")returns%2Faccount%3Fx%3D1, which a browser resolves as a relative path segment, so every legitimate redirect lands on/%2Faccount%3Fx%3D1;Encode("Mozilla/5.0 (Windows NT 10.0; Win64; x64)")returns a header value that whatever reads it now has to know to decode. Both measured on .NET 10. Every malicious-input test still passes, because a rejected value never reaches the encoder - the only assertion that catches it is following an accepted redirect to its destination, or reading back the header you set. - Treating
Url.IsLocalUrl()as only an open-redirect check: on .NET 10 it is both. It rejects protocol-relative and absolute URLs (CWE-601) and returnsfalsefor any value containing a control character, verified across\r,\n,\r\n, tab and NUL. So the "strip CRLF separately afterwards" step that older guidance pairs with it is dead code. Do not read that as a reason to skip validation elsewhere: the guarantee isIsLocalUrl's, so it travels only with the values you actually pass through it, and a hand-written reimplementation of the same predicate has whichever behaviour it was written with.