fix(lyrics-plus): sanitize Genius HTML before dangerouslySetInnerHTML#3886
fix(lyrics-plus): sanitize Genius HTML before dangerouslySetInnerHTML#3886sebastiondev wants to merge 1 commit into
Conversation
ProviderGenius.fetchLyricsVersion concatenates the raw innerHTML of every <div data-lyrics-container="true"> from a Genius page and returns the string to GeniusPage in Pages.js, which renders it via dangerouslySetInnerHTML. Genius pages contain crowd-sourced annotations and are fetched through a plaintext CORS proxy, so their contents are untrusted. An attacker who can influence a Genius annotation or MitM the proxy response can inject <script>, <img onerror=...>, <iframe srcdoc=...>, javascript: URLs, or SVG event handlers that execute inside the Spotify web view — where they gain access to Spicetify.CosmosAsync (authenticated Spotify HTTP), Spicetify.LocalStorage, Spicetify.Player, and can persist across sessions via lyrics-plus:local-lyrics. Rebuild the lyric HTML from an allow-list of tags (a, br, i, b, em, strong, span, p, div) and attributes (a[href|data-id]) before returning it. href values are restricted to http(s)/absolute-path/fragment URLs so that the annotation-note fetch loop in Pages.js#GeniusPage keeps working while javascript:/data: URLs are rejected.
📝 WalkthroughWalkthroughProviderGenius.js adds a sanitizeLyricNode function that allow-lists lyric HTML tags and attributes, strips disallowed elements while keeping their text, escapes content, and filters href URL schemes. fetchLyricsVersion now builds lyric output via this sanitizer instead of raw innerHTML. ChangesLyric HTML Sanitization
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant fetchLyricsVersion
participant sanitizeLyricNode
participant DOM
fetchLyricsVersion->>DOM: read lyric container node (i)
fetchLyricsVersion->>sanitizeLyricNode: sanitizeLyricNode(i)
sanitizeLyricNode->>DOM: traverse child nodes
sanitizeLyricNode-->>fetchLyricsVersion: return sanitized HTML string
fetchLyricsVersion->>fetchLyricsVersion: append sanitized HTML + <br>
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
CustomApps/lyrics-plus/ProviderGenius.js (1)
11-52: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueSanitizer is sound; consider centralizing the escape logic.
The allow-list approach is correct: disallowed tags are unwrapped with their text kept and escaped, block/inline tags are emitted attribute-free, anchors keep only
href/data-id, andSAFE_HREF(anchored, applied aftertrim()) rejectsjavascript:/data:/vbscript:and whitespace/null-byte prefixed schemes. Attribute values are always double-quoted and"-escaped, so there's no breakout. This is a solid fix for thedangerouslySetInnerHTMLsink inPages.js#GeniusPage.The static-analysis CWE-79 hint on lines 14/38 is a false positive for these contexts — the manual escaping is complete for text (
&<>) and double-quoted attributes (&"<>), and a vetted sanitizer like DOMPurify isn't readily available in the Spicetify runtime. That said, the escape logic is duplicated; extracting small helpers reduces the chance of the two copies drifting in this security-sensitive path.♻️ Optional: extract escape helpers
const SAFE_HREF = /^(https?:\/\/|\/|#)/i; + const escapeHTML = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); + const escapeAttr = (s) => escapeHTML(s).replace(/"/g, """); function sanitizeLyricNode(node) { let out = ""; for (const child of node.childNodes) { if (child.nodeType === 3 /* TEXT_NODE */) { - out += child.textContent.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); + out += escapeHTML(child.textContent); continue; }- attrs.push(`${attr.name}="${value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">")}"`); + attrs.push(`${attr.name}="${escapeAttr(value)}"`);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@CustomApps/lyrics-plus/ProviderGenius.js` around lines 11 - 52, The sanitizer in sanitizeLyricNode is already correct, but the same escaping logic is duplicated for text nodes and attribute values. Extract shared escape helper(s) and use them consistently inside sanitizeLyricNode so the text and attribute sanitization paths cannot drift, keeping the allow-list behavior, SAFE_HREF check, and existing tag handling unchanged.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@CustomApps/lyrics-plus/ProviderGenius.js`:
- Around line 11-52: The sanitizer in sanitizeLyricNode is already correct, but
the same escaping logic is duplicated for text nodes and attribute values.
Extract shared escape helper(s) and use them consistently inside
sanitizeLyricNode so the text and attribute sanitization paths cannot drift,
keeping the allow-list behavior, SAFE_HREF check, and existing tag handling
unchanged.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 88aafaa0-1df2-4bec-bdfe-3df53a7ab3d1
📒 Files selected for processing (1)
CustomApps/lyrics-plus/ProviderGenius.js
|
Congratulations on using AI to find this, however it's not applicable here. |
|
@rxri Apparently, that entire profile is a "Security Research" AI agent; you can check their profile's website. I'd block their account so they don't ever come back. |
|
thanks for letting me know I'm going to steal the info off hundreds of spicetify genius users now |
Yeah I noticed it that's it's some company's. Pretty sad to see where GitHub goes with all of these "automatic" AI bots. We already blocked this account off this org |
Summary
ProviderGenius.fetchLyricsVersionconcatenates the raw.innerHTMLof every<div data-lyrics-container="true">scraped from a Genius page and returns the string toGeniusPageinCustomApps/lyrics-plus/Pages.js, which renders it via ReactdangerouslySetInnerHTML. Because Genius pages contain crowd-sourced annotations and are fetched through a plaintext Cosmos HTTP proxy, the HTML is untrusted, so a<script>,<img onerror>,<iframe srcdoc>,<svg onload>, orjavascript:URL that reaches that string executes inside the Spotify web view — which is the same origin as the Spicetify runtime.CustomApps/lyrics-plus/ProviderGenius.js→fetchLyricsVersion(previously didlyrics += \${i.innerHTML}``)
CustomApps/lyrics-plus/Pages.jsL860 (lyrics) and L886 (lyrics2), bothdangerouslySetInnerHTML: { __html: lyrics }1.2.14 – 1.2.30(Genius provider is disabled above 1.2.31 via thespotifyVersion >= "1.2.31"lexicographic guard inindex.js,TabBar.js, andSettings.js; the sanitizer is defense-in-depth for those still on the affected range and for the fragility of the lexicographic version check).Data flow
The fix
Sanitize the Genius HTML at the source, inside
ProviderGenius.fetchLyricsVersion, by walking the parsed DOM and emitting a fresh string from an allow-list:ALLOWED_LYRIC_TAGS = { A, BR, I, B, EM, STRONG, SPAN, P, DIV }— everything else (script,iframe,img,svg,style,template,plaintext,object,embed,base,form,link,meta,foreignObject, ...) is dropped; only the sanitized text content survives so lyrics remain readable.ALLOWED_LYRIC_ATTRS = { A: { href, data-id } }— every other attribute (including allon*handlers,srcdoc,style,formaction,xlink:href, ...) is stripped.hrefis trimmed and matched against/^(https?:\/\/|\/|#)/i, sojavascript:,data:,vbscript:, protocol-relative//evil.com, and backslash-prefixed schemes are rejected. This preserves thedata-id/#note-…annotation-note fetch loop inGeniusPage(which callsProviderGenius.getNote(id)).&,<,>,").Rebuilding via the DOM API rather than regex avoids classic sanitizer bypasses (mXSS via
<style>/<template>/<plaintext>, malformed comment closers,<svg><script>context switches, etc.) — anything the parser dropped can't reappear in the output.Nothing else in the file changed; the diff is 57 additions in one file:
Testing
I loaded the patched
ProviderGenius.jsin a JSDOM harness and ran the sanitizer against 23 attempted bypass payloads before submitting, including:<script>alert(1)</script>and<img src=x onerror=alert(1)>(baseline).<iframe srcdoc="<script>parent.postMessage(document.cookie,'*')</script>">.<svg><script>alert(1)</script></svg>and<svg onload=alert(1)>.<style>@import "//evil"</style>and<template><script>…</script></template>mXSS attempts.<plaintext>/<foreignObject>context-switch tricks.<a href="javascript:alert(1)">,<a href=" \tjavascript:alert(1)">(entity-encoded, whitespace, tab, and control-char variants),<a href="//evil.com">,<a href="\\evil.com">.<a>,data-id="" onclick=alert(1) x="", andhrefvalues containing quotes, angle brackets, and newlines.<constructor>and<__proto__>that could confuse a naiveSetlookup.Every payload produced clean output containing zero
<script>,<iframe>,<img>,<svg>,<style>,<object>,<embed>,<base>,<form>,<link>,<meta>, or<template>tags, noon*attributes, and no unsafe-scheme hrefs. Attribute values were entity-encoded so quote-escapes couldn't break out of the surrounding"…".I also checked the downstream sinks after the fix:
Pages.jsL860 (__html: lyrics) and L886 (__html: lyrics2) both receive the already-sanitized string fromfetchLyricsVersion.index.js#saveLocalLyricsstill strips all HTML with.replace(/<[^>]*>/g, "")before persisting tolocalStorage["lyrics-plus:local-lyrics"], so no XSS payload can be smuggled into persistent storage either.<a data-id="…">annotation anchors,<br>separators) round-trips through the sanitizer unchanged, so the annotation-note feature keeps working.Adversarial review
Before submitting I tried to talk myself out of this. The concerns considered:
spotifyVersion >= "1.2.31", a lexicographic string compare that fails on any hypothetical Spotify1.2.100+(which would sort below"1.2.31"as strings and re-enable Genius). The sanitizer is exactly the defense-in-depth that stops a version-check regression from becoming an XSS. It also covers users still on the affected 1.2.14–1.2.30 range today.Spicetify.CosmosAsync(authenticated Spotify HTTP),Spicetify.LocalStorage, andSpicetify.Playeris a real capability escalation over "can post content on Genius".dangerouslySetInnerHTML, which is the whole point of the API name. The two sinks inPages.jsbypass React's escaping.Proof of concept
Minimal reproducer that mirrors what
fetchLyricsVersiondoes — parse a hostile Genius body, run the current (vulnerable) code path vs. the patched one, and observe that the vulnerable path leaves executable HTML while the patched path does not.Expected output:
In-app reproduction (for a maintainer with a dev install of Spicetify + Spotify 1.2.14–1.2.30 and lyrics-plus loaded): intercept the response of
https://genius.com/<song-path>with any local proxy (mitmproxy, Charles, Fiddler) and inject<script>alert("XSS via lyrics-plus")</script>inside<div data-lyrics-container="true">…</div>. Play the matching track and open the lyrics-plus panel — on unpatched builds the alert fires inside the Spotify web view; on patched builds the script is stripped and the lyrics render as text.Discovered by the Sebastion AI GitHub App.
Summary by CodeRabbit