Client-Side Template Injection (CSTI)
Also Known As
TL;DR
Explanation
CSTI occurs when a server safely HTML-encodes user input but then a client-side framework re-parses the encoded output as a template, evaluating any expression syntax the attacker injected. The classic case is AngularJS 1.x: a server places `{{user_input}}` into the page after HTML-escaping it; Angular sees `{{constructor.constructor('alert(1)')()}}` and executes it. Vue, Handlebars (`{{ }}`), Underscore (`<%= %>`), and any framework that interprets braces or other delimiters at render-time are vulnerable to the same class of bug. CSTI is distinct from server-side template injection (SSTI): it never runs code on the server, only in the browser, and the impact is XSS-class (session theft, request forgery, defacement). It is also distinct from classic XSS in that HTML encoding alone does not stop it — the framework processes the encoded text as a template after the encoding has been applied. Defences: never let user input land inside an active template region; use text bindings (`v-text`, `ng-bind`) instead of interpolation; sanitise input against the framework's expression syntax; or escape framework-specific delimiters (`{{` → `{{ '{{' }}`).
How It's Exploited
Common Misconception
Why It Matters
Common Mistakes
- Mounting an Angular/Vue app on a region of server-rendered HTML containing user input — every interpolation expression in that region is exploitable.
- Using Vue's `v-html` (or Angular's `[innerHTML]`) on user-supplied strings — bypasses both HTML encoding and the framework's expression escaping.
- Assuming Handlebars 'safe-string' helpers protect against template syntax — they protect against HTML output but still evaluate expressions.
- Stripping `<script>` tags but leaving `{{` intact — the executor here is the framework, not a script tag.
- Trusting that modern framework defaults are immune — Vue 3 disables in-DOM template compilation by default, but inline templates and SSR hydration regions can still be vulnerable.
Avoid When
- The framework's mount region contains zero server-rendered user content — pure data-binding apps are not vulnerable to this class.
When To Use
- Auditing pages that mix server-rendered user content with client-side framework mount points.
- Reviewing template-rendering libraries used both server-side and client-side.
Code Examples
<!-- ❌ Server HTML-encodes user comment, then Vue mounts on the surrounding div -->
<div id="app">
<h2>Comment by <?= htmlspecialchars($comment->author) ?></h2>
<p><?= htmlspecialchars($comment->body) ?></p>
</div>
<script src="https://unpkg.com/vue@3"></script>
<script>
const { createApp } = Vue;
createApp({}).mount('#app');
// Vue parses everything inside #app as a template.
// A comment body of {{ constructor.constructor('alert(1)')() }} executes.
</script>
<!-- ✅ Pass user data as data, not as template content; use v-text/v-pre -->
<div id="app">
<h2>Comment by <span v-text="author"></span></h2>
<p v-text="body"></p>
</div>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
author: <?= json_encode($comment->author, JSON_HEX_TAG | JSON_HEX_AMP) ?>,
body: <?= json_encode($comment->body, JSON_HEX_TAG | JSON_HEX_AMP) ?>
};
}
}).mount('#app');
// v-text writes data as text content — no template interpretation.
// Alternative: wrap static user-rendered regions in v-pre to disable compilation.
</script>