Joplin Server: Stored XSS via Inline-Served Note Attachment on Public Shares

Joplin Server serves the attachments of publicly published notes with a Content-Type taken from the attacker-controlled resource mime field, inline. A non-admin user uploads an SVG with an empty title and runs arbitrary JavaScript in the server origin for anyone opening the public share, escalating to full admin access against a logged-in administrator.

Advisory ID: TP-2026-060
Product: Joplin Server (self-hosted sync and sharing backend for the open-source Joplin note-taking app)
Vulnerability type: Stored Cross-Site Scripting (CWE-79)
CVE: CVE-2026-59814
CVSS 3.1: 7.6 (High) · CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N
Affected versions: <= 3.7.1
Patched in: 3.7.2
Vendor advisory: GHSA-mx98-7h4g-6gmh
Reported: 23 June 2026

Summary

Joplin Server serves the attachments (resources) of publicly published notes through GET /shares/:id?resource_id= with a Content-Type taken directly from the attacker-controlled resource mime field. The route sets Content-Disposition: attachment only when the resource has a non-empty title. A resource uploaded with an empty title and mime: image/svg+xml is served inline. The application sets one CSP header for the whole site, frame-ancestors 'none', with no script-src, no sandbox, and no X-Content-Type-Options: nosniff, so the inline SVG document runs its embedded script in the Joplin Server web origin. Served user content shares the application origin by default. Any authenticated non-admin user uploads such a resource through the documented sync API and publishes the note; the public link carries the payload to any viewer.

Root cause

renderResource() returns the served mime and filename directly from the attacker-uploaded resource-metadata item, with no MIME allow-list and an optional title (packages/server/src/utils/joplinUtils.ts:206-211). The public share route writes that attacker mime as the response Content-Type and emits Content-Disposition only when result.filename is truthy, so an empty resource title produces an inline response (packages/server/src/routes/index/shares.ts:57-60). The only CSP set for the whole application is frame-ancestors 'none', with no script-src, no sandbox, and no X-Content-Type-Options: nosniff (packages/server/src/middleware/clickJackingHandler.ts:5), so an inline image/svg+xml document runs its script. Served user content uses the application origin because userContentBaseUrl defaults to baseUrl (packages/server/src/config.ts:210,246). Publishing a note carries no permission gate, because ShareModel.checkIfAllowed restricts only folder shares (packages/server/src/models/ShareModel.ts:23).

Proof of Concept

# 1. As a non-admin user, each via PUT /api/items/root:/<name>:/content
#    (header X-API-AUTH: <session>), upload: a folder, a note whose body is
#    ![x](:/<resId>), a resource-metadata item with an EMPTY title and
#    mime image/svg+xml, and the resource blob:
<svg xmlns="http://www.w3.org/2000/svg" onload="document.title=document.domain"><script>document.title=document.domain</script></svg>

# 2. Publish the note:
POST /api/shares
{"note_id":"<noteId>"}
=> returns the share id

# 3. As an anonymous client, open the resource URL:
GET /shares/<shareId>?resource_id=<resId>
HTTP/1.1 200 OK
Content-Security-Policy: frame-ancestors 'none'
Content-Type: image/svg+xml
(no Content-Disposition, no X-Content-Type-Options)

# Live-verified: real Chrome renders the SVG document, runs the onload handler
# and the inline <script>, and rewrites document.title in the Joplin Server
# origin. With a logged-in administrator viewing, an equivalent payload reads
# /admin/users (HTTP 200, full user list) and the anti-CSRF token from
# /admin/users/new using the admin session cookie.

Impact

  • Arbitrary JavaScript runs in the Joplin Server origin in any viewer's browser of a published note.
  • Against a logged-in administrator (live-verified): the script reads the admin-only /admin/users page (HTTP 200, full user list and emails) and reads the anti-CSRF token from /admin/users/new under the admin session cookie, defeating CSRF and enabling admin write operations (create or disable users, reset user passwords, impersonate).
  • Against any logged-in user: same-origin read and modification of that user's data and account.
  • Planting needs only the lowest non-admin role on default config; the carrier is an anonymous public share link.

References

Is Something Like This in Your Software?

Our team discovered this vulnerability during its work. Have your applications reviewed by the same specialists, with a penetration test from turingpoint.