Previous articles in the series focused on cryptography and authentication — certificates, tokens, protocols. Now we make a shift: the most common vulnerabilities in real web applications do not come from weak cryptography, but from unvalidated input, confused context, and implicit trust in external data.
CSRF, XSS, and Injection consistently occupy the top positions in the OWASP Top 10. Not because they are hard to understand, but because they are easy to overlook when you are focused on functionality. This article covers all three: how the attack works, what you get wrong if you don't defend yourself, and the complete implementation of the defense in ASP.NET Core.
1. CSRF — Cross-Site Request Forgery
How the attack works
CSRF exploits the fact that the browser automatically sends session cookies with any request to a domain, regardless of where the request comes from. An attacker builds a page that makes a request to your application on behalf of the authenticated user — without them knowing.
Concrete scenario:
- The user is authenticated on
bank.com— the session is active, the cookie is in the browser - The user visits
evil.com(a link from an email, for example) evil.comcontains a hidden form that makes a POST tobank.com/transfer- The browser automatically sends the session cookie from
bank.com - The server sees a valid authenticated request — and executes the transfer
<!-- Attacker's page: evil.com -->
<form action="https://bank.com/transfer" method="POST" id="csrf-form">
<input type="hidden" name="amount" value="10000" />
<input type="hidden" name="to" value="attacker-account" />
</form>
<script>document.getElementById('csrf-form').submit();</script>
Defense: Antiforgery Tokens in ASP.NET Core
The standard mechanism: the server generates a unique token per session, includes it in the form, and verifies on every POST that the token in the form matches the one in the cookie/session. The attacker on evil.com cannot read the token from your page's DOM (same-origin policy), so cannot include it in the forged request.
Configuration in Program.cs
builder.Services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-TOKEN"; // for AJAX
options.Cookie.Name = ".myapp.csrf";
options.Cookie.HttpOnly = false; // JavaScript needs to read the token
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict;
});
In Razor Pages — automatic
Razor Pages include antiforgery automatically for any form with method="post":
<!-- Razor Page -->
<form method="post">
<!-- @Html.AntiForgeryToken() is injected automatically -->
<input asp-for="Amount" />
<button type="submit">Transfer</button>
</form>
In MVC Controllers
// Apply antiforgery validation globally on all controllers
builder.Services.AddControllersWithViews(options =>
{
options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute());
});
// OR per-action
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Transfer(TransferDto dto) { ... }
// Explicitly exclude where not needed (API endpoints with JWT)
[HttpPost]
[IgnoreAntiforgeryToken]
public IActionResult ApiEndpoint([FromBody] Dto dto) { ... }
AJAX — reading token from cookie and sending it via header
// JavaScript
function getCsrfToken() {
return document.cookie
.split('; ')
.find(row => row.startsWith('.myapp.csrf='))
?.split('=')[1];
}
await fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken()
},
body: JSON.stringify({ amount: 100, to: 'account-123' })
});
SameSite Cookie — complementary defense
SameSite=Strict on the session cookie provides an additional layer: the browser does not send the cookie on cross-site requests. It does not replace antiforgery tokens (it has exceptions), but complements them:
builder.Services.ConfigureApplicationCookie(options =>
{
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.HttpOnly = true;
});
APIs with JWT are not vulnerable to CSRF if the token is stored in memory (not in a cookie). A cross-site request cannot read the JWT token from the JavaScript memory of another tab — same-origin policy prevents this. If you store JWT in a cookie, you are vulnerable again and need antiforgery.
2. XSS — Cross-Site Scripting
How the attack works
XSS injects malicious JavaScript code into your page, which runs in the victim's browser with full access to the DOM, cookies, localStorage, and can make authenticated requests on behalf of the user.
There are three types:
- Reflected XSS — the payload comes from the URL/request and is immediately reflected in the response (
/search?q=<script>...</script>) - Stored XSS — the payload is saved in the database and later displayed to other users (comments, messages, profiles)
- DOM-based XSS — the payload is processed by client-side JavaScript without reaching the server (
document.write(location.hash))
The impact can include: session theft, keylogging, redirect to phishing, page modification, or performing actions on behalf of the user.
Defense 1: Output Encoding — first line
Razor automatically does output encoding for any @ expression. This is the main defense:
<!-- SAFE: Razor encodes automatically -->
<p>Welcome, @Model.UserName!</p>
<!-- If UserName = "<script>alert(1)</script>"
output: <script>alert(1)</script> -->
<!-- DANGEROUS: Html.Raw disables encoding -->
<p>@Html.Raw(Model.UserDescription)</p>
<!-- Use ONLY for trusted HTML content -->
Different contexts require different encoding — Razor handles this automatically:
<!-- HTML context — HTMLEncoder -->
<div>@Model.Name</div>
<!-- HTML attribute context — HTMLEncoder -->
<input value="@Model.Name" />
<!-- JavaScript context — JavaScriptEncoder (IMPORTANT!) -->
<script>
var name = "@Json.Serialize(Model.Name)";
// Not: var name = "@Model.Name"; -- insufficient in JS context
</script>
<!-- URL context -->
<a href="/profile?id=@Uri.EscapeDataString(Model.Id)">Profile</a>
Defense 2: Content Security Policy (CSP)
CSP is an HTTP header that tells the browser exactly from where it is allowed to load resources (scripts, styles, images). Even if an attacker manages to inject a <script> tag, the browser will block it if the source is not on the whitelist.
// Program.cs — add CSP header to all responses
app.Use(async (context, next) =>
{
context.Response.Headers.Append(
"Content-Security-Policy",
string.Join("; ",
"default-src 'self'",
"script-src 'self' https://cdn.jsdelivr.net",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: https:",
"connect-src 'self'",
"frame-ancestors 'none'", // protection also against Clickjacking
"base-uri 'self'",
"form-action 'self'"
));
await next();
});
Avoid
'unsafe-inline'for scripts. If you have inline scripts in Razor pages, migrate them to external.jsfiles.'unsafe-inline'for script-src negates much of CSP protection.
Defense 3: HTML Sanitization for rich-text content
When users can enter HTML (WYSIWYG editors, formatted comments), you cannot encode everything — you must sanitize: keep allowed HTML tags and remove dangerous ones.
dotnet add package HtmlSanitizer
using Ganss.Xss;
public class HtmlSanitizationService
{
private readonly HtmlSanitizer _sanitizer;
public HtmlSanitizationService()
{
_sanitizer = new HtmlSanitizer();
// Allow only safe tags
_sanitizer.AllowedTags.Clear();
foreach (var tag in new[] { "p", "br", "strong", "em",
"ul", "ol", "li", "a", "h2", "h3", "blockquote" })
{
_sanitizer.AllowedTags.Add(tag);
}
// Allow safe attributes
_sanitizer.AllowedAttributes.Clear();
_sanitizer.AllowedAttributes.Add("href");
_sanitizer.AllowedAttributes.Add("class");
// Allow only HTTPS in href
_sanitizer.AllowedSchemes.Clear();
_sanitizer.AllowedSchemes.Add("https");
}
public string Sanitize(string html) => _sanitizer.Sanitize(html);
}
Defense 4: Additional Security Headers
app.Use(async (context, next) =>
{
var headers = context.Response.Headers;
// Prevent MIME sniffing
headers.Append("X-Content-Type-Options", "nosniff");
// Prevent display in iframe (Clickjacking)
headers.Append("X-Frame-Options", "DENY");
// Enforce HTTPS (HSTS)
headers.Append("Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload");
// Control information in Referrer header
headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
// Disable dangerous browser features
headers.Append("Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=()");
await next();
});
3. Injection
Injection occurs when user-supplied data is interpreted as instructions — SQL, shell commands, LDAP expressions, XML, etc. The most well-known: SQL Injection.
SQL Injection — how it works
// VULNERABLE — direct concatenation
var query = $"SELECT * FROM Users WHERE Username = '{username}'";
// If username = "admin' OR '1'='1"
// query becomes: SELECT * FROM Users WHERE Username = 'admin' OR '1'='1'
// Returns ALL users
More serious — with SQL Server:
// username = "admin'; DROP TABLE Users; --"
// query: SELECT * FROM Users WHERE Username = 'admin'; DROP TABLE Users; --'
Defense 1: Parameterization — the only correct defense
// SAFE — parameters with ADO.NET
using var cmd = new SqlCommand(
"SELECT * FROM Users WHERE Username = @username", connection);
cmd.Parameters.AddWithValue("@username", username);
// The parameter is treated as DATA, not SQL — injection is impossible
// SAFE — Entity Framework Core (automatic parameterization)
var user = await context.Users
.Where(u => u.Username == username)
.FirstOrDefaultAsync();
// EF Core automatically generates parameterized query
// SAFE — Dapper with parameters
var user = await connection.QueryFirstOrDefaultAsync<User>(
"SELECT * FROM Users WHERE Username = @Username",
new { Username = username });
// DANGEROUS — Dapper with interpolation
var user = await connection.QueryFirstOrDefaultAsync<User>(
$"SELECT * FROM Users WHERE Username = '{username}'");
// Concatenation = vulnerability, regardless of library
Defense 2: Raw SQL in EF Core — with care
When you need complex queries in EF Core and use FromSqlRaw, parameterization is your responsibility:
// DANGEROUS
var users = context.Users
.FromSqlRaw($"SELECT * FROM Users WHERE Username = '{username}'");
// SAFE — FromSqlInterpolated (automatic parameterization from interpolations)
var users = context.Users
.FromSqlInterpolated($"SELECT * FROM Users WHERE Username = {username}");
// SAFE — FromSqlRaw with explicit parameters
var users = context.Users
.FromSqlRaw("SELECT * FROM Users WHERE Username = {0}", username);
Command Injection
The same principle applies to shell commands. If your application executes external processes with user input:
// DANGEROUS — direct input in shell command
Process.Start("bash", $"-c \"convert {userInput} output.pdf\"");
// userInput = "input.jpg; rm -rf /" = disaster
// SAFE — separate arguments, no shell
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = "convert",
// Arguments as array, not concatenated in shell string
ArgumentList = { validatedInputPath, "output.pdf" },
UseShellExecute = false // DO NOT go through shell
}
};
process.Start();
LDAP Injection
If you use LDAP for authentication or Active Directory directory search:
// DANGEROUS
var filter = $"(sAMAccountName={username})";
// username = "*)(|(objectClass=*" bypasses authentication
// SAFE — escape special LDAP characters
var safeUsername = username
.Replace("\\", "\\5c")
.Replace("*", "\\2a")
.Replace("(", "\\28")
.Replace(")", "\\29")
.Replace("\0", "\\00");
var filter = $"(sAMAccountName={safeUsername})";
Path Traversal
A special case of injection: user input influences a file path:
// DANGEROUS
var filePath = Path.Combine(baseDir, userInput);
// userInput = "../../etc/passwd" → reads files outside the allowed directory
// SAFE — validate that the final path is inside the allowed directory
public static string SafeCombine(string baseDirectory, string userInput)
{
var fullPath = Path.GetFullPath(
Path.Combine(baseDirectory, userInput));
if (!fullPath.StartsWith(
Path.GetFullPath(baseDirectory),
StringComparison.OrdinalIgnoreCase))
{
throw new UnauthorizedAccessException(
"Path traversal detected.");
}
return fullPath;
}
4. Input Validation — general principles
Beyond the specific techniques for each vulnerability, there are principles that reduce the attack surface in general:
Validation with Data Annotations and FluentValidation
public class TransferDto
{
[Required]
[StringLength(50, MinimumLength = 3)]
[RegularExpression(@"^[a-zA-Z0-9\-]+$")] // character whitelist
public string ToAccount { get; set; } = default!;
[Range(0.01, 100_000)]
public decimal Amount { get; set; }
}
// Program.cs — automatically returns 400 on failed validation
builder.Services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var errors = context.ModelState
.Where(e => e.Value?.Errors.Count > 0)
.ToDictionary(
k => k.Key,
v => v.Value!.Errors
.Select(e => e.ErrorMessage).ToArray());
return new BadRequestObjectResult(new { errors });
};
});
Whitelist vs. blacklist principle
Always validate by whitelist (what is allowed), not by blacklist (what is forbidden). A blacklist will always miss something:
// WEAK — blacklist: block known variants, not all
if (input.Contains("<script>") || input.Contains("javascript:"))
return BadRequest();
// Bypass: <SCRIPT>, <scr\nipt>, <img onerror=...>
// CORRECT — whitelist: allow only what you know is safe
if (!Regex.IsMatch(input, @"^[a-zA-Z0-9 \-_\.]+$"))
return BadRequest("Input contains disallowed characters.");
5. Defense in Depth — multiple layers
No single technique is sufficient. The correct model is defense in depth: multiple independent layers, so that failure of one does not compromise the entire system.
| Vulnerability | Layer 1 | Layer 2 | Layer 3 |
|---|---|---|---|
| CSRF | Antiforgery tokens | SameSite=Strict on cookie | Origin/Referer header check |
| XSS | Output encoding (Razor automatic) | Content Security Policy | HtmlSanitizer for rich-text |
| SQL Injection | Parameterization / ORM | Least privilege principle on DB user | WAF (Web Application Firewall) |
| Command Injection | Separate arguments (no shell) | Whitelist validation of input | Sandbox / container without privileges |
| Path Traversal | SafeCombine with Path.GetFullPath | File extension validation | Restrictive filesystem permissions |
6. Automated vulnerability testing
Defense is incomplete without testing. Some useful tools:
- OWASP ZAP — automatic web vulnerability scanner, free, with CI/CD integration
- dotnet-retire — detects .NET dependencies with known vulnerabilities
- Snyk / Dependabot — automatic alerts for vulnerabilities in NuGet packages
- Security Code Scan — Roslyn analyzer that detects vulnerable patterns in C# code at compile time
# Add Security Code Scan as analyzer
dotnet add package SecurityCodeScan.VS2019
# Automatically detects: SQL Injection, XSS, Path Traversal, missing CSRF
# in C# code at build time, as compile warnings
Regression test for XSS — NUnit example
[TestFixture]
public class XssProtectionTests
{
private readonly HtmlSanitizationService _sanitizer = new();
[TestCase("<script>alert(1)</script>")]
[TestCase("<img src=x onerror=alert(1)>")]
[TestCase("<svg onload=alert(1)>")]
[TestCase("javascript:alert(1)")]
[TestCase("<a href=\"javascript:void(0)\" onclick=\"alert(1)\">click</a>")]
public void Sanitize_RemovesXssPayloads(string maliciousInput)
{
var result = _sanitizer.Sanitize(maliciousInput);
Assert.That(result, Does.Not.Contain("<script"));
Assert.That(result, Does.Not.Contain("onerror"));
Assert.That(result, Does.Not.Contain("onload"));
Assert.That(result, Does.Not.Contain("javascript:"));
}
}
7. Common problems and their solutions
| Problem | Likely cause | Solution |
|---|---|---|
| Invalid AntiForgery token on POST | Request comes from another tab / expired session / SPA without header | Check that SPA sends X-CSRF-TOKEN header; reload token after re-authentication |
| CSP blocks legitimate scripts | Too restrictive whitelist or inline scripts | Use Content-Security-Policy-Report-Only first to see what is blocked without breaking UI |
| EF Core generates vulnerable query | FromSqlRaw with string interpolation |
Replace with FromSqlInterpolated or explicit parameters |
| XSS in JSON content returned by API | API returns unencoded HTML in JSON fields | Encode HTML before storage OR before display; do not rely on API client to do this |
| Path traversal in file uploads | Filename from form used directly | Ignore original filename; generate a GUID for stored filename |
8. Production checklist
- ✅
AutoValidateAntiforgeryTokenAttributeapplied globally on all MVC controllers - ✅ Session cookie with
HttpOnly=true,Secure=true,SameSite=Strict - ✅ Content Security Policy configured and tested (no
'unsafe-inline'for scripts) - ✅ Security headers:
X-Content-Type-Options,X-Frame-Options,HSTS - ✅ Zero string concatenations in SQL queries — 100% parameterized or ORM
- ✅
Html.Raw()used exclusively for internally generated content, never for user input - ✅ HtmlSanitizer for any field that accepts HTML from users
- ✅ Whitelist validation on all inputs with
[RegularExpression]or FluentValidation - ✅ File uploads: filename ignored, extension validated, stored with GUID
- ✅ SecurityCodeScan or equivalent in CI/CD pipeline
- ✅ OWASP ZAP or automated scan in staging before each release
- ✅ Least privilege principle on database user — SELECT/INSERT/UPDATE, never DROP or admin
Conclusion
CSRF, XSS, and Injection have been around for nearly three decades and still dominate vulnerability reports. Not because they are sophisticated — but because they arise from small decisions made under time pressure: a "temporary" string concatenation, an Html.Raw() for convenience, a cookie without SameSite.
ASP.NET Core has excellent tooling for all three: Razor encodes automatically, antiforgery is built-in, EF Core parameterizes by default. The vast majority of vulnerabilities appear when you circumvent these mechanisms, not when you use them.
The series continues with Secure Blazor WebAssembly — a context where many of the above principles apply differently, because the code runs in the client's browser.