Summary
The /api/lute/spinBlockDOM endpoint is registered with only model.CheckAuth middleware, making it accessible to any authenticated role including RoleEditor and RoleReader. Its sibling endpoint /api/lute/html2BlockDOM, which performs a comparable DOM transformation, is correctly guarded with model.CheckAdminRole and model.CheckReadonly. The inconsistency is annotated in the router source code itself with the comment // 未测试 ("untested"), indicating the endpoint has not been security-reviewed.
The handler accepts an arbitrary dom string from the request body, passes it without length validation to luteEngine.SpinBlockDOM(dom), and returns the transformed result. The Lute library is a large, actively maintained parser written in Go. The security profile of SpinBlockDOM against adversarial input — including its behavior on pathologically nested, large, or malformed DOM blobs — is not documented and has not been fuzz-tested as part of this research.
A second concern is resource consumption. ControlConcurrency serializes all requests to /api/lute/spinBlockDOM through a per-path mutex. A caller who sends a request with a large or complex DOM input causes the mutex to be held for the duration of the parse, blocking any concurrent legitimate request to this endpoint. This is a serialized denial-of-service (starvation) of the endpoint rather than a parallel exhaustion.
All current releases of SiYuan are affected. The endpoint is accessible to publish-mode RoleReader callers when the publish service is active.
Affected Components
| Component |
Location |
| File |
kernel/api/router.go |
| File |
kernel/api/lute.go — spinBlockDOM() |
| Route |
POST /api/lute/spinBlockDOM |
| Middleware |
model.CheckAuth only (missing model.CheckAdminRole) |
| Library |
luteEngine.SpinBlockDOM(dom) (external: github.com/88250/lute) |
| Middleware |
model.ControlConcurrency (per-path serialization) |
Root Cause Analysis
Inconsistent role gating. The router registers both endpoints in the same file:
ginServer.Handle("POST", "/api/lute/spinBlockDOM",
model.CheckAuth, spinBlockDOM) // 未测试
ginServer.Handle("POST", "/api/lute/html2BlockDOM",
model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, html2BlockDOM)
Both call into the Lute parser. html2BlockDOM has CheckAdminRole and CheckReadonly. spinBlockDOM has neither. The inline comment // 未测试 confirms the omission was noticed but not resolved.
No input size validation. The handler extracts the DOM string from the request body and passes it directly to the parser:
var dom string
if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("dom", &dom, true, false)) {
return
}
luteEngine := model.NewLute()
dom = luteEngine.SpinBlockDOM(dom)
util.ParseJsonArgs validates presence and string type but does not constrain len(dom). The gin server has a MaxMultipartMemory setting but JSON body size is not subject to that limit in the reviewed code. No explicit JSON body size limit was identified for this endpoint.
Serialization amplifies starvation. ControlConcurrency serializes the /api/lute/spinBlockDOM path:
mutex.Lock()
defer mutex.Unlock()
c.Next()
A caller sending a very large DOM causes SpinBlockDOM to hold the per-path mutex for the duration of the parse, blocking any concurrent request to the same path. While this prevents parallel resource exhaustion, it creates a deterministic starvation vector for a single caller.
Code Analysis
Route registration with inconsistency visible in source:
// kernel/api/router.go
ginServer.Handle("POST", "/api/lute/spinBlockDOM",
model.CheckAuth, spinBlockDOM) // Only CheckAuth — 未测试
ginServer.Handle("POST", "/api/lute/html2BlockDOM",
model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, html2BlockDOM)
// All three middleware — the expected pattern for write/transform endpoints
Handler with no size check:
// kernel/api/lute.go
func spinBlockDOM(c *gin.Context) {
ret := gulu.Ret.NewResult()
defer c.JSON(http.StatusOK, ret)
arg, ok := util.JsonArg(c, ret)
if !ok { return }
var dom string
if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("dom", &dom, true, false)) {
return
}
// No: if len(dom) > threshold { reject }
luteEngine := model.NewLute()
dom = luteEngine.SpinBlockDOM(dom) // Lute parser called on unconstrained input
ret.Data = map[string]any{"dom": dom}
}
The Lute parser is called with no upper bound on the size or complexity of the input string. The library is a complex parsing system; its behavior on adversarial inputs is out of scope for static analysis.
Attack Prerequisites
For the authorization gap:
-
Authenticated as any role (RoleEditor or RoleReader) via a valid session cookie, Authorization header, or publish-service JWT.
-
Network access to the SiYuan kernel port.
For endpoint starvation:
-
Authenticated as any role (same as above).
-
Ability to send large POST bodies to /api/lute/spinBlockDOM.
No user interaction required for either scenario.
Attack Scenarios
Scenario 1 — Access by Reader/Editor
- A publish-service
RoleReader account (or an Editor who should not have access to system-level DOM transformation) authenticates to the SiYuan publish proxy.
- The caller sends:
POST /api/lute/spinBlockDOM
Authorization: Token <reader-jwt>
Content-Type: application/json
{"dom": "<div>some content</div>"}
- SiYuan processes the request and returns the transformed DOM. No role check blocks the call.
Severity of this scenario as described: Low. The endpoint performs a transformation and returns data; it does not mutate persistent state in the reviewed code. The concern escalates if SpinBlockDOM has undiscovered vulnerabilities in the Lute parser when called on attacker-controlled input.
Scenario 2 — Endpoint Starvation
- An authenticated caller (any role) repeatedly sends large DOM blobs to
/api/lute/spinBlockDOM.
ControlConcurrency serializes these requests through a single per-path mutex.
- Each large parse holds the mutex for an extended period, causing all other concurrent requests to this specific endpoint to queue or time out.
- Legitimate users who depend on the endpoint receive delayed or failed responses.
Scope limitation: Starvation is confined to the /api/lute/spinBlockDOM path by the per-path mutex design. Other SiYuan endpoints are not affected by this specific starvation.
Proof of Concept
The following PoC is theoretical and requires runtime verification.
Authorization gap:
# Using a RoleReader JWT obtained from the publish service
curl -s -X POST http://localhost:6806/api/lute/spinBlockDOM \
-H "Authorization: Token <READER_JWT>" \
-H "Content-Type: application/json" \
-d '{"dom": "<div data-node-id=\"20240101000000-abcdefg\"><p>test</p></div>"}'
# Expected if vulnerable: {"code":0,"msg":"","data":{"dom":"..."}}
# Expected if mitigated: 403 Forbidden
Starvation (conceptual):
# Generate a large DOM blob
python3 -c "print('{\"dom\": \"' + '<div>' * 50000 + '</div>' * 50000 + '\"}')" > large_dom.json
# Submit with valid credentials
curl -s -X POST http://localhost:6806/api/lute/spinBlockDOM \
-H "Authorization: Token <VALID_TOKEN>" \
-H "Content-Type: application/json" \
-d @large_dom.json &
# While previous request is processing, concurrent request to same endpoint stalls
curl -s -X POST http://localhost:6806/api/lute/spinBlockDOM \
-H "Authorization: Token <VALID_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"dom": "<p>urgent</p>"}'
# This request queues behind the large parse
The actual impact of the starvation scenario depends on whether SpinBlockDOM exhibits linear or super-linear time complexity for large inputs — this requires empirical measurement.
Security Impact
| Dimension |
Impact |
| Confidentiality |
None confirmed — the endpoint returns only a transformation of the caller-supplied input; it does not read persistent state. |
| Integrity |
Low for the role gap as currently understood. Reassess if SpinBlockDOM modifies state indirectly. |
| Availability |
Low-to-Medium — starvation is bounded to the /api/lute/spinBlockDOM path by the per-path mutex. |
| Parser safety |
Unknown — the security profile of luteEngine.SpinBlockDOM against adversarial input is not assessed in this report. |
Unknown — parser safety: If the Lute library has latent parsing vulnerabilities (heap exhaustion, panic, memory corruption), exposing it to unauthenticated or low-privilege callers is a meaningful attack surface expansion relative to the current html2BlockDOM gating. This concern is classified as Unknown rather than confirmed.
Suggested Fix
Primary Fix — Add CheckAdminRole to Match Sibling Endpoint
// kernel/api/router.go
ginServer.Handle("POST", "/api/lute/spinBlockDOM",
model.CheckAuth, model.CheckAdminRole, spinBlockDOM)
// Remove the "// 未测试" comment or replace with a tested annotation
Add Explicit Input Size Limit
// kernel/api/lute.go — spinBlockDOM
const maxSpinDOMBytes = 1 * 1024 * 1024 // 1 MB
var dom string
if !util.ParseJsonArgs(arg, ret, util.BindJsonArg("dom", &dom, true, false)) {
return
}
if len(dom) > maxSpinDOMBytes {
ret.Code = -1
ret.Msg = "dom input exceeds maximum permitted size"
return
}
luteEngine := model.NewLute()
dom = luteEngine.SpinBlockDOM(dom)
Defense in Depth
Conduct fuzz testing of SpinBlockDOM with adversarial inputs before expanding access to lower-privilege roles. Reference the Lute project's issue tracker for any known parsing vulnerabilities.
Summary
The
/api/lute/spinBlockDOMendpoint is registered with onlymodel.CheckAuthmiddleware, making it accessible to any authenticated role includingRoleEditorandRoleReader. Its sibling endpoint/api/lute/html2BlockDOM, which performs a comparable DOM transformation, is correctly guarded withmodel.CheckAdminRoleandmodel.CheckReadonly. The inconsistency is annotated in the router source code itself with the comment// 未测试("untested"), indicating the endpoint has not been security-reviewed.The handler accepts an arbitrary
domstring from the request body, passes it without length validation toluteEngine.SpinBlockDOM(dom), and returns the transformed result. The Lute library is a large, actively maintained parser written in Go. The security profile ofSpinBlockDOMagainst adversarial input — including its behavior on pathologically nested, large, or malformed DOM blobs — is not documented and has not been fuzz-tested as part of this research.A second concern is resource consumption.
ControlConcurrencyserializes all requests to/api/lute/spinBlockDOMthrough a per-path mutex. A caller who sends a request with a large or complex DOM input causes the mutex to be held for the duration of the parse, blocking any concurrent legitimate request to this endpoint. This is a serialized denial-of-service (starvation) of the endpoint rather than a parallel exhaustion.All current releases of SiYuan are affected. The endpoint is accessible to publish-mode
RoleReadercallers when the publish service is active.Affected Components
kernel/api/router.gokernel/api/lute.go—spinBlockDOM()POST /api/lute/spinBlockDOMmodel.CheckAuthonly (missingmodel.CheckAdminRole)luteEngine.SpinBlockDOM(dom)(external:github.com/88250/lute)model.ControlConcurrency(per-path serialization)Root Cause Analysis
Inconsistent role gating. The router registers both endpoints in the same file:
Both call into the Lute parser.
html2BlockDOMhasCheckAdminRoleandCheckReadonly.spinBlockDOMhas neither. The inline comment// 未测试confirms the omission was noticed but not resolved.No input size validation. The handler extracts the DOM string from the request body and passes it directly to the parser:
util.ParseJsonArgsvalidates presence and string type but does not constrainlen(dom). The gin server has aMaxMultipartMemorysetting but JSON body size is not subject to that limit in the reviewed code. No explicit JSON body size limit was identified for this endpoint.Serialization amplifies starvation.
ControlConcurrencyserializes the/api/lute/spinBlockDOMpath:A caller sending a very large DOM causes
SpinBlockDOMto hold the per-path mutex for the duration of the parse, blocking any concurrent request to the same path. While this prevents parallel resource exhaustion, it creates a deterministic starvation vector for a single caller.Code Analysis
Route registration with inconsistency visible in source:
Handler with no size check:
The Lute parser is called with no upper bound on the size or complexity of the input string. The library is a complex parsing system; its behavior on adversarial inputs is out of scope for static analysis.
Attack Prerequisites
For the authorization gap:
Authenticated as any role (
RoleEditororRoleReader) via a valid session cookie,Authorizationheader, or publish-service JWT.Network access to the SiYuan kernel port.
For endpoint starvation:
Authenticated as any role (same as above).
Ability to send large POST bodies to
/api/lute/spinBlockDOM.No user interaction required for either scenario.
Attack Scenarios
Scenario 1 — Access by Reader/Editor
RoleReaderaccount (or an Editor who should not have access to system-level DOM transformation) authenticates to the SiYuan publish proxy.Scenario 2 — Endpoint Starvation
/api/lute/spinBlockDOM.ControlConcurrencyserializes these requests through a single per-path mutex.Proof of Concept
Authorization gap:
Starvation (conceptual):
Security Impact
SpinBlockDOMmodifies state indirectly./api/lute/spinBlockDOMpath by the per-path mutex.luteEngine.SpinBlockDOMagainst adversarial input is not assessed in this report.Suggested Fix
Primary Fix — Add
CheckAdminRoleto Match Sibling EndpointAdd Explicit Input Size Limit
Defense in Depth
Conduct fuzz testing of
SpinBlockDOMwith adversarial inputs before expanding access to lower-privilege roles. Reference the Lute project's issue tracker for any known parsing vulnerabilities.