{"article":{"slug":"xss-in-yarr","title":"XSS in yarr","subtitle":null,"summary":"Matthew Hogg walks through a cross-site scripting bug in yarr, the open source Go RSS reader: content passes its HTML sanitizer, but parsing differences and a permissive default iframe sandbox let a malicious feed run script for any user who opens a post.","content_type":"research","language":"en","canonical_url":"https://0x67673068.me/xss-in-yarr/","author":{"name":"Matthew Hogg","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"Matthew Hogg","url":"https://0x67673068.me/","listing_slug":null,"listing":null},"topics":[{"name":"security","slug":"security","url":"https://listedarticles.com/topics/security"},{"name":"web-development","slug":"web-development","url":"https://listedarticles.com/topics/web-development"},{"name":"vulnerability-research","slug":"vulnerability-research","url":"https://listedarticles.com/topics/vulnerability-research"},{"name":"open-source","slug":"open-source","url":"https://listedarticles.com/topics/open-source"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":829,"reading_minutes":4,"published_at":"2026-10-20T00:00:00.000Z","added_at":"2026-10-30T08:15:12.523Z","updated_at":"2026-10-30T08:15:12.523Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/xss-in-yarr","markdown_url":"https://listedarticles.com/articles/xss-in-yarr.md","example":false,"citation":"Matthew Hogg, Matthew Hogg. \"XSS in yarr.\" 20 Oct 2026. https://0x67673068.me/xss-in-yarr/ (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://0x67673068.me/xss-in-yarr/"},"body_markdown":"# XSS in yarr\n\n*20 Oct, 2026*\n\n[yarr](https://github.com/nkanaev/yarr) is an open source RSS reader written in Go. I use it myself in my homelab and I was interested one day how yarr loads and renders this content.\n\nTaking a look at their source we can see how. When you add an RSS feed, each post within the feed is treated as an \"item\" within yarr's object model. These are processed with the aptly named `handleItem` function.\n\n```\nfunc (s *Server) handleItem(w http.ResponseWriter, r *http.Request) {\n\tid, err := strconv.ParseInt(r.PathValue(\"id\"), 10, 64)\n        ...\n\tswitch r.Method {\n\tcase http.MethodGet:\n\t\titem := s.db(r).GetItem(id)\n                ...\n\t\titem.Content = sanitizer.Sanitize(item.Link, item.Content)\n\t\tfor i, link := range item.MediaLinks {\n\t\t\titem.MediaLinks[i].Description = sanitizer.Sanitize(item.Link, link.Description)\n\t\t}\n                ...\n```\n\nWhen this loads the post from a remote url it first passes the content through the `Sanitize` function. This one is rather self explanatory.\n\n```\n// Sanitize returns safe HTML.\nfunc Sanitize(baseURL, input string) string {\n\ttokenizer := html.NewTokenizer(bytes.NewBufferString(input))\n\t...\n\tattrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)\n```\n\nThe sanitised content is returned in the `\"content\"` value of the response after it is serialised to JSON.\n![image](https://bear-images.sfo2.cdn.digitaloceanspaces.com/0x67673068/image-1.webp)\nAnd from here dynamically rendered into the DOM.\n\nWith this model in mind, it was clear that if there was a gap in the sanitisation logic it would lead to XSS. So I took a closer look at how the function was implemented. This in itself is beyond explaining thoroughly for the purposes of this post as much of it is quite irrelevant to the vulnerability, so I will summarise it simply as recursive parsing of the HTML nodes sanitising from children first (in fact I'd say they are likely further flaws lurking here...). But onto the vulnerability in question, first we observe in `getTagAllowList` that `iframe` is an allowed tag, and `src` an allowed attribute.\n\n```\n func getTagAllowList() map[string][]string {\n     ...\n     whitelist[\"iframe\"] = []string{\"width\", \"height\", \"frameborder\", \"src\", \"allowfullscreen\"}\n     return whitelist\n }\n```\n\nFurthermore in the function responsible for sanitising attributes we observe there is a special case for handling the `src` attribute of iFrames in `isValidIframeSource`:\n\n```\nfunc sanitizeAttributes(baseURL string, node *html.Node) []string {\n\tvar attrNames []string\n\tvar attrs []html.Attribute\n\ttagName := node.Data\n\n\tfor _, attribute := range node.Attr {\n\t\t// attribute names are case-insensitive; the parser may preserve\n\t\t// the original casing, while the allowlist is lowercase.\n\t\tkey := strings.ToLower(attribute.Key)\n\t\tvalue := attribute.Val\n\n\t\tif !isValidAttribute(tagName, key) {\n\t\t\tcontinue\n\t\t}\n\n\t\tif (tagName == \"img\" || tagName == \"source\") && key == \"srcset\" {\n\t\t\tvalue = sanitizeSrcsetAttr(baseURL, value)\n\t\t}\n\n\t\tif isExternalResourceAttribute(key) {\n\t\t\tif tagName == \"iframe\" {\n\t\t\t\tif isValidIframeSource(baseURL, attribute.Val) { // here!!!\n\t\t\t\t\tvalue = attribute.Val\n\t\t\t\t} else {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t} else if tagName == \"img\" && key == \"src\" && isValidDataAttribute(attribute.Val) {\n\t\t\t\tvalue = attribute.Val\n\t\t\t} else {\n\t\t\t\tvalue = htmlutil.AbsoluteUrl(value, baseURL)\n\t\t\t\tif value == \"\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif !hasValidURIScheme(value) || isBlockedResource(value) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\t\t}\n```\n\nThis function is as follows:\n\n```\nfunc isValidIframeSource(baseURL, src string) bool {\n\twhitelist := []string{\n\t\t\"bandcamp.com\",\n\t\t\"cdn.embedly.com\",\n\t\t\"invidio.us\",\n\t\t\"player.bilibili.com\",\n\t\t\"player.vimeo.com\",\n\t\t\"soundcloud.com\",\n\t\t\"vk.com\",\n\t\t\"w.soundcloud.com\",\n\t\t\"www.dailymotion.com\",\n\t\t\"www.youtube-nocookie.com\",\n\t\t\"www.youtube.com\",\n\t}\n\n\tdomain := htmlutil.URLDomain(src)\n\t// allow iframe from same origin\n\tif htmlutil.URLDomain(baseURL) == domain {\n\t\treturn true\n\t}\n\treturn slices.Contains(whitelist, domain)\n}\n\n...\n\nfunc URLDomain(val string) string {\n\tif u, err := url.Parse(val); err == nil {\n\t\treturn u.Host\n\t}\n\treturn val\n}\n```\n\ntldr, it takes the provided URL (the one in the iFrame src), extracts the host part of the Url, checks it either matches the yarr base URL or an entry in the defined whitelist and rejects it if not.\n\nThe URL is parsed with net/url. The package descriptions states\n\n> Package url parses URLs and implements query escaping.\n> See RFC 3986. This package generally follows RFC 3986, except where it deviates for compatibility reasons. RFC 6874 followed for IPv6 zone literals.\n\nAnd with this comes the trick that enables the polyglot payload. In RFC 3986 the character sequence marks the beginning of the authority section (which contains the host accessed by `URLDomain`).\n\n![image](https://bear-images.sfo2.cdn.digitaloceanspaces.com/0x67673068/image-2.webp)\n\nWe combine this with the fact that iFrames will happily process `javascript:` URIs in their src to arrive at `javascript://www.youtube.com/%0a<arbitrary-js>`. net/url sees the authority www.youtube.com, extracts the host (here it is the entire authority) matches it to the whitelist and approves. Yet in the web browser, when this is processed as a `javascript:` URI everything after the colon is treated as JavaScript code (after a round of URL decoding). Therefore we arrive at:\n\n```\n<iframe src=\"javascript://www.youtube.com/%0a<arbitrary-js>\"></iframe>\n```\n\nwhich is processed as\n\n```\n//www.youtube.com/\n<arbitrary-js>\n```\n\nnote '//' doubles as a JavaScript comment here (and %0a a newline on decoding) - a different meaning in each grammar.\n\nThe final piece is the permissive sandbox policy applied to iFrames by default\n\n```\nfunc extraAttributes(tagName string) ([]string, []html.Attribute) {\n\tswitch tagName {\n...\n\tcase \"iframe\":\n\t\treturn []string{\"sandbox\", \"loading\"}, []html.Attribute{\n\t\t\t{Key: \"sandbox\", Val: \"allow-scripts allow-same-origin allow-popups\"},\n\t\t\t{Key: \"loading\", Val: \"lazy\"},\n\t\t}\n...\n\tdefault:\n\t\treturn nil, nil\n\t}\n}\n```\n\nPutting it all together, an RSS feed can XSS any yarr user who loads their post, here a simple demo (rewriting the parent DOM because no alerts with that policy (: )\n\nI notified the maintainers of yarr about this finding but didn't get a response. With that said I don't see much immediate impact given yarr's account-less nature, at most spying on what other RSS feeds your subscribers are looking at. Nevertheless it was a fun little finding, thanks for reading.\n","body_html":"<h1 id=\"xss-in-yarr\">XSS in yarr</h1>\n<p><em>20 Oct, 2026</em></p>\n<p><a href=\"https://github.com/nkanaev/yarr\" rel=\"nofollow ugc noopener\">yarr</a> is an open source RSS reader written in Go. I use it myself in my homelab and I was interested one day how yarr loads and renders this content.</p>\n<p>Taking a look at their source we can see how. When you add an RSS feed, each post within the feed is treated as an &quot;item&quot; within yarr&#39;s object model. These are processed with the aptly named <code>handleItem</code> function.</p>\n<pre><code>func (s *Server) handleItem(w http.ResponseWriter, r *http.Request) {\n    id, err := strconv.ParseInt(r.PathValue(&quot;id&quot;), 10, 64)\n        ...\n    switch r.Method {\n    case http.MethodGet:\n        item := s.db(r).GetItem(id)\n                ...\n        item.Content = sanitizer.Sanitize(item.Link, item.Content)\n        for i, link := range item.MediaLinks {\n            item.MediaLinks[i].Description = sanitizer.Sanitize(item.Link, link.Description)\n        }\n                ...</code></pre>\n<p>When this loads the post from a remote url it first passes the content through the <code>Sanitize</code> function. This one is rather self explanatory.</p>\n<pre><code>// Sanitize returns safe HTML.\nfunc Sanitize(baseURL, input string) string {\n    tokenizer := html.NewTokenizer(bytes.NewBufferString(input))\n    ...\n    attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)</code></pre>\n<p>The sanitised content is returned in the <code>&quot;content&quot;</code> value of the response after it is serialised to JSON.\n<img src=\"https://bear-images.sfo2.cdn.digitaloceanspaces.com/0x67673068/image-1.webp\" alt=\"image\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" />\nAnd from here dynamically rendered into the DOM.</p>\n<p>With this model in mind, it was clear that if there was a gap in the sanitisation logic it would lead to XSS. So I took a closer look at how the function was implemented. This in itself is beyond explaining thoroughly for the purposes of this post as much of it is quite irrelevant to the vulnerability, so I will summarise it simply as recursive parsing of the HTML nodes sanitising from children first (in fact I&#39;d say they are likely further flaws lurking here...). But onto the vulnerability in question, first we observe in <code>getTagAllowList</code> that <code>iframe</code> is an allowed tag, and <code>src</code> an allowed attribute.</p>\n<pre><code> func getTagAllowList() map[string][]string {\n     ...\n     whitelist[&quot;iframe&quot;] = []string{&quot;width&quot;, &quot;height&quot;, &quot;frameborder&quot;, &quot;src&quot;, &quot;allowfullscreen&quot;}\n     return whitelist\n }</code></pre>\n<p>Furthermore in the function responsible for sanitising attributes we observe there is a special case for handling the <code>src</code> attribute of iFrames in <code>isValidIframeSource</code>:</p>\n<pre><code>func sanitizeAttributes(baseURL string, node *html.Node) []string {\n    var attrNames []string\n    var attrs []html.Attribute\n    tagName := node.Data\n\n    for _, attribute := range node.Attr {\n        // attribute names are case-insensitive; the parser may preserve\n        // the original casing, while the allowlist is lowercase.\n        key := strings.ToLower(attribute.Key)\n        value := attribute.Val\n\n        if !isValidAttribute(tagName, key) {\n            continue\n        }\n\n        if (tagName == &quot;img&quot; || tagName == &quot;source&quot;) &amp;&amp; key == &quot;srcset&quot; {\n            value = sanitizeSrcsetAttr(baseURL, value)\n        }\n\n        if isExternalResourceAttribute(key) {\n            if tagName == &quot;iframe&quot; {\n                if isValidIframeSource(baseURL, attribute.Val) { // here!!!\n                    value = attribute.Val\n                } else {\n                    continue\n                }\n            } else if tagName == &quot;img&quot; &amp;&amp; key == &quot;src&quot; &amp;&amp; isValidDataAttribute(attribute.Val) {\n                value = attribute.Val\n            } else {\n                value = htmlutil.AbsoluteUrl(value, baseURL)\n                if value == &quot;&quot; {\n                    continue\n                }\n                if !hasValidURIScheme(value) || isBlockedResource(value) {\n                    continue\n                }\n            }\n        }</code></pre>\n<p>This function is as follows:</p>\n<pre><code>func isValidIframeSource(baseURL, src string) bool {\n    whitelist := []string{\n        &quot;bandcamp.com&quot;,\n        &quot;cdn.embedly.com&quot;,\n        &quot;invidio.us&quot;,\n        &quot;player.bilibili.com&quot;,\n        &quot;player.vimeo.com&quot;,\n        &quot;soundcloud.com&quot;,\n        &quot;vk.com&quot;,\n        &quot;w.soundcloud.com&quot;,\n        &quot;www.dailymotion.com&quot;,\n        &quot;www.youtube-nocookie.com&quot;,\n        &quot;www.youtube.com&quot;,\n    }\n\n    domain := htmlutil.URLDomain(src)\n    // allow iframe from same origin\n    if htmlutil.URLDomain(baseURL) == domain {\n        return true\n    }\n    return slices.Contains(whitelist, domain)\n}\n\n...\n\nfunc URLDomain(val string) string {\n    if u, err := url.Parse(val); err == nil {\n        return u.Host\n    }\n    return val\n}</code></pre>\n<p>tldr, it takes the provided URL (the one in the iFrame src), extracts the host part of the Url, checks it either matches the yarr base URL or an entry in the defined whitelist and rejects it if not.</p>\n<p>The URL is parsed with net/url. The package descriptions states</p>\n<blockquote><p>Package url parses URLs and implements query escaping.\nSee RFC 3986. This package generally follows RFC 3986, except where it deviates for compatibility reasons. RFC 6874 followed for IPv6 zone literals.</p></blockquote>\n<p>And with this comes the trick that enables the polyglot payload. In RFC 3986 the character sequence marks the beginning of the authority section (which contains the host accessed by <code>URLDomain</code>).</p>\n<figure><img src=\"https://bear-images.sfo2.cdn.digitaloceanspaces.com/0x67673068/image-2.webp\" alt=\"image\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" /></figure>\n<p>We combine this with the fact that iFrames will happily process <code>javascript:</code> URIs in their src to arrive at <code>javascript://www.youtube.com/%0a&lt;arbitrary-js&gt;</code>. net/url sees the authority www.youtube.com, extracts the host (here it is the entire authority) matches it to the whitelist and approves. Yet in the web browser, when this is processed as a <code>javascript:</code> URI everything after the colon is treated as JavaScript code (after a round of URL decoding). Therefore we arrive at:</p>\n<pre><code>&lt;iframe src=&quot;javascript://www.youtube.com/%0a&lt;arbitrary-js&gt;&quot;&gt;&lt;/iframe&gt;</code></pre>\n<p>which is processed as</p>\n<pre><code>//www.youtube.com/\n&lt;arbitrary-js&gt;</code></pre>\n<p>note &#39;//&#39; doubles as a JavaScript comment here (and %0a a newline on decoding) - a different meaning in each grammar.</p>\n<p>The final piece is the permissive sandbox policy applied to iFrames by default</p>\n<pre><code>func extraAttributes(tagName string) ([]string, []html.Attribute) {\n    switch tagName {\n...\n    case &quot;iframe&quot;:\n        return []string{&quot;sandbox&quot;, &quot;loading&quot;}, []html.Attribute{\n            {Key: &quot;sandbox&quot;, Val: &quot;allow-scripts allow-same-origin allow-popups&quot;},\n            {Key: &quot;loading&quot;, Val: &quot;lazy&quot;},\n        }\n...\n    default:\n        return nil, nil\n    }\n}</code></pre>\n<p>Putting it all together, an RSS feed can XSS any yarr user who loads their post, here a simple demo (rewriting the parent DOM because no alerts with that policy (: )</p>\n<p>I notified the maintainers of yarr about this finding but didn&#39;t get a response. With that said I don&#39;t see much immediate impact given yarr&#39;s account-less nature, at most spying on what other RSS feeds your subscribers are looking at. Nevertheless it was a fun little finding, thanks for reading.</p>","headings":[{"level":1,"text":"XSS in yarr","id":"xss-in-yarr"}]}}