{"schema_version":"1.9.0","id":"GHSA-7jvp-hj45-2f2m","published":"2026-07-06T17:30:17Z","modified":"2026-08-17T03:55:59.396551374Z","aliases":["CVE-2026-73061"],"summary":"Scriban: Template Writes to Arbitrary CLR Properties via `TypedObjectAccessor` (Mass Assignment + `private` / `init` / `internal` Setter Bypass)","details":"<!-- obsidian --><h2 data-heading=\"Description\">Description</h2>\n<p>When a host pushes a CLR object into a Scriban <code>TemplateContext</code> via the standard, documented pattern —</p>\n<pre><code class=\"language-csharp\">var so = new ScriptObject();\nso[\"user\"] = currentUser;   // direct CLR reference\ncontext.PushGlobal(so);\n</code></pre>\n<p>— <code>TypedObjectAccessor</code> exposes every public-getter property for <strong>both reading and writing</strong>, and writes land on the live host object and <strong>persist after <code>Render()</code> returns</strong>. The write path performs no <code>CanWrite</code> and no setter-visibility check, producing two related but distinct weaknesses:</p>\n<p><strong>(A) Mass assignment of public setters — CWE-915 (originally F-002).</strong> Any <code>{ get; set; }</code> property is writable from template code (<code>{{ user.is_admin = true }}</code>, <code>{{ order.total_price = 0 }}</code>). This is \"surprising but technically consistent with the setter being public\" — and crucially, Scriban offers <strong>no way to expose such a property read-only</strong>, because <code>MemberFilter</code> is read/write-symmetric.</p>\n<p><strong>(B) Access-modifier bypass — CWE-284 (originally F-007).</strong> Properties the developer <strong>deliberately</strong> restricted are also writable, because reflection ignores C# accessibility:</p>\n\nDeclaration | Developer intent | Actual behavior\n-- | -- | --\n{ get; set; } | writable | writable (mass assignment — A)\n{ get; private set; } | only the owning class writes | template writes freely\n{ get; internal set; } | only the declaring assembly writes | template writes freely\n{ get; init; } | immutable after construction (C# 9 language guarantee) | template writes freely post-construction\n\n\n<p>The <code>init</code>-only post-construction write — the highest false-positive risk — was explicitly confirmed against the shipped 7.2.1 package.</p>\n<h2 data-heading=\"Affected Versions\">Affected Versions</h2>\n<p>All releases that ship <code>TypedObjectAccessor</code> (<code>&#x3C;= 7.2.1</code>). <code>PrepareMembers</code> has used the getter-only filter since the accessor was introduced, and <code>TrySetValue</code> has never checked the setter. The <code>init</code> bypass applies on .NET 5+; <code>private set</code> / <code>internal set</code> apply on every supported runtime. No patched version exists.</p>\n<h2 data-heading=\"Steps to Reproduce\">Steps to Reproduce</h2>\n<blockquote>\n<p>Copy-paste. Run from the engagement root (the folder containing both <code>scriban/</code> and <code>reports/</code>).</p>\n</blockquote>\n<p><strong>Prereqs:</strong></p>\n<pre><code class=\"language-bash\">test -d scriban || { echo \"scriban source missing\"; exit 1; }\n( command -v dotnet >/dev/null &#x26;&#x26; dotnet --list-sdks | grep -q '^10\\.' ) \\\n  || ( \"$HOME/.dotnet/dotnet\" --list-sdks | grep -q '^10\\.' ) \\\n  || { echo \".NET 10 SDK missing\"; exit 1; }\nexport PATH=\"$HOME/.dotnet:$PATH\"\n</code></pre>\n<p><strong>Run both PoCs (native):</strong></p>\n<pre><code class=\"language-bash\">( cd reports/f002/poc &#x26;&#x26; dotnet run -c Release )   # (A) public-setter mass assignment\n( cd reports/f007/poc &#x26;&#x26; dotnet run -c Release )   # (B) private/internal/init bypass\n</code></pre>\n<p><strong>Docker fallback (no native SDK required):</strong></p>\n<pre><code class=\"language-bash\">docker run --rm -v \"$PWD\":/work -w /work/reports/f007/poc \\\n  mcr.microsoft.com/dotnet/sdk:10.0 bash -lc \"dotnet run -c Release\"\n</code></pre>\n<p><strong>Confirm the published package is affected (not just master):</strong> swap the <code>ProjectReference</code> in <code>reports/f007/poc/poc.csproj</code> for <code>&#x3C;PackageReference Include=\"Scriban\" Version=\"7.2.1\" /></code> and re-run — the four bypasses still succeed.</p>\n<p>Each PoC prints <code>[1]</code> original CLR values, <code>[2]</code> template output (reads originals → writes → reads back), and <code>[3]</code> the <strong>C#-side</strong> read after <code>Render()</code> proving the live host object was permanently altered.</p>\n<h2 data-heading=\"Remediation\">Remediation</h2>\n<p>Fixes are listed flat. Note that (B) has a clean, clearly-correct code fix; (A) requires a <em>new control</em> because public-setter writes are otherwise by-design.</p>\n<ul>\n<li><strong>Fix 1 — block restricted setters in <code>TrySetValue</code> (<code>TypedObjectAccessor.cs</code> L108–L123). Fixes (B).</strong> Before the L120 <code>SetValue</code>, require a public, non-<code>init</code> setter:\n<pre><code class=\"language-csharp\">var setM = propertyAccessor.GetSetMethod(nonPublic: false);\nif (setM is null) return false;   // private / internal / protected setters\nif (setM.ReturnParameter.GetRequiredCustomModifiers()\n      .Any(m => m.FullName == \"System.Runtime.CompilerServices.IsExternalInit\"))\n    return false;                 // init-only: setter IS public, so the IsExternalInit check is REQUIRED\n</code></pre>\nA plain <code>GetSetMethod(nonPublic:false) != null</code> check is <strong>not</strong> sufficient for <code>init</code> — the init setter is public; only the <code>IsExternalInit</code> modreq distinguishes it.</li>\n<li><strong>Fix 2 — give hosts a read/write distinction (addresses (A)).</strong> Add a <code>MemberWriteFilter</code> on <code>TemplateContext</code> (separate from <code>MemberFilter</code>) and/or a <code>[ScriptMemberReadOnly]</code> attribute, and split <code>_members</code> into <code>_readableMembers</code> / <code>_writableMembers</code> in <code>PrepareMembers</code> (L126–L186). Public-settable mass assignment cannot be blocked without one of these, because <code>MemberFilter</code> is read/write-symmetric today.</li>\n<li><strong>Fix 3 — restore read-only-by-default on <code>ScriptObject.Import</code> (<code>ScriptObjectExtensions.cs</code> L320–L324).</strong> Gate the Liquid-compatibility relaxation behind an explicit opt-in instead of removing write protection globally.</li>\n<li><strong>Fix 4 — documentation (<code>site/docs/runtime/safe-runtime.md</code>).</strong> State explicitly that templates can write CLR properties via reflection (including <code>private</code>/<code>internal</code>/<code>init</code> setters), and that <code>MemberFilter</code> does not separate read from write.</li>\n<li><strong>Fix 5 — regression tests (<code>src/Scriban.Tests/</code>).</strong> Assert <code>private set</code> / <code>internal set</code> / <code>init</code> are non-writable from templates, that <code>MemberWriteFilter</code> / <code>[ScriptMemberReadOnly]</code> gate writes, and that only public <code>set</code> is writable.</li>\n</ul>\n<h2 data-heading=\"References\">References</h2>\n<ul>\n<li>Vulnerable write path (no setter check): <code>scriban/src/Scriban/Runtime/Accessors/TypedObjectAccessor.cs</code> L108–L123 (<code>TrySetValue</code>), sink at L120 <code>propertyAccessor.SetValue(target, context.ToObject(span, value, propertyAccessor.PropertyType));</code></li>\n<li>Getter-only member filter: <code>TypedObjectAccessor.cs</code> L126–L186 (<code>PrepareMembers</code>), enumeration at L150, gate at L156; same <code>_members</code> consumed by <code>TryGetValue</code> (L66–L83)</li>\n<li>Member-assignment dispatch: <code>scriban/src/Scriban/ScribanAsync.generated.cs:2297</code> (<code>accessor.TrySetValue(...)</code>) and the synchronous evaluator</li>\n<li>No read/write separation: <code>MemberFilter</code> declared <code>TemplateContext.cs:286</code>, applied <code>TemplateContext.cs:1026</code>; <code>ScriptObject.Import</code> read-only removal <code>ScriptObjectExtensions.cs:320–324</code></li>\n<li>.NET reflection bypasses access modifiers: <a href=\"https://learn.microsoft.com/dotnet/api/system.reflection.propertyinfo.setvalue\" class=\"external-link\" target=\"_blank\" rel=\"noopener nofollow\">https://learn.microsoft.com/dotnet/api/system.reflection.propertyinfo.setvalue</a></li>\n<li><code>init</code> accessors (C# 9): <a href=\"https://learn.microsoft.com/dotnet/csharp/language-reference/proposals/csharp-9.0/init\" class=\"external-link\" target=\"_blank\" rel=\"noopener nofollow\">https://learn.microsoft.com/dotnet/csharp/language-reference/proposals/csharp-9.0/init</a></li>\n<li>CWE-915 — <a href=\"https://cwe.mitre.org/data/definitions/915.html\" class=\"external-link\" target=\"_blank\" rel=\"noopener nofollow\">https://cwe.mitre.org/data/definitions/915.html</a></li>\n<li>CWE-284 — <a href=\"https://cwe.mitre.org/data/definitions/284.html\" class=\"external-link\" target=\"_blank\" rel=\"noopener nofollow\">https://cwe.mitre.org/data/definitions/284.html</a></li>\n</ul>","affected":[{"package":{"name":"Scriban","ecosystem":"NuGet","purl":"pkg:nuget/Scriban"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"7.2.2"}]}],"versions":["0.1.0","0.10.0","0.11.0","0.12.0","0.12.1","0.13.0","0.14.0","0.15.0","0.16.0","0.2.0","0.2.1","0.2.2","0.3.0","0.3.1","0.3.1-pre028","0.4.0","0.5.0","0.6.0","0.7.0","0.9.0","0.9.0-pre100","0.9.1","1.0.0","1.0.0-beta-001","1.0.0-beta-002","1.0.0-beta-003","1.0.0-beta-004","1.0.0-beta-005","1.0.0-beta-006","1.1.0","1.1.1","1.2.0","1.2.1","1.2.2","1.2.3","1.2.4","1.2.5","1.2.6","1.2.7","1.2.8","1.2.9","2.0.0","2.0.0-alpha-001","2.0.0-alpha-002","2.0.0-alpha-003","2.0.0-alpha-004","2.0.0-alpha-005","2.0.0-alpha-006","2.0.1","2.1.0","2.1.1","2.1.2","2.1.3","2.1.4","3.0.0","3.0.1","3.0.2","3.0.3","3.0.4","3.0.5","3.0.6","3.0.7","3.1.0","3.2.0","3.2.1","3.2.2","3.3.0","3.3.1","3.3.2","3.3.3","3.4.0","3.4.1","3.4.2","3.5.0","3.6.0","3.7.0","3.8.0","3.8.1","3.8.2","3.9.0","4.0.0","4.0.1","4.0.2","4.1.0","5.0.0","5.1.0","5.10.0","5.11.0","5.12.0","5.12.1","5.2.0","5.3.0","5.4.0","5.4.1","5.4.2","5.4.3","5.4.4","5.4.5","5.4.6","5.5.0","5.5.1","5.5.2","5.6.0","5.7.0","5.8.0","5.9.0","5.9.1","6.0.0","6.1.0","6.2.0","6.2.1","6.3.0","6.4.0","6.5.0","6.5.1","6.5.2","6.5.3","6.5.4","6.5.5","6.5.6","6.5.7","6.5.8","6.6.0","7.0.0","7.0.1","7.0.2","7.0.3","7.0.4","7.0.5","7.0.6","7.1.0","7.2.0","7.2.1"],"database_specific":{"last_known_affected_version_range":"<= 7.2.1","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-7jvp-hj45-2f2m/GHSA-7jvp-hj45-2f2m.json"}}],"references":[{"type":"WEB","url":"https://github.com/scriban/scriban/security/advisories/GHSA-7jvp-hj45-2f2m"},{"type":"PACKAGE","url":"https://github.com/scriban/scriban"}],"database_specific":{"cwe_ids":["CWE-284","CWE-915"],"github_reviewed":true,"github_reviewed_at":"2026-07-06T17:30:17Z","nvd_published_at":null,"severity":"HIGH"},"severity":[{"type":"CVSS_V4","score":"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"}]}