# Scriban has an authorization bypass due to stale include cache surviving TemplateContext.Reset()

Scriban's TemplateContext.Reset() leaves CachedTemplates intact, so pooled contexts serve a prior tenant's or admin's include to later renders.

- Payout: Research
- Severity: high
- Program: Scriban
- Found by: Zwique, adamus2
- Disclosed: 2026-03-24
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-x6m9-38vm-2xhf
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-x6m9-38vm-2xhf
- Tags: area:oss domain:oss impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:dotnet technique:source-review vuln:access-control vuln:info-disclosure

**Target:** Scriban template engine TemplateContext / include function
**Root cause:** TemplateContext.Reset() clears output, globals, cultures and source files but not CachedTemplates, which is initialized once per context; GetOrCreateTemplate() returns the cached compiled template without calling ITemplateLoader.Load() again.
**Steps:**
1. Create a TemplateContext with a custom ITemplateLoader whose Load() returns request-dependent content.
2. Parse `{{ include 'profile' }}` and render with loader content `admin-only`; output is `admin-only`.
3. Call `context.Reset()` and change loader content to `guest-view`.
4. Render the same template again; output remains `admin-only` because the cached template is reused.
**How found:** Code reading of Reset() (lines 877-902) versus CachedTemplates initialization (line 197) and the include path through IncludeFunction.Invoke() to GetOrCreateTemplate(), noticing Load() is skipped on cache hit; confirmed with a switching loader PoC.
**Exploitation:** An app that pools TemplateContext objects, calls Reset() between requests, and uses a request/tenant/user-dependent ITemplateLoader will serve the first render's include content to subsequent users; a first authorized render primes the cache with admin-only or tenant-specific template content that leaks to later renders.
**Insight:** When auditing template engines or any pooled/reusable context object, check whether Reset()/Clear() covers every cache field, not just output and globals; a per-context template cache plus a request-dependent loader is a cross-tenant leak primitive.

## Proof of concept (copied exactly from the original report)

**C# PoC reusing context after Reset**

```
var loader = new SwitchingLoader();
var context = new TemplateContext
{
    TemplateLoader = loader,
};

var template = Template.Parse("{{ include 'profile' }}");

loader.Content = "admin-only";
Console.WriteLine("first=" + template.Render(context));

context.Reset();

loader.Content = "guest-view";
Console.WriteLine("second=" + template.Render(context));
```

**SwitchingLoader returning request-dependent content**

```
sealed class SwitchingLoader : ITemplateLoader
{
    public string Content { get; set; } = string.Empty;

    public string GetPath(TemplateContext context, SourceSpan callerSpan, string templateName) => templateName;

    public string Load(TemplateContext context, SourceSpan callerSpan, string templatePath) => Content;

    public ValueTask<string> LoadAsync(TemplateContext context, SourceSpan callerSpan, string templatePath)
        => new(Content);
```

