SVG Sanitizer Bypass via Whitespace in javascript: URI — Unauthenticated XSS
Summary
SiYuan's SVG sanitizer (SanitizeSVG) checks href attributes for the javascript: prefix using strings.HasPrefix(). However, inserting ASCII tab (	), newline ( ), or carriage return ( ) characters inside the javascript: string bypasses this prefix check. Browsers strip these characters per the WHATWG URL specification before parsing the URL scheme, so the JavaScript still executes. This allows an attacker to inject executable JavaScript into the unauthenticated /api/icon/getDynamicIcon endpoint, creating a reflected XSS.
This is a second bypass of the fix for CVE-2026-29183 (fixed in v3.5.9), distinct from the <animate> element bypass.
Affected Component
- File:
kernel/util/misc.go
- Function:
SanitizeSVG() (lines 234-319)
- Specific check: Line 271 —
strings.HasPrefix(val, "javascript:")
- Endpoint:
GET /api/icon/getDynamicIcon?type=8&content=... (unauthenticated)
- Version: SiYuan <= 3.5.9
Root Cause
The sanitizer uses Go's html.Parse which decodes HTML entities in attribute values. When the input contains java	script:alert(1), the parser decodes 	 to a literal tab character (U+0009). The sanitizer then checks:
val := strings.TrimSpace(strings.ToLower(a.Val))
// val is now "java\tscript:alert(1)"
if strings.HasPrefix(val, "javascript:") {
continue // This check FAILS — tab breaks the prefix match
}
strings.TrimSpace only removes leading/trailing whitespace, not internal whitespace. The HasPrefix check fails because "java\tscript:..." does not start with "javascript:".
However, per the WHATWG URL Standard, step 1 of URL parsing removes all ASCII tab and newline characters (U+0009, U+000A, U+000D) from the input. So the browser parses java\tscript:alert(1) as javascript:alert(1).
Proof of Concept
Vector 1: Tab character (	)
GET /api/icon/getDynamicIcon?type=8&content=</text><a href="java	script:alert(document.domain)"><text x="50%25" y="80%25" fill="red" style="font-size:60px">Click me</text></a><text>&color=blue
Vector 2: Newline character ( )
GET /api/icon/getDynamicIcon?type=8&content=</text><a href="java script:alert(document.domain)"><text x="50%25" y="80%25" fill="red" style="font-size:60px">Click me</text></a><text>&color=blue
Vector 3: Carriage return ( )
GET /api/icon/getDynamicIcon?type=8&content=</text><a href="java script:alert(document.domain)"><text x="50%25" y="80%25" fill="red" style="font-size:60px">Click me</text></a><text>&color=blue
Vector 4: Multiple whitespace characters
GET /api/icon/getDynamicIcon?type=8&content=</text><a href="j	a v a	s c r	i p t:alert(document.domain)"><text x="50%25" y="80%25" fill="red" style="font-size:60px">Click me</text></a><text>&color=blue
Processing trace
- Input:
<a href="java	script:alert(document.domain)">
- html.Parse: Decodes entity → attribute value =
java\tscript:alert(document.domain)
- Sanitizer:
TrimSpace(ToLower(val)) = java\tscript:alert(document.domain) (tab preserved in middle)
- HasPrefix check:
"java\tscript:..." does NOT start with "javascript:" → passes through
- html.Render: Outputs literal tab character in href (tabs are not HTML-special)
- Browser URL parser: Strips tab per WHATWG URL spec →
javascript:alert(document.domain)
- User clicks link → JavaScript executes
Attack Scenario
Same as CVE-2026-29183 / advisory #01:
- Attacker crafts a malicious
getDynamicIcon URL
- Victim navigates to the URL (or is redirected)
- SVG renders with
Content-Type: image/svg+xml
- Victim clicks the text link in the SVG
- JavaScript executes in SiYuan's origin
- Attacker steals session cookies, API tokens, or makes authenticated API calls
Impact
- Severity: CRITICAL (CVSS ~9.1)
- Type: CWE-79 (Improper Neutralization of Input During Web Page Generation)
- Unauthenticated reflected XSS via SVG injection
- Executes in the SiYuan application origin
- Bypasses the fix for CVE-2026-29183
- Independent of the
<animate> element bypass (advisory #01) — different root cause
Suggested Fix
Replace the simple HasPrefix check with whitespace-stripped comparison:
// Strip ASCII tab, newline, CR before checking for javascript: prefix
cleaned := strings.Map(func(r rune) rune {
if r == '\t' || r == '\n' || r == '\r' {
return -1 // Remove character
}
return r
}, val)
if key == "href" || key == "xlink:href" || key == "xlinkhref" {
if strings.HasPrefix(cleaned, "javascript:") {
continue
}
if strings.HasPrefix(cleaned, "data:") {
if strings.Contains(cleaned, "text/html") || strings.Contains(cleaned, "image/svg+xml") || strings.Contains(cleaned, "application/xhtml+xml") {
continue
}
}
}
This should also be applied to the data: URI check, as the same whitespace bypass could potentially affect it.
References
SVG Sanitizer Bypass via Whitespace in
javascript:URI — Unauthenticated XSSSummary
SiYuan's SVG sanitizer (
SanitizeSVG) checkshrefattributes for thejavascript:prefix usingstrings.HasPrefix(). However, inserting ASCII tab (	), newline ( ), or carriage return ( ) characters inside thejavascript:string bypasses this prefix check. Browsers strip these characters per the WHATWG URL specification before parsing the URL scheme, so the JavaScript still executes. This allows an attacker to inject executable JavaScript into the unauthenticated/api/icon/getDynamicIconendpoint, creating a reflected XSS.This is a second bypass of the fix for CVE-2026-29183 (fixed in v3.5.9), distinct from the
<animate>element bypass.Affected Component
kernel/util/misc.goSanitizeSVG()(lines 234-319)strings.HasPrefix(val, "javascript:")GET /api/icon/getDynamicIcon?type=8&content=...(unauthenticated)Root Cause
The sanitizer uses Go's
html.Parsewhich decodes HTML entities in attribute values. When the input containsjava	script:alert(1), the parser decodes	to a literal tab character (U+0009). The sanitizer then checks:strings.TrimSpaceonly removes leading/trailing whitespace, not internal whitespace. TheHasPrefixcheck fails because"java\tscript:..."does not start with"javascript:".However, per the WHATWG URL Standard, step 1 of URL parsing removes all ASCII tab and newline characters (U+0009, U+000A, U+000D) from the input. So the browser parses
java\tscript:alert(1)asjavascript:alert(1).Proof of Concept
Vector 1: Tab character (
	)Vector 2: Newline character (
)Vector 3: Carriage return (
)Vector 4: Multiple whitespace characters
Processing trace
<a href="java	script:alert(document.domain)">java\tscript:alert(document.domain)TrimSpace(ToLower(val))=java\tscript:alert(document.domain)(tab preserved in middle)"java\tscript:..."does NOT start with"javascript:"→ passes throughjavascript:alert(document.domain)Attack Scenario
Same as CVE-2026-29183 / advisory #01:
getDynamicIconURLContent-Type: image/svg+xmlImpact
<animate>element bypass (advisory #01) — different root causeSuggested Fix
Replace the simple
HasPrefixcheck with whitespace-stripped comparison:This should also be applied to the
data:URI check, as the same whitespace bypass could potentially affect it.References