Analyzer rule
Spec: LC018 - Avoid FromSqlRaw with Interpolated Strings
EF Core LINQ performance analyzer and Roslyn analyzer for catching query issues at compile time.
Spec: LC018 - Avoid FromSqlRaw with Interpolated Strings
Goal
Detect FromSqlRaw(...) and SqlQueryRaw<T>(...) calls where the SQL string is an interpolated string or contains non-constant concatenations. This pattern is a major security risk because it can lead to SQL injection.
The Problem
FromSqlRaw expects a raw SQL string and a separate array of parameters. If a developer uses string interpolation ($"{var}"), the variable is embedded directly into the SQL string before it reaches EF Core, bypassing parameterization.
Example Violation
// Violation: Potential SQL Injection
var name = "admin'; DROP TABLE Users; --";
var users = db.Users.FromSqlRaw($"SELECT * FROM Users WHERE Name = '{name}'").ToList();
The Fix
Use FromSqlInterpolated (or FromSql in newer EF Core versions) which automatically handles parameterization for interpolated strings.
// Correct: Safely parameterized
var name = "admin";
var users = db.Users.FromSqlInterpolated($"SELECT * FROM Users WHERE Name = {name}").ToList();
Analyzer Logic
ID: LC018
Category: Security
Severity: Warning
Notes
LC018 covers two raw-SQL query entry points: FromSqlRaw(...) on an IQueryable/DbSet, and SqlQueryRaw<T>(...) on the DbContext.Database facade (db.Database.SqlQueryRaw<int>($"..."), the EF7+ scalar/keyless query form). Both take a raw string sql and are equal injection sinks. Their safe siblings — FromSqlInterpolated and SqlQuery<T> (which take a FormattableString and parameterize the holes) — are not flagged. The diagnostic message names the matching safe sibling for the call it found.
LC018 reports direct interpolated strings with non-constant interpolation holes and direct non-constant string concatenations passed to the sql argument of FromSqlRaw(...) or SqlQueryRaw(...), including named sql: arguments. The constant-only safe-shape gate fires only when every hole is a compile-time constant: const fields, const locals, numeric/string literals, nameof(...), and arithmetic over const operands all stay quiet. A static readonly field is not a compile-time constant in Roslyn’s IOperation.ConstantValue sense and is treated as potentially unsafe — its value is observable and can be reassigned via reflection or non-trivial static initializers. A single non-constant hole in an otherwise constant interpolation still triggers, because a safe neighbour does not launder runtime data. The fixer is intentionally narrow: it is offered only for direct interpolated-string calls with no additional raw SQL parameters, where every interpolation hole is in a likely SQL value position such as after =, <, >, LIKE, or IN (. It rewrites FromSqlRaw(...) to FromSqlInterpolated(...) and SqlQueryRaw<T>(...) to SqlQuery<T>(...). It is not offered when an interpolation hole appears inside SQL single quotes, such as '{name}'; remove the SQL quotes manually before using the safe interpolated API so EF can parameterize the value correctly. It is also not offered when a hole appears where SQL expects an identifier or structural fragment, such as SELECT {columnName}, FROM {tableName}, WHERE {columnName} = 1, EXEC {procedureName}, JOIN {tableName}, or ORDER BY {columnName}; those cases still report because raw SQL structure built from runtime data needs manual allow-listing or query redesign rather than value parameterization.
Test Cases
Violations
db.Users.FromSqlRaw($"SELECT * FROM Users WHERE Id = {id}");
db.Users.FromSqlRaw("SELECT * FROM Users WHERE Name = " + name);
Valid
var sql = $"SELECT * FROM Users WHERE Id = {id}";
db.Users.FromSqlRaw(sql); // LC037 owns constructed aliases that flow into raw SQL
db.Users.FromSqlRaw("SELECT * FROM Users");
db.Users.FromSqlRaw("SELECT * FROM Users WHERE Id = {0}", id);
db.Users.FromSqlInterpolated($"SELECT * FROM Users WHERE Id = {id}");
db.Database.SqlQuery<int>($"SELECT Id FROM Users WHERE Id = {id}");
Rule Boundary
- LC018 owns direct interpolated-string holes containing runtime data and direct non-constant
+concatenation passed straight intoFromSqlRaw(...). It inspects thesqlargument expression at the call site only — it does not follow the expression back through a local variable or parameter. - LC018 requires the matched method to come from the EF Core namespace boundary (
Microsoft.EntityFrameworkCoreor a child namespace), not a same-named lookalike namespace. - LC018 requires a queryable/DbSet receiver for
FromSqlRaw(...)or aDatabaseFacadereceiver forSqlQueryRaw<T>(...), so same-named helpers in the EF namespace on unrelated receiver types stay quiet. - LC018 fires regardless of how the receiver is reached: the instance call
dbSet.FromSqlRaw(...), theDbContext.Set<T>().FromSqlRaw(...)shape, and the static-extension formRelationalQueryableExtensions.FromSqlRaw(query, ...)all participate; the safe siblingFromSqlInterpolatedstays quiet on every variant. - LC037 owns the upstream construction flows that LC018 deliberately ignores: a local
var sql = $"..."orvar sql = "..." + xthat later flows intoFromSqlRaw(sql), plusstring.Format(...),string.Concat(...),StringBuilder.Append(...), and+=accumulation. Aliased construction never double-fires LC018; LC037 picks it up, so both rules can stay narrow.
Fixer Behavior
- Direct
FromSqlRaw($"... {value} ...")calls with no extra raw SQL parameters becomeFromSqlInterpolated($"... {value} ..."). - Direct
SqlQueryRaw<T>($"... {value} ...")calls with no extra raw SQL parameters becomeSqlQuery<T>($"... {value} ..."); the generic type argument is preserved. - Direct
+concatenation still reports but is not auto-fixed, because the safe rewrite usually needs a new interpolated string or raw parameter list. - Calls with additional raw SQL parameters are not auto-fixed; keep the SQL text constant and pass values through the raw API’s parameter list.
- Interpolations inside SQL string literals, such as
'{name}', are not auto-fixed. Remove the SQL quotes first, then useFromSqlInterpolated(...)orSqlQuery<T>(...). - Interpolations outside likely SQL value positions, such as
FROM {tableName},SELECT {columnName},WHERE {columnName} = 1, orEXEC {procedureName}, are not auto-fixed.FromSqlInterpolated(...)would parameterize the hole as a value, not a table, column, schema, procedure, or ordering fragment.