Summary
The Template calculation operator renders a user-authored Go template on the server and stores the output verbatim. The client interpolates that output into HTML and assigns it via innerHTML, with no escaping or sanitization.
The project already has a sanitizer for exactly this data. getAVTemplateHTML applies DOMPurify to Go-template output on the template-column path. The calculation path never calls it.
No template injection is required. text/template passes literal text through unchanged, so the template body itself is the payload.
Details
The server stores rendered output verbatim. In kernel/av/calc_template.go:
rendered = buf.String()
calc.Result = &Value{Type: KeyTypeText, Text: &ValueText{Content: rendered}}
text/template performs no contextual escaping by design, unlike html/template. The result is stored as a plain text value.
The client falls back to the raw content, every time. app/src/protyle/render/av/calc.ts:584-586, byte-identical on dev and master:
case "Template":
value = `<span>${resultCalc.formattedContent ?? resultCalc.content}</span>...`;
Every other operator in that switch reads formattedContent from a Number or Date value, which the server formats and which is never free-form. Template is the only operator whose value is an attacker-authored string.
The fallback is not occasional. av.ValueText declares only Content string; there is no FormattedContent field, so the key is absent from every text value and ?? resolves to content on every render.
The sink. render.ts:219 builds calcHTML, which becomes contentHTML, which is assigned via .innerHTML = at render.ts:324 and :637.
The sanitizer exists and is not called. app/src/protyle/render/av/attributeValue.ts:28, used at cell.ts:1091 and attributeValue.ts:182:
export const getAVTemplateHTML = (content: string) => {
if (window.siyuan.config.editor.allowHTMLBLockScript) { return content; }
return window.DOMPurify.sanitize(content);
};
This handles Go-template output from the template column, which is the same feature family producing the same kind of string. getCalcValue does not use it. One path is sanitized and gated behind the allowHTMLBLockScript setting; the other is not sanitized at all.
No template injection is involved. This is worth stating plainly because of the recent func-map hardening. GHSA-v97v-* removed env, expandenv and getHostByName from templateFuncMap(), which constrains what a template can call. This report concerns where the rendered output goes. Its 12,037-character body contains no reference to innerHTML, escaping, getCalcValue, calc.ts, render.ts, Electron or code execution. Because text/template emits literal text unchanged, an attacker needs no function at all: the template body is the payload. The Sprig functions widen what can be assembled into that payload, and the SQL template functions wired into sibling paths allow database rows to be interpolated into the injected markup.
Consequence in the desktop application. app/electron/main.js sets nodeIntegration: true, contextIsolation: false and webSecurity: false on every window (lines 913, 1019-1022, 1933-1936, 2157, 2197, 2248), so script executing in a renderer reaches Node built-ins including require('child_process').
Scope of observation. Storage, the API response and the parsed result below are confirmed as described. I have not observed handler invocation in a running client, so the step from the parsed attribute to execution is stated from the Electron configuration rather than from a captured runtime event.
Relationship to existing advisories. getCalcValue, formattedContent, av__calc and calcResultTemplate appear in no published advisory for this project. setAttrViewColCalc, calc.Result, evalRollupTemplate and calcFieldByTemplate match only GHSA-v97v-*, whose scope is the function map.
Proof of Concept
Set a Template calculation whose body is markup:
POST /api/transactions
{"reqId":<numeric>, ... "action":"setAttrViewColCalc",
"operator":"Template",
"template":"<img src=x onerror=alert(document.domain)>PWNED"}
→ {"code":0}
The stored result comes back unmodified, and with no formattedContent key:
POST /api/av/renderAttributeView
→ calc.result = {"type":"text","text":{"content":"<img src=x onerror=alert(document.domain)>PWNED"}}
Passing that exact server response through calc.ts:585 and render.ts:218-219 and parsing the result yields:
div.av__calc > span > img { src: "x", onerror: "alert(document.domain)" }
The event handler is a real attribute on a real element in the parsed output, not an inference from the template text.
Then open any view displaying that calculation footer.
Impact
A user-authored template body becomes markup in the rendered document, executing wherever the calculation is displayed. Calculation footers render as part of the ordinary table view, so no deliberate interaction is required beyond opening the database.
In the desktop client the renderer has Node integration enabled, so execution at that point is not confined to the page.
The value is written through the ordinary calculation-configuration operation, so any path by which a database reaches a victim, including import, synchronisation, a shared workspace or a distributed package, carries the payload. Where the SQL template functions are reachable, the injected markup can additionally carry data drawn from the database.
Suggested fix
Route the Template calculation result through getAVTemplateHTML, which already applies DOMPurify to the equivalent output on the template-column path and already honours the allowHTMLBLockScript setting. Alternatively, apply escapeHtml in getCalcValue.
Routing through the existing helper is preferable, since it makes the two template paths behave identically and keeps the user-facing setting meaningful in both.
Summary
The Template calculation operator renders a user-authored Go template on the server and stores the output verbatim. The client interpolates that output into HTML and assigns it via
innerHTML, with no escaping or sanitization.The project already has a sanitizer for exactly this data.
getAVTemplateHTMLapplies DOMPurify to Go-template output on the template-column path. The calculation path never calls it.No template injection is required.
text/templatepasses literal text through unchanged, so the template body itself is the payload.Details
The server stores rendered output verbatim. In
kernel/av/calc_template.go:text/templateperforms no contextual escaping by design, unlikehtml/template. The result is stored as a plain text value.The client falls back to the raw content, every time.
app/src/protyle/render/av/calc.ts:584-586, byte-identical on dev and master:Every other operator in that switch reads
formattedContentfrom a Number or Date value, which the server formats and which is never free-form. Template is the only operator whose value is an attacker-authored string.The fallback is not occasional.
av.ValueTextdeclares onlyContent string; there is noFormattedContentfield, so the key is absent from every text value and??resolves tocontenton every render.The sink.
render.ts:219buildscalcHTML, which becomescontentHTML, which is assigned via.innerHTML =atrender.ts:324and:637.The sanitizer exists and is not called.
app/src/protyle/render/av/attributeValue.ts:28, used atcell.ts:1091andattributeValue.ts:182:This handles Go-template output from the template column, which is the same feature family producing the same kind of string.
getCalcValuedoes not use it. One path is sanitized and gated behind theallowHTMLBLockScriptsetting; the other is not sanitized at all.No template injection is involved. This is worth stating plainly because of the recent func-map hardening.
GHSA-v97v-*removedenv,expandenvandgetHostByNamefromtemplateFuncMap(), which constrains what a template can call. This report concerns where the rendered output goes. Its 12,037-character body contains no reference toinnerHTML, escaping,getCalcValue,calc.ts,render.ts, Electron or code execution. Becausetext/templateemits literal text unchanged, an attacker needs no function at all: the template body is the payload. The Sprig functions widen what can be assembled into that payload, and the SQL template functions wired into sibling paths allow database rows to be interpolated into the injected markup.Consequence in the desktop application.
app/electron/main.jssetsnodeIntegration: true,contextIsolation: falseandwebSecurity: falseon every window (lines 913, 1019-1022, 1933-1936, 2157, 2197, 2248), so script executing in a renderer reaches Node built-ins includingrequire('child_process').Scope of observation. Storage, the API response and the parsed result below are confirmed as described. I have not observed handler invocation in a running client, so the step from the parsed attribute to execution is stated from the Electron configuration rather than from a captured runtime event.
Relationship to existing advisories.
getCalcValue,formattedContent,av__calcandcalcResultTemplateappear in no published advisory for this project.setAttrViewColCalc,calc.Result,evalRollupTemplateandcalcFieldByTemplatematch onlyGHSA-v97v-*, whose scope is the function map.Proof of Concept
Set a Template calculation whose body is markup:
The stored result comes back unmodified, and with no
formattedContentkey:Passing that exact server response through
calc.ts:585andrender.ts:218-219and parsing the result yields:The event handler is a real attribute on a real element in the parsed output, not an inference from the template text.
Then open any view displaying that calculation footer.
Impact
A user-authored template body becomes markup in the rendered document, executing wherever the calculation is displayed. Calculation footers render as part of the ordinary table view, so no deliberate interaction is required beyond opening the database.
In the desktop client the renderer has Node integration enabled, so execution at that point is not confined to the page.
The value is written through the ordinary calculation-configuration operation, so any path by which a database reaches a victim, including import, synchronisation, a shared workspace or a distributed package, carries the payload. Where the SQL template functions are reachable, the injected markup can additionally carry data drawn from the database.
Suggested fix
Route the Template calculation result through
getAVTemplateHTML, which already applies DOMPurify to the equivalent output on the template-column path and already honours theallowHTMLBLockScriptsetting. Alternatively, applyescapeHtmlingetCalcValue.Routing through the existing helper is preferable, since it makes the two template paths behave identically and keeps the user-facing setting meaningful in both.