Engineering · Oct 5, 2026 · 6 min read

Client-side template injection: when the template engine runs in the browser

An AngularJS expression evaluated in the browser is not reflected XSS and not SSTI - a separate class where the confirming artifact is an arithmetic oracle resolved by the template engine in the DOM.

Client-side template injection is the class where user input reaches a template expression context that is evaluated by a JavaScript framework in the browser - not by the server. It shares a confirmation mechanic with SSTI (an arithmetic oracle) and a browser runtime with DOM XSS, but it is neither. A scanner keyed on server error messages misses it entirely; one keyed only on DOM sinks misses the framework expression context. Probing it correctly means understanding which engine runs the template and what capability a confirmed evaluation enables.

01The sink is a template engine, not a DOM write

In reflected and stored XSS the vector is a value the browser writes into the DOM or an event handler. In SSTI the vector reaches a server-side engine. In CSTI the vector reaches a client-side template compiler - AngularJS's $compile, Angular's JIT runtime, Vue's in-browser template compiler, or Handlebars rendered on the page. The browser runs the expression; the server is a bystander that handed the template to the page in the first place.

Reflected XSS user input ──► HTML/attribute/script sink DOM write SSTI user input ──► server template engine server evaluates CSTI user input ──► client template engine browser evaluates AngularJS $compile / Angular JIT / Vue compiler / Handlebars Confirming signal {{7919*7907}} ──► 62615533 in the DOM (engine ran; server returned the literal string)
Three injection classes share a name pattern. Only one fires in the browser; only one fires on the server. The arithmetic oracle tells you which.

02Arithmetic oracle confirms evaluation, nothing else runs

The confirming probe is identical to SSTI in its outward form but the execution context is the browser. Submit the expression {{7919*7907}} into a field whose value is interpolated by a framework template. If the DOM shows 62615533, the framework evaluated the expression - not just reflected it. That is in-browser proof of evaluation with no dangerous gadget, no real command, and no code run beyond an arithmetic multiply.

csti-probe.jsjavascript
// AngularJS 1.x: inject into a scope-bound interpolation field
// payload lands in a {{expr}} binding the $compile step evaluates

const probe = "{{7919*7907}}";
// submit via the input that populates a binding, e.g. a search label:
//   <h1>Results for {{userQuery}}</h1>

// confirming artifact: DOM text node reads 62615533, not the literal string
const text = document.querySelector('h1').textContent;
if (text.includes('62615533')) {
    // expression was evaluated by $compile - CSTI confirmed
    report('csti-confirmed', { payload: probe, dom: text });
}
// a plain-string reflect (no evaluation): DOM shows {{7919*7907}} literally
// HTTP response inspection alone cannot distinguish these two cases

The server response is identical in both cases - the literal string {{7919*7907}} travels the wire. The difference is what the framework does with it when it compiles the template. An HTTP-level scanner that reads the response body sees no signal either way; the confirming artifact is in the DOM after the page renders, which is why headless-browser evaluation is the correct tool for this class.

Proven - HighAngularJS CSTI - expression evaluation - /search?q=
GET /search?q={{7919*7907}} ──► HTTP 200 (literal string in response body: {{7919*7907}}) ──► DOM after $compile: h1.textContent = "Results for 62615533" baseline (q=hello): h1.textContent = "Results for hello" - no evaluation
Oracle: arithmetic. Evaluated in-browser by AngularJS $compile after page load. HTTP response body carries the unexpanded literal - HTTP scanner sees no signal. Rated high; sandbox escape on AngularJS 1.x upgrades to critical RCE.

03Per-framework behavior changes both the vector and the payload

AngularJS (1.x) is the most commonly encountered CSTI surface: its $compile phase evaluates every {{expr}} in the DOM, and documented sandbox-escape chains (Gareth Heyes, 2016) turn expression evaluation into arbitrary JavaScript execution - which in a browser context means full DOM access and credential theft without any server-side component. Angular 2+ compiles templates ahead-of-time (AOT) by default, but JIT mode leaves the surface open. Vue's in-browser template compiler, used when runtime-compiler is loaded and user input reaches v-html or a directive that calls Vue.compile, shares the same class. Handlebars rendered in the browser with a triple-mustache ({{{unsafe}}}) that the application treats as a template is a fourth variant with its own confirmation path.

FrameworkEvaluation triggerConfirming oracleMax severity
AngularJS 1.xAny {{expr}} in $compile scope; ng-app on ancestor element{{7919*7907}} resolves to 62615533 in DOM textCritical - sandbox escapes yield JS execution
Angular 2+ JITJIT mode only; template string passed to compiler at runtimeSame arithmetic oracle; scope differsHigh - sandbox narrower than AngularJS 1.x
Vue (browser compiler)v-html or dynamic template with runtime-compiler build{{7919*7907}} in compiled bindingHigh - expression context limited
Handlebars (browser)Triple-mustache {{{expr}}} or registerHelper with user inputArithmetic in helper return valueMedium to high - depends on available helpers

A probe keyed on AngularJS alone silently misses three other frameworks. Detecting the loaded engine first - by fingerprinting angular, Vue, or Handlebars on window before sending the arithmetic probe - avoids the false negative a single-framework check creates.

04Severity follows confirmed capability, not the expression itself

An arithmetic oracle that resolves to 62615533 is confirmed evaluation - rated high. A sandbox escape that lets the expression call constructor to reach a real function and execute arbitrary JavaScript is rated critical, because DOM access, session theft and exfiltration are all in scope once arbitrary code runs in the browser page. The expression that merely evaluates a number is not the severity anchor - the capability the engine exposes to that expression is.

The inverse also applies: a Vue v-html binding that reflects user HTML verbatim is a stored or reflected XSS finding (DOM write), not a CSTI finding, unless the runtime compiler actually evaluates the injected string as a template. Conflating the two inflates CSTI counts and mislabels the fix: the XSS path is closed by sanitizing the value before the DOM write; the CSTI path is closed by never passing user input through a template compilation step.

4
frameworks, four distinct probe paths
0
network requests fired by the probe
1
DOM read to confirm - not HTTP response

The fix is on the application side: never pass untrusted input into a $compile call, a JIT template string, a Vue.compile result, or a triple-mustache binding. Render dynamic content as data, not as a template - the one-way gate is the framework's own server-side or build-time rendering path, where user values are substituted as strings, not evaluated as expressions.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →