Skip to content

A database Template calculation becomes Remote Code Execution on the desktop client, because the sanitizer written for identical template output is never called

Critical
88250 published GHSA-rwh7-gm74-67h6 Aug 1, 2026

Package

gomod github.com/siyuan-note/siyuan/kernel (Go)

Affected versions

eef105683 and dev 98a4fb37b

Patched versions

v3.7.4

Description

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.

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
Required
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H

CVE ID

No known CVE

Weaknesses

Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. Learn more on MITRE.

Credits