Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix server-side template injection in C# and ASP.NET Core

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Don't run user input as code: CSharpScript.EvaluateAsync compiles the 'template' as C#. Render user-authored templates with a secure engine such as Fluid, whose member access is limited to the types you register, so it cannot reach code, reflection or the environment. For plain values, bind them to a constant template. Do not denylist Process or GetType; Environment.GetEnvironmentVariable slips past.

On this page

Steps

  1. Find every CSharpScript.EvaluateAsync, runtime Razor compile, or dynamic Type.GetType/Activator whose program or template is built from request data.
  2. When the input is a value, bind it to a constant template (Fluid SetValue) or build the string with an HtmlEncoder; never compile it.
  3. When users genuinely author templates, render them with Fluid and register only the model types the template needs; turn TryParse errors and render exceptions into a 400.
  4. Add regression tests with this card's payloads (an Environment.GetEnvironmentVariable read, a string-literal breakout and a Type.GetType reflection chain) next to functional tests for legitimate templates and non-ASCII data.
  5. Return a generic 400 and never echo the compiler or engine error to the client.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerable…/Templating/impl/VulnerableTemplateRenderer.csC#
public string RenderNotification(string template)
{
    // The user template is evaluated as a C# expression with references to
    // System, so it can read the environment, files or run processes.
    return RoslynScripting.Evaluate(template, _order);
}

With IMPL=vulnerable, 5 of 9 tests fail (5 exploit tests).

The fix: fixed template and data

Fixedsrc/Templating/impl/FixedTemplateRenderer.csC#
public string Greeting(string name)
{
    // Constant template; the name is a bound value, so "{System.Environment...}"
    // or "{{ ... }}" in it is printed literally, never evaluated.
    var context = new TemplateContext(Options);
    context.SetValue("name", name);
    return Parser.Parse("Hello {{ name }}!").Render(context);
}

With IMPL=fixed, 9 of 9 tests pass.

The fix: secure engine for user-authored templates

Fixedsrc/Templating/impl/FixedTemplateRenderer.csC#
public string RenderNotification(string template)
{
    if (!Parser.TryParse(template, out var parsed, out var error))
    {
        throw new ArgumentException(error);
    }
    return parsed.Render(new TemplateContext(_order, Options));
}

With IMPL=fixed, 9 of 9 tests pass.

A fix that looks safe but is not

Wrong fixsrc/Templating/impl/WrongTemplateRenderer.csC#
public string RenderNotification(string template)
{
    RejectBlocked(template); // same denylist; see Blocked above
    return RoslynScripting.Evaluate(template, _order);
}

With IMPL=wrong, 3 of 9 tests fail (3 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/Templating.Tests/ExploitTests.csC#
// Breaks out of the greeting's string literal to append C# that reads the secret.
private const string GreetingBreakout = "\" + System.Environment.GetEnvironmentVariable(\"SSTI_SECRET\") + \"";
private const string GreetingReflection =
    "\" + (string)System.Type.GetType(\"System.Environment\").GetMethod(\"GetEnvironmentVariable\", new System.Type[]{typeof(string)}).Invoke(null, new object[]{\"SSTI_SECRET\"}) + \"";
private const string EnvDirect = "System.Environment.GetEnvironmentVariable(\"SSTI_SECRET\")";
private const string EnvNoNamespace = "Environment.GetEnvironmentVariable(\"SSTI_SECRET\")";
private const string Reflection =
    "(string)System.Type.GetType(\"System.Environment\").GetMethod(\"GetEnvironmentVariable\", new System.Type[]{typeof(string)}).Invoke(null, new object[]{\"SSTI_SECRET\"})";

[Fact]
public async Task GreetingBreakoutDoesNotRevealTheSecret()
{
    var (_, body) = await GetGreeting(GreetingBreakout);
    Assert.DoesNotContain(Marker, body);
}

[Fact]
public async Task GreetingReflectionDoesNotRevealTheSecret()
{
    var (_, body) = await GetGreeting(GreetingReflection);
    Assert.DoesNotContain(Marker, body);
}

[Fact]
public async Task RenderEnvironmentReadDoesNotRevealTheSecret()
{
    var (_, body) = await PostRender(EnvDirect);
    Assert.DoesNotContain(Marker, body);
}

[Fact]
public async Task RenderEnvironmentReadWithoutNamespaceDoesNotRevealTheSecret()
{
    var (_, body) = await PostRender(EnvNoNamespace);
    Assert.DoesNotContain(Marker, body);
}

[Fact]
public async Task RenderReflectionDoesNotRevealTheSecret()
{
    var (_, body) = await PostRender(Reflection);
    Assert.DoesNotContain(Marker, body);
}

Pitfalls

  • CSharpScript.EvaluateAsync and runtime Razor compile and run the input as a program, so the 'template' has the whole base class library in reach: Environment, System.Diagnostics.Process, System.IO.File and reflection.
  • Fluid resolves only members of the model types you Register; it never invokes arbitrary methods, constructs types or reaches statics, so a user template cannot run code. Scriban is comparable when you scope the ScriptObject.
  • Register only the types the template needs (here OrderView). Do not register or import objects that transitively reach configuration, the environment or process APIs.
  • A Liquid {{ member }} is a member read, not a method call; Fluid treats a C# interpolation {..} and any reflection as plain text, so the exploit payloads render literally.
  • For a plain value, a constant Fluid template with SetValue, or string building with System.Text.Encodings.Web.HtmlEncoder, is the fix; never route the value through a scripting engine.
  • Do not denylist: Environment.GetEnvironmentVariable reads the secret with no Process, File or reflection token, and Type.GetType("System.Enviro" + "nment") rebuilds a blocked name at run time.
  • Microsoft.CodeAnalysis scripting evaluates asynchronously; if you must keep it, await it rather than blocking, and never on untrusted input.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs csharp-aspnetcore does this for you).
  2. Static analysis is weak here: Semgrep CE has no rule for CSharpScript or Fluid (p/csharp found nothing on src/), and CodeQL's cs/code-injection was not executed, so the regression tests are the control.
  3. Against your own endpoint, post a template of System.Environment.GetEnvironmentVariable("SECRET") and a Type.GetType reflection chain and confirm no secret comes back and Fluid renders them as text.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix server-side template injection in C# and ASP.NET Core
