Every security vulnerability that involves user input — SQL injection, XSS, path traversal, command injection — starts the same way: trusting data that shouldn't be trusted. Input validation and sanitisation are your first line of defence. They're not glamorous, but they prevent the majority of real-world attacks.
Validation vs Sanitisation
Validation checks whether input meets expected criteria and rejects it if it doesn't. "Is this a valid email address?" "Is this number between 1 and 100?"
Sanitisation modifies input to remove or encode dangerous content. "Strip HTML tags from this comment." "Encode angle brackets before rendering."
Prefer validation over sanitisation. Rejecting bad input is safer than trying to clean it up.
Data Annotations
The simplest validation approach in ASP.NET Core uses data annotations:
public class CreateUserRequest
{
[Required]
[StringLength(100, MinimumLength = 2)]
public string Name { get; set; } = string.Empty;
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
[Range(13, 150)]
public int Age { get; set; }
[RegularExpression(@"^[a-zA-Z0-9_-]+$",
ErrorMessage = "Username can only contain letters, numbers, hyphens, and underscores.")]
[StringLength(30, MinimumLength = 3)]
public string Username { get; set; } = string.Empty;
}
ASP.NET Core validates automatically when model binding:
app.MapPost("/users", (CreateUserRequest request) =>
{
// If we get here, validation passed
return Results.Created($"/users/{request.Username}", request);
});
Invalid requests receive a 400 response with validation errors automatically.
FluentValidation for Complex Rules
Data annotations struggle with conditional validation, cross-property validation, and async rules. FluentValidation handles these cleanly:
public class OrderValidator : AbstractValidator<CreateOrderRequest>
{
public OrderValidator()
{
RuleFor(x => x.CustomerEmail)
.NotEmpty()
.EmailAddress();
RuleFor(x => x.Items)
.NotEmpty()
.WithMessage("Order must contain at least one item.");
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.Quantity)
.GreaterThan(0)
.LessThanOrEqualTo(100);
item.RuleFor(i => i.ProductId)
.NotEmpty();
});
RuleFor(x => x.ShippingAddress)
.NotNull()
.When(x => x.DeliveryMethod == DeliveryMethod.Shipping);
RuleFor(x => x.DiscountCode)
.MustAsync(async (code, cancellation) =>
{
// Validate against database
return await IsValidDiscountCode(code);
})
.When(x => !string.IsNullOrEmpty(x.DiscountCode));
}
}
Preventing SQL Injection
Parameterised queries are non-negotiable. Never concatenate user input into SQL:
// DANGEROUS - SQL injection vulnerability
var sql = $"SELECT * FROM Users WHERE Name = '{name}'";
// SAFE - parameterised query with Dapper
var user = await connection.QuerySingleOrDefaultAsync<User>(
"SELECT * FROM Users WHERE Name = @Name",
new { Name = name });
// SAFE - Entity Framework Core (parameterised by default)
var user = await context.Users
.Where(u => u.Name == name)
.FirstOrDefaultAsync();
EF Core parameterises LINQ queries automatically. Even raw SQL methods provide parameterisation:
var users = await context.Users
.FromSqlInterpolated($"SELECT * FROM Users WHERE Name = {name}")
.ToListAsync();
The interpolated string is not concatenated — EF Core extracts the parameters.
Preventing XSS
Razor views HTML-encode output by default:
<!-- Automatically encoded - safe -->
<p>@Model.UserComment</p>
<!-- Explicitly unencoded - dangerous unless you've sanitised -->
<p>@Html.Raw(Model.UserComment)</p>
Never use Html.Raw with unsanitised user input. If you must render HTML from users (e.g., a rich text editor), sanitise it with a library like HtmlSanitizer:
using Ganss.Xss;
var sanitiser = new HtmlSanitizer();
// Only allow safe tags and attributes
sanitiser.AllowedTags.Clear();
sanitiser.AllowedTags.Add("p");
sanitiser.AllowedTags.Add("b");
sanitiser.AllowedTags.Add("i");
sanitiser.AllowedTags.Add("a");
sanitiser.AllowedTags.Add("ul");
sanitiser.AllowedTags.Add("li");
sanitiser.AllowedAttributes.Clear();
sanitiser.AllowedAttributes.Add("href");
var clean = sanitiser.Sanitize(userInput);
Path Traversal Prevention
When users provide file names, validate they don't escape the intended directory:
app.MapGet("/files/{fileName}", (string fileName) =>
{
// Remove any path traversal attempts
var safeName = Path.GetFileName(fileName);
if (string.IsNullOrEmpty(safeName) || safeName != fileName)
return Results.BadRequest("Invalid file name");
var basePath = Path.GetFullPath("/app/uploads");
var fullPath = Path.GetFullPath(Path.Combine(basePath, safeName));
// Verify the resolved path is still within the base directory
if (!fullPath.StartsWith(basePath))
return Results.BadRequest("Invalid file path");
if (!File.Exists(fullPath))
return Results.NotFound();
return Results.File(fullPath);
});
Path.GetFullPath resolves .. segments, so comparing the result against the base path catches traversal attempts.
Custom Validation Attributes
For domain-specific validation, create custom attributes:
public class NotDisposableEmailAttribute : ValidationAttribute
{
private static readonly HashSet<string> DisposableDomains =
new(StringComparer.OrdinalIgnoreCase)
{
"mailinator.com", "guerrillamail.com", "tempmail.com"
};
protected override ValidationResult? IsValid(
object? value, ValidationContext context)
{
if (value is string email)
{
var domain = email.Split('@').LastOrDefault();
if (domain is not null && DisposableDomains.Contains(domain))
{
return new ValidationResult(
"Disposable email addresses are not allowed.");
}
}
return ValidationResult.Success;
}
}
Wrapping Up
Validate early, validate strictly, and prefer rejection over sanitisation. Use parameterised queries for SQL, let Razor handle HTML encoding, and validate file paths against a base directory. These aren't exciting techniques, but they prevent the attacks that actually happen in production.