Injection and Output Encoding
Parameterised SQL, path traversal and HTML encoding: one rule, treat every string from outside as data, never as code.
context.Blogs.FromSql($"SELECT * FROM blogs WHERE author = {author}")Injection is one mistake in several costumes. A string arrives from outside your program and is handed to something that reads part of it as instructions. The database reads SQL. The file system reads ... The browser reads <script>. Nothing is broken in any of them; they did what the text told them to do.
SQL
Never build a query by joining strings. Send the value separately as a parameter, a placeholder the database fills in after it has finished parsing the SQL. In EF Core, FromSql and ExecuteSql take an interpolated string and turn every hole into a parameter automatically, so $"... WHERE author = {author}" is safe. The longhand is FromSqlRaw("... WHERE author = @p0", parameter), which produces the same query. They differ in one way that matters: FromSqlRaw drops plain strings straight into the SQL, so it is the only one of the pair you can get wrong.
Paths
A file name from a request may contain ../. Resolve the combined path with Path.GetFullPath, then check the result still starts with your directory. Validating the input string instead of the resolved path is how traversal bugs survive.
HTML
Razor HTML-encodes every @ expression, so untrusted text is shown rather than run. Html.Raw and HtmlString opt out of that; never pass them anything a user typed. Outside Razor, use HtmlEncoder.Default.Encode, and encode when you write output, not when you store data.
Example
using System.Text.Encodings.Web;
// A comment typed by a stranger. It is data, not markup.
string comment = "<script>alert(1)</script>";
// Encoding turns the angle brackets into characters a browser displays,
// instead of tags a browser runs.
Console.WriteLine(HtmlEncoder.Default.Encode(comment));
// The only directory this program is allowed to read from.
string root = Path.GetFullPath("uploads");
Console.WriteLine(ResolveInside(root, "notes.txt") is not null);
Console.WriteLine(ResolveInside(root, "../appsettings.json") is not null);
// Returns the full path when it stays under root, and null when it escapes.
static string? ResolveInside(string root, string requested)
{
// Resolve first. "uploads/../appsettings.json" collapses to a path
// outside uploads, and that is the string worth checking.
string candidate = Path.GetFullPath(Path.Combine(root, requested));
string prefix = root + Path.DirectorySeparatorChar;
return candidate.StartsWith(prefix, StringComparison.Ordinal) ? candidate : null;
}
// Output:
// <script>alert(1)</script>
// True
// FalseWhen to use it
- Filter a list by a search term a user typed without opening a SQL injection hole.
- Serve a file by name from one upload directory and nothing above it.
- Display a user's comment on a page without their markup becoming part of the page.
More examples
Parameters in EF Core, shorthand and longhand
The first two calls are interchangeable; recognising both matters because older code uses FromSqlRaw everywhere. The third shows the one case parameters cannot cover: identifiers. An allow-list is the answer there, and it is also cheaper, because it stops a caller sorting by an unindexed column.
// Both of these produce the same parameterised SQL. The value never becomes
// part of the statement the database parses.
string author = request.Author;
// Shorthand: the interpolated hole becomes a DbParameter.
List<Post> byAuthor = await context.Posts
.FromSql($"SELECT * FROM posts WHERE author = {author}")
.ToListAsync(cancellationToken);
// Longhand: the placeholder and the parameter are written out.
List<Post> same = await context.Posts
.FromSqlRaw("SELECT * FROM posts WHERE author = {0}", author)
.ToListAsync(cancellationToken);
// A column name cannot be a parameter, because the database needs it to parse
// the statement. So it must come from a list you control, never from input.
string[] sortable = ["title", "published_on"];
string column = sortable.Contains(request.SortBy) ? request.SortBy : "title";
List<Post> sorted = await context.Posts
.FromSqlRaw($"SELECT * FROM posts ORDER BY {column}")
.ToListAsync(cancellationToken);Raw ADO.NET against Postgres
Below EF Core the principle is identical: the command text is fixed, parameters carry the data. An author value of "x'; DROP TABLE posts; --" is searched for literally and finds nothing. Reach for this when you are using Dapper or a raw command and there is no interpolated-string helper doing it for you.
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);
// The SQL is a constant. The value travels beside it.
await using var command = new NpgsqlCommand(
"SELECT title FROM posts WHERE author = @author LIMIT 50", connection);
command.Parameters.AddWithValue("author", author);
List<string> titles = [];
await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
titles.Add(reader.GetString(0));
}Razor encodes by default; Html.Raw does not
The default is the safe one, which is why most Razor pages have no XSS problem. The bug appears the day someone reaches for Html.Raw to make stored markup render. Reach for Html.Raw only for strings your own code built, and store user-supplied rich text as Markdown so a parser can strip embedded HTML.
@{
string comment = "<script>alert(1)</script>";
string trustedMarkup = "<em>Editor's pick</em>";
}
@* Encoded. The page shows the text, including the angle brackets. *@
<p>@comment</p>
@* Also encoded: tag helpers and attributes go through the same encoder. *@
<div data-comment="@comment"></div>
@* Not encoded. Only ever pass markup you produced yourself. *@
<p>@Html.Raw(trustedMarkup)</p>
Discussion