ToolRuleWhat it found here
Semgrepcsharp (p/csharp pack)No findings; Semgrep parsed the source cleanly (no primary-constructor issue, as the renderers use ordinary constructors). Semgrep CE ships no rule for Microsoft.CodeAnalysis.CSharp.Scripting (CSharpScript) or for Fluid template rendering, so the regression tests are the only automated control for this card.Checked: Semgrep 1.180.0 (CE) (semgrep scan --config p/csharp src/Templating)
CodeQLcs/code-injectionCWE-094 query for code generated from untrusted input, with ASP.NET minimal-API handler parameters as remote sources. Whether it models Microsoft.CodeAnalysis.CSharp.Scripting.CSharpScript.EvaluateAsync as a code-injection sink depends on the C# library flow models and was not verified here.Checked: github/codeql main; not executed

Libraries and versions

Libraries and versions
ComponentVersion
LanguageC# 14
Runtime.NET 10.0.12 (SDK 10.0.112)
FrameworkASP.NET Core 10 minimal API
Fluid.Core2.40.0
Microsoft.CodeAnalysis.CSharp.Scripting5.6.0
Microsoft.AspNetCore.Mvc.Testing10.0.12
xunit.v34.0.2 (Microsoft Testing Platform v2)

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the template-injection/csharp-aspnetcorefolder.

Shell
dotnet restore tests/Templating.Tests --locked-mode
Shell
# Expected: every test passes
IMPL=fixed SSTI_SECRET=TMPL-INJECTION-PROOF-4d9f2a7c dotnet test --project tests/Templating.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

# Expected: an exploit test fails
IMPL=vulnerable SSTI_SECRET=TMPL-INJECTION-PROOF-4d9f2a7c dotnet test --project tests/Templating.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

# Expected: a test fails
IMPL=wrong SSTI_SECRET=TMPL-INJECTION-PROOF-4d9f2a7c dotnet test --project tests/Templating.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs template-injection/csharp-aspnetcore runs all three modes and checks the expected results.

Download the project ZIP, 27 KB

Verified

Toolchain
  • dotnetSdk 10.0.112
  • dotnetRuntime 10.0.12
  • fluidCore 2.40.0
  • roslynScripting 5.6.0
  • aspnetMvcTesting 10.0.12
  • xunitV3 4.0.2
Matrix
fixed: 9 of 9 tests pass; vulnerable: 5 of 9 tests fail (5 exploit tests); wrong: 3 of 9 tests fail (3 exploit tests)

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine
  2. Fluid: a .NET template engine based on the Liquid markup (secure by design)
  3. Roslyn scripting API (Microsoft.CodeAnalysis.CSharp.Scripting)
  4. Scriban: safe runtime and member access
  5. ASP.NET Core: Integration tests (WebApplicationFactory)
  6. OWASP Web Security Testing Guide: Testing for Server-Side Template Injection
  7. OWASP Top 10:2025 A05 Injection

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.