Importing bookmarks from an external site looked like a textbook SSRF target: you hand it a URL, the server fetches that URL, and it previews what it finds. I threw every SSRF trick I know at it, and none of them landed. What I walked away with instead was an XSS hiding in how it parsed the response.

The function

The importer works in four steps:

  1. You give it a URL.
  2. The server sends an HTTP request to that URL.
  3. It previews the bookmarks it finds on the page.
  4. On Add, it stores them into your own page.

The preview only pulls the contents of the <title> and <a> tags from the fetched page.

Attacker URL - <img src=x onerror> hidden in <title> and <a> App fetches + previews: <a> sanitized · <title> passed through Self-XSS - the <title> payload executes "Add" stores the bookmark - but strips on* event handlers <audio src/onerror=alert(document.cookie)> survives the filter Stored XSS - fires for every visitor of the page
Fig. 01 - the importer sanitizes <a> but not <title>, giving a self-XSS; the save step strips on* handlers, but <audio src/onerror> slips past into a stored XSS.

I tried every SSRF payload I could think of against step 2, and nothing came back. So instead of attacking the fetch, I turned the page it fetches into the attack surface.

Self-XSS: the title tag isn't filtered

I hosted a page with this content and pointed the importer at it:

<title>
Title Injected<img src=x onerror=alert(1)>
</title>

<a href="#">
Link Injected<img src=x onerror=alert(1)>
</a>

The <a> content came back filtered, but the <title> did not, and its payload fired. For now, though, this is only a self-XSS: I am injecting into a preview of a page I already control.

Stored XSS: past the event-handler filter

The interesting step is Add, which stores a bookmark into the victim's own page:

  • The preview accepted any payload; there is no XSS filtering there at all.
  • But the Add step strips JavaScript event handlers from the bookmark before storing it.

So a plain onerror= is removed on save. After some fuzzing, this payload slipped through:

<audio src/onerror=alert(document.cookie)>

and got stored as:

<audio src(unknown) onerror="alert(document.cookie)">

The unusual src/onerror form, with no space and no value on src, confused the filter enough that the onerror handler survived the strip and was written into the page. Now it is stored, firing for anyone who loads it.

The anticlimax

The report was closed as Informative: the program's policy does not accept XSS.

Came looking for SSRF, left with a stored XSS and a shrug. Still a fun little parser bug.