GitHub Advisoryadvisory · NuGet

GHSA-7jvp-hj45-2f2m Scriban: Template Writes to Arbitrary CLR Properties via `TypedObjectAccessor` (Mass Assignment + `private` / `init` / `internal` Setter Bypass)

HIGH Published 2026-07-06 Modified 2026-07-06 Official page ↗
Ecosystem
NuGet
Packages
Scriban
Severity
High
CVSS vector
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P
Weaknesses
CWE-284, CWE-915
Reviewed
2026-07-06

Affected packages

• NuGet Scriban: < 7.2.2 → fixed in 7.2.2

Description

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 (&#x3C;= 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 &#x26;&#x26; 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 &#x26;&#x26; dotnet run -c Release ) # (A) public-setter mass assignment ( cd reports/f007/poc &#x26;&#x26; 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 &#x3C;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. Fix 1 — block restricted setters in TrySetValue (TypedObjectAccessor.cs L108–L123). Fixes (B). Before the L120 SetValue, require a public, non-init setter: var setM = propertyAccessor.GetSetMethod(nonPublic: false); if (setM is null) return false; // private / internal / protected setters if (setM.ReturnParameter.GetRequiredCustomModifiers() .Any(m => m.FullName == "System.Runtime.CompilerServices.IsExternalInit")) return false; // init-only: setter IS public, so the IsExternalInit check is REQUIRED A plain GetSetMethod(nonPublic:false) != null check is not sufficient for init — the init setter is public; only the IsExternalInit modreq distinguishes it. Fix 2 — give hosts a read/write distinction (addresses (A)). Add a MemberWriteFilter on TemplateContext (separate from MemberFilter) and/or a [ScriptMemberReadOnly] attribute, and split _members into _readableMembers / _writableMembers in PrepareMembers (L126–L186). Public-settable mass assignment cannot be blocked without one of these, because MemberFilter is read/write-symmetric today. Fix 3 — restore read-only-by-default on ScriptObject.Import (ScriptObjectExtensions.cs L320–L324). Gate the Liquid-compatibility relaxation behind an explicit opt-in instead of removing write protection globally. Fix 4 — documentation (site/docs/runtime/safe-runtime.md). State explicitly that templates can write CLR properties via reflection (including private/internal/init setters), and that MemberFilter does not separate read from write. Fix 5 — regression tests (src/Scriban.Tests/). Assert private set / internal set / init are non-writable from templates, that MemberWriteFilter / [ScriptMemberReadOnly] gate writes, and that only public set is writable. References Vulnerable write path (no setter check): scriban/src/Scriban/Runtime/Accessors/TypedObjectAccessor.cs L108–L123 (TrySetValue), sink at L120 propertyAccessor.SetValue(target, context.ToObject(span, value, propertyAccessor.PropertyType)); Getter-only member filter: TypedObjectAccessor.cs L126–L186 (PrepareMembers), enumeration at L150, gate at L156; same _members consumed by TryGetValue (L66–L83) Member-assignment dispatch: scriban/src/Scriban/ScribanAsync.generated.cs:2297 (accessor.TrySetValue(...)) and the synchronous evaluator No read/write separation: MemberFilter declared TemplateContext.cs:286, applied TemplateContext.cs:1026; ScriptObject.Import read-only removal ScriptObjectExtensions.cs:320–324 .NET reflection bypasses access modifiers: https://learn.microsoft.com/dotnet/api/system.reflection.propertyinfo.setvalue init accessors (C# 9): <a href="https://learn.microsoft.com/d…

Related

Weakness · CWE (2)

  • CWE-284 Improper Access Control weakness
  • CWE-915 Improperly Controlled Modification of Dynamically-Determined Object Attributes weakness

References