#81821·Go

html/template: XSS via stale jsCtx at template-literal expression entry, tJSTmpl `${`

Author: thatnealpatelCreated Sep 28, 2026Updated Sep 28, 2026
LabelsSecurityrelease-blocker

The original report is abridged as follows:

This is a new report on html/template JS-context tracking — a different
root cause from my previous ones (missing nextJSCtx calls for < and -;
incomplete regexpPrecederKeywords). This one is in tJSTmpl: the
template-literal expression entry does not reset jsCtx.

Summary
-------
When a JS template literal contains two or more consecutive ${...}
expressions, the second expression inherits the jsCtx left behind by the
first one (jsCtxDivOp). tJSTmpl (transition.go) switches to stateJS on
"${" but never resets c.jsCtx. If the second expression starts with a
regexp literal /{{.}}/, tJS misreads the opening '/' as a division
operator and stays in stateJS; the action inside the regexp then gets
jsvalescaper (json.Marshal — does NOT escape '/') instead of
jsregexpescaper. An attacker-controlled '/' closes the regexp the browser
actually opened, and arbitrary JavaScript executes.

Affected code (file:line)
-------------------------
Repository: golang/go
Package: html/template

- src/html/template/transition.go:382-386  tJSTmpl — "${" sets
  c.state = stateJS but does not reset c.jsCtx
- src/html/template/escape.go:240-243      stateJS selects jsvalescaper
  (wrong) instead of jsregexpescaper (correct)

Versions: all Go releases including master at e744d9ebb6b.

Root cause
----------
In tJSTmpl:

    case '$':
        if len(s) >= i+2 && s[i+1] == '{' {
            c.jsBraceDepth = append(c.jsBraceDepth, 0)
            c.state = stateJS
            return c, i + 2
        }

The expression entry leaves c.jsCtx untouched. The leftover value is
jsCtxDivOp because (a) escapeAction sets jsCtx = jsCtxDivOp after every
stateJS action, and (b) nextJSCtx returns divOp after the previous
expression's last token. A JS expression may however BEGIN with a regexp
literal. The machine only gets the first expression right by accident:
the backtick entry in tJS sets jsCtx = jsCtxRegexp. For the second and
later expressions the stale jsCtxDivOp persists, so a regexp-opening '/'
is misread as division.

Data flow
---------
1. Template: <script>const s = `${1}${/{{.}}/g}`</script>
2. ${1}: expression state, bulk "1" -> jsCtxDivOp; "}" pops, back to
   stateJSTmplLit, jsCtx stays jsCtxDivOp.
3. Second "${": stateJS, jsCtx still jsCtxDivOp (stale).
4. '/' with jsCtxDivOp -> treated as division -> stays in stateJS.
5. {{.}} -> stateJS -> jsvalescaper applied (should be jsregexpescaper).
6. Attacker input: x/.exec(alert(1))} -> json.Marshal:
   "x/.exec(alert(1))}"  (/ NOT escaped)
7. Output: <script>const s = `${1}${/"x/.exec(alert(1))}"/g}`</script>
8. Browser: the '/' after ${ opens a regexp; the attacker's '/' closes it
   at /"x/, then .exec(alert(1)) executes — alert(1) fires (XSS).

Reproduction
------------
Tested on Go master at e744d9ebb6b and on the go1.26.3 release — identical
behavior. The emitted <script> content was evaluated in node (V8, same
engine family as Chrome) with a global alert stub: "ALERT FIRED: 1".

  Template: <script>const s = `${1}${/{{.}}/g}`</script>
  Input:    x/.exec(alert(1))}
  Output:   <script>const s = `${1}${/"x/.exec(alert(1))}"/g}`</script>
  *** XSS — alert fired in node ***

Controls (all SAFE, correctly escaped):
  <script>const s = `${/{{.}}/g}`</script>              -> jsregexpescaper
  <script>const s = `foo${/{{.}}/g}`</script>           -> jsregexpescaper
  (literal content does not touch jsCtx; only a PRIOR EXPRESSION leaves
  jsCtxDivOp — the bug is specific to consecutive expressions)
  Nested literal `${`a`${/{{.}}/g}}` also safe — the inner backtick
  entry resets jsCtx to jsCtxRegexp.

This is CVE-2026-94448, originally tracked in http://b/545173298.