high

GHSA-7jvp-hj45-2f2m

NuGet · Scriban

Summary

Scriban: Template Writes to Arbitrary CLR Properties via `TypedObjectAccessor` (Mass Assignment + `private` / `init` / `internal` Setter Bypass)

Severity
high
CWE
CWE-284, CWE-915
Published
2026-07-06
Updated
2026-07-06

Advisory details

Description

When a host pushes a CLR object into a Scriban TemplateContext via the standard, documented pattern —

var so = new ScriptObject();
so["user"] = currentUser;   // direct CLR reference
context.PushGlobal(so);

TypedObjectAccessor exposes every public-getter property for both reading and writing, and writes land on the live host object and persist after Render() returns. The write path performs no CanWrite and no setter-visibility check, producing two related but distinct weaknesses:

(A) Mass assignment of public setters — CWE-915 (originally F-002). Any { get; set; } property is writable from template code ({{ user.is_admin = true }}, {{ order.total_price = 0 }}). This is "surprising but technically consistent with the setter being public" — and crucially, Scriban offers no way to expose such a property read-only, because MemberFilter is read/write-symmetric.

(B) Access-modifier bypass — CWE-284 (originally F-007). Properties the developer deliberately restricted are also writable, because reflection ignores C# accessibility:

Declaration Developer intent Actual behavior
{ get; set; } writable writable (mass assignment — A)
{ get; private set; } only the owning class writes template writes freely
{ get; internal set; } only the declaring assembly writes template writes freely
{ get; init; } immutable after construction (C# 9 language guarantee) template writes freely post-construction

The init-only post-construction write — the highest false-positive risk — was explicitly confirmed against the shipped 7.2.1 package.

Affected Versions

All releases that ship TypedObjectAccessor (<= 7.2.1). PrepareMembers has used the getter-only filter since the accessor was introduced, and TrySetValue has never checked the setter. The init bypass applies on .NET 5+; private set / internal set apply on every supported runtime. No patched version exists.

Steps to Reproduce

Copy-paste. Run from the engagement root (the folder containing both scriban/ and reports/).

Prereqs:

test -d scriban || { echo "scriban source missing"; exit 1; }
( command -v dotnet >/dev/null && dotnet --list-sdks | grep -q '^10\.' ) \
  || ( "$HOME/.dotnet/dotnet" --list-sdks | grep -q '^10\.' ) \
  || { echo ".NET 10 SDK missing"; exit 1; }
export PATH="$HOME/.dotnet:$PATH"

Run both PoCs (native):

( cd reports/f002/poc && dotnet run -c Release )   # (A) public-setter mass assignment
( cd reports/f007/poc && dotnet run -c Release )   # (B) private/internal/init bypass

Docker fallback (no native SDK required):

docker run --rm -v "$PWD":/work -w /work/reports/f007/poc \
  mcr.microsoft.com/dotnet/sdk:10.0 bash -lc "dotnet run -c Release"

Confirm the published package is affected (not just master): swap the ProjectReference in reports/f007/poc/poc.csproj for <PackageReference Include="Scriban" Version="7.2.1" /> and re-run — the four bypasses still succeed.

Each PoC prints [1] original CLR values, [2] template output (reads originals → writes → reads back), and [3] the C#-side read after Render() proving the live host object was permanently altered.

Remediation

Fixes are listed flat. Note that (B) has a clean, clearly-correct code fix; (A) requires a new control because public-setter writes are otherwise by-design.

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.