GHSA-7JVP-HJ45-2F2M
Vulnerability from github – Published: 2026-07-06 17:30 – Updated: 2026-07-06 17:30Description
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/andreports/).
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.
- Fix 1 — block restricted setters in
TrySetValue(TypedObjectAccessor.csL108–L123). Fixes (B). Before the L120SetValue, require a public, non-initsetter:
A plainvar 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 REQUIREDGetSetMethod(nonPublic:false) != nullcheck is not sufficient forinit— the init setter is public; only theIsExternalInitmodreq distinguishes it. - Fix 2 — give hosts a read/write distinction (addresses (A)). Add a
MemberWriteFilteronTemplateContext(separate fromMemberFilter) and/or a[ScriptMemberReadOnly]attribute, and split_membersinto_readableMembers/_writableMembersinPrepareMembers(L126–L186). Public-settable mass assignment cannot be blocked without one of these, becauseMemberFilteris read/write-symmetric today. - Fix 3 — restore read-only-by-default on
ScriptObject.Import(ScriptObjectExtensions.csL320–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 (includingprivate/internal/initsetters), and thatMemberFilterdoes not separate read from write. - Fix 5 — regression tests (
src/Scriban.Tests/). Assertprivate set/internal set/initare non-writable from templates, thatMemberWriteFilter/[ScriptMemberReadOnly]gate writes, and that only publicsetis writable.
References
- Vulnerable write path (no setter check):
scriban/src/Scriban/Runtime/Accessors/TypedObjectAccessor.csL108–L123 (TrySetValue), sink at L120propertyAccessor.SetValue(target, context.ToObject(span, value, propertyAccessor.PropertyType)); - Getter-only member filter:
TypedObjectAccessor.csL126–L186 (PrepareMembers), enumeration at L150, gate at L156; same_membersconsumed byTryGetValue(L66–L83) - Member-assignment dispatch:
scriban/src/Scriban/ScribanAsync.generated.cs:2297(accessor.TrySetValue(...)) and the synchronous evaluator - No read/write separation:
MemberFilterdeclaredTemplateContext.cs:286, appliedTemplateContext.cs:1026;ScriptObject.Importread-only removalScriptObjectExtensions.cs:320–324 - .NET reflection bypasses access modifiers: https://learn.microsoft.com/dotnet/api/system.reflection.propertyinfo.setvalue
initaccessors (C# 9): https://learn.microsoft.com/dotnet/csharp/language-reference/proposals/csharp-9.0/init- CWE-915 — https://cwe.mitre.org/data/definitions/915.html
- CWE-284 — https://cwe.mitre.org/data/definitions/284.html
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 7.2.1"
},
"package": {
"ecosystem": "NuGet",
"name": "Scriban"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "7.2.2"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"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"
},
"details": "\u003c!-- obsidian --\u003e\u003ch2 data-heading=\"Description\"\u003eDescription\u003c/h2\u003e\n\u003cp\u003eWhen a host pushes a CLR object into a Scriban \u003ccode\u003eTemplateContext\u003c/code\u003e via the standard, documented pattern \u2014\u003c/p\u003e\n\u003cpre\u003e\u003ccode class=\"language-csharp\"\u003evar so = new ScriptObject();\nso[\"user\"] = currentUser; // direct CLR reference\ncontext.PushGlobal(so);\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u2014 \u003ccode\u003eTypedObjectAccessor\u003c/code\u003e exposes every public-getter property for \u003cstrong\u003eboth reading and writing\u003c/strong\u003e, and writes land on the live host object and \u003cstrong\u003epersist after \u003ccode\u003eRender()\u003c/code\u003e returns\u003c/strong\u003e. The write path performs no \u003ccode\u003eCanWrite\u003c/code\u003e and no setter-visibility check, producing two related but distinct weaknesses:\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e(A) Mass assignment of public setters \u2014 CWE-915 (originally F-002).\u003c/strong\u003e Any \u003ccode\u003e{ get; set; }\u003c/code\u003e property is writable from template code (\u003ccode\u003e{{ user.is_admin = true }}\u003c/code\u003e, \u003ccode\u003e{{ order.total_price = 0 }}\u003c/code\u003e). This is \"surprising but technically consistent with the setter being public\" \u2014 and crucially, Scriban offers \u003cstrong\u003eno way to expose such a property read-only\u003c/strong\u003e, because \u003ccode\u003eMemberFilter\u003c/code\u003e is read/write-symmetric.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e(B) Access-modifier bypass \u2014 CWE-284 (originally F-007).\u003c/strong\u003e Properties the developer \u003cstrong\u003edeliberately\u003c/strong\u003e restricted are also writable, because reflection ignores C# accessibility:\u003c/p\u003e\n\nDeclaration | Developer intent | Actual behavior\n-- | -- | --\n{ get; set; } | writable | writable (mass assignment \u2014 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\u003cp\u003eThe \u003ccode\u003einit\u003c/code\u003e-only post-construction write \u2014 the highest false-positive risk \u2014 was explicitly confirmed against the shipped 7.2.1 package.\u003c/p\u003e\n\u003ch2 data-heading=\"Affected Versions\"\u003eAffected Versions\u003c/h2\u003e\n\u003cp\u003eAll releases that ship \u003ccode\u003eTypedObjectAccessor\u003c/code\u003e (\u003ccode\u003e\u0026#x3C;= 7.2.1\u003c/code\u003e). \u003ccode\u003ePrepareMembers\u003c/code\u003e has used the getter-only filter since the accessor was introduced, and \u003ccode\u003eTrySetValue\u003c/code\u003e has never checked the setter. The \u003ccode\u003einit\u003c/code\u003e bypass applies on .NET 5+; \u003ccode\u003eprivate set\u003c/code\u003e / \u003ccode\u003einternal set\u003c/code\u003e apply on every supported runtime. No patched version exists.\u003c/p\u003e\n\u003ch2 data-heading=\"Steps to Reproduce\"\u003eSteps to Reproduce\u003c/h2\u003e\n\u003cblockquote\u003e\n\u003cp\u003eCopy-paste. Run from the engagement root (the folder containing both \u003ccode\u003escriban/\u003c/code\u003e and \u003ccode\u003ereports/\u003c/code\u003e).\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e\u003cstrong\u003ePrereqs:\u003c/strong\u003e\u003c/p\u003e\n\u003cpre\u003e\u003ccode class=\"language-bash\"\u003etest -d scriban || { echo \"scriban source missing\"; exit 1; }\n( command -v dotnet \u003e/dev/null \u0026#x26;\u0026#x26; dotnet --list-sdks | grep -q \u0027^10\\.\u0027 ) \\\n || ( \"$HOME/.dotnet/dotnet\" --list-sdks | grep -q \u0027^10\\.\u0027 ) \\\n || { echo \".NET 10 SDK missing\"; exit 1; }\nexport PATH=\"$HOME/.dotnet:$PATH\"\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003cstrong\u003eRun both PoCs (native):\u003c/strong\u003e\u003c/p\u003e\n\u003cpre\u003e\u003ccode class=\"language-bash\"\u003e( cd reports/f002/poc \u0026#x26;\u0026#x26; dotnet run -c Release ) # (A) public-setter mass assignment\n( cd reports/f007/poc \u0026#x26;\u0026#x26; dotnet run -c Release ) # (B) private/internal/init bypass\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003cstrong\u003eDocker fallback (no native SDK required):\u003c/strong\u003e\u003c/p\u003e\n\u003cpre\u003e\u003ccode class=\"language-bash\"\u003edocker 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\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003cstrong\u003eConfirm the published package is affected (not just master):\u003c/strong\u003e swap the \u003ccode\u003eProjectReference\u003c/code\u003e in \u003ccode\u003ereports/f007/poc/poc.csproj\u003c/code\u003e for \u003ccode\u003e\u0026#x3C;PackageReference Include=\"Scriban\" Version=\"7.2.1\" /\u003e\u003c/code\u003e and re-run \u2014 the four bypasses still succeed.\u003c/p\u003e\n\u003cp\u003eEach PoC prints \u003ccode\u003e[1]\u003c/code\u003e original CLR values, \u003ccode\u003e[2]\u003c/code\u003e template output (reads originals \u2192 writes \u2192 reads back), and \u003ccode\u003e[3]\u003c/code\u003e the \u003cstrong\u003eC#-side\u003c/strong\u003e read after \u003ccode\u003eRender()\u003c/code\u003e proving the live host object was permanently altered.\u003c/p\u003e\n\u003ch2 data-heading=\"Remediation\"\u003eRemediation\u003c/h2\u003e\n\u003cp\u003eFixes are listed flat. Note that (B) has a clean, clearly-correct code fix; (A) requires a \u003cem\u003enew control\u003c/em\u003e because public-setter writes are otherwise by-design.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eFix 1 \u2014 block restricted setters in \u003ccode\u003eTrySetValue\u003c/code\u003e (\u003ccode\u003eTypedObjectAccessor.cs\u003c/code\u003e L108\u2013L123). Fixes (B).\u003c/strong\u003e Before the L120 \u003ccode\u003eSetValue\u003c/code\u003e, require a public, non-\u003ccode\u003einit\u003c/code\u003e setter:\n\u003cpre\u003e\u003ccode class=\"language-csharp\"\u003evar setM = propertyAccessor.GetSetMethod(nonPublic: false);\nif (setM is null) return false; // private / internal / protected setters\nif (setM.ReturnParameter.GetRequiredCustomModifiers()\n .Any(m =\u003e m.FullName == \"System.Runtime.CompilerServices.IsExternalInit\"))\n return false; // init-only: setter IS public, so the IsExternalInit check is REQUIRED\n\u003c/code\u003e\u003c/pre\u003e\nA plain \u003ccode\u003eGetSetMethod(nonPublic:false) != null\u003c/code\u003e check is \u003cstrong\u003enot\u003c/strong\u003e sufficient for \u003ccode\u003einit\u003c/code\u003e \u2014 the init setter is public; only the \u003ccode\u003eIsExternalInit\u003c/code\u003e modreq distinguishes it.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eFix 2 \u2014 give hosts a read/write distinction (addresses (A)).\u003c/strong\u003e Add a \u003ccode\u003eMemberWriteFilter\u003c/code\u003e on \u003ccode\u003eTemplateContext\u003c/code\u003e (separate from \u003ccode\u003eMemberFilter\u003c/code\u003e) and/or a \u003ccode\u003e[ScriptMemberReadOnly]\u003c/code\u003e attribute, and split \u003ccode\u003e_members\u003c/code\u003e into \u003ccode\u003e_readableMembers\u003c/code\u003e / \u003ccode\u003e_writableMembers\u003c/code\u003e in \u003ccode\u003ePrepareMembers\u003c/code\u003e (L126\u2013L186). Public-settable mass assignment cannot be blocked without one of these, because \u003ccode\u003eMemberFilter\u003c/code\u003e is read/write-symmetric today.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eFix 3 \u2014 restore read-only-by-default on \u003ccode\u003eScriptObject.Import\u003c/code\u003e (\u003ccode\u003eScriptObjectExtensions.cs\u003c/code\u003e L320\u2013L324).\u003c/strong\u003e Gate the Liquid-compatibility relaxation behind an explicit opt-in instead of removing write protection globally.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eFix 4 \u2014 documentation (\u003ccode\u003esite/docs/runtime/safe-runtime.md\u003c/code\u003e).\u003c/strong\u003e State explicitly that templates can write CLR properties via reflection (including \u003ccode\u003eprivate\u003c/code\u003e/\u003ccode\u003einternal\u003c/code\u003e/\u003ccode\u003einit\u003c/code\u003e setters), and that \u003ccode\u003eMemberFilter\u003c/code\u003e does not separate read from write.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eFix 5 \u2014 regression tests (\u003ccode\u003esrc/Scriban.Tests/\u003c/code\u003e).\u003c/strong\u003e Assert \u003ccode\u003eprivate set\u003c/code\u003e / \u003ccode\u003einternal set\u003c/code\u003e / \u003ccode\u003einit\u003c/code\u003e are non-writable from templates, that \u003ccode\u003eMemberWriteFilter\u003c/code\u003e / \u003ccode\u003e[ScriptMemberReadOnly]\u003c/code\u003e gate writes, and that only public \u003ccode\u003eset\u003c/code\u003e is writable.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 data-heading=\"References\"\u003eReferences\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eVulnerable write path (no setter check): \u003ccode\u003escriban/src/Scriban/Runtime/Accessors/TypedObjectAccessor.cs\u003c/code\u003e L108\u2013L123 (\u003ccode\u003eTrySetValue\u003c/code\u003e), sink at L120 \u003ccode\u003epropertyAccessor.SetValue(target, context.ToObject(span, value, propertyAccessor.PropertyType));\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003eGetter-only member filter: \u003ccode\u003eTypedObjectAccessor.cs\u003c/code\u003e L126\u2013L186 (\u003ccode\u003ePrepareMembers\u003c/code\u003e), enumeration at L150, gate at L156; same \u003ccode\u003e_members\u003c/code\u003e consumed by \u003ccode\u003eTryGetValue\u003c/code\u003e (L66\u2013L83)\u003c/li\u003e\n\u003cli\u003eMember-assignment dispatch: \u003ccode\u003escriban/src/Scriban/ScribanAsync.generated.cs:2297\u003c/code\u003e (\u003ccode\u003eaccessor.TrySetValue(...)\u003c/code\u003e) and the synchronous evaluator\u003c/li\u003e\n\u003cli\u003eNo read/write separation: \u003ccode\u003eMemberFilter\u003c/code\u003e declared \u003ccode\u003eTemplateContext.cs:286\u003c/code\u003e, applied \u003ccode\u003eTemplateContext.cs:1026\u003c/code\u003e; \u003ccode\u003eScriptObject.Import\u003c/code\u003e read-only removal \u003ccode\u003eScriptObjectExtensions.cs:320\u2013324\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e.NET reflection bypasses access modifiers: \u003ca href=\"https://learn.microsoft.com/dotnet/api/system.reflection.propertyinfo.setvalue\" class=\"external-link\" target=\"_blank\" rel=\"noopener nofollow\"\u003ehttps://learn.microsoft.com/dotnet/api/system.reflection.propertyinfo.setvalue\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003einit\u003c/code\u003e accessors (C# 9): \u003ca href=\"https://learn.microsoft.com/dotnet/csharp/language-reference/proposals/csharp-9.0/init\" class=\"external-link\" target=\"_blank\" rel=\"noopener nofollow\"\u003ehttps://learn.microsoft.com/dotnet/csharp/language-reference/proposals/csharp-9.0/init\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003eCWE-915 \u2014 \u003ca href=\"https://cwe.mitre.org/data/definitions/915.html\" class=\"external-link\" target=\"_blank\" rel=\"noopener nofollow\"\u003ehttps://cwe.mitre.org/data/definitions/915.html\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003eCWE-284 \u2014 \u003ca href=\"https://cwe.mitre.org/data/definitions/284.html\" class=\"external-link\" target=\"_blank\" rel=\"noopener nofollow\"\u003ehttps://cwe.mitre.org/data/definitions/284.html\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e",
"id": "GHSA-7jvp-hj45-2f2m",
"modified": "2026-07-06T17:30:17Z",
"published": "2026-07-06T17:30:17Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/scriban/scriban/security/advisories/GHSA-7jvp-hj45-2f2m"
},
{
"type": "PACKAGE",
"url": "https://github.com/scriban/scriban"
}
],
"schema_version": "1.4.0",
"severity": [
{
"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",
"type": "CVSS_V4"
}
],
"summary": "Scriban: Template Writes to Arbitrary CLR Properties via `TypedObjectAccessor` (Mass Assignment + `private` / `init` / `internal` Setter Bypass)"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.