search module renders untrusted result fields unescaped (stored/reflected XSS in default templates)
Summary
Semantic UI's search module ($('.ui.search').search({...})) builds its result list by string-concatenating untrusted result fields into an HTML fragment, then injects it with $results.html(html). A working HTML escaper exists in the code (and is even loaded into a local variable inside the category template) but is never called. Every app using the default standard or category template renders attacker-controlled title/description/price/url/image/action.* (and the category name) as raw HTML; the URL/image fields additionally break out of their quoted attributes.
If the search source is a remote API or reflects user input this is reflected XSS; if it serves stored/user-generated data, stored XSS. The library hands the sink to every consumer by default.
Root cause
src/definitions/modules/search.js:
- A working escaper exists and is dead.
templates.escape(line 1348) correctly maps& < > " ' \`` to entities. Inside thecategory` template it's even pulled into a local (line 1394):
category: function(response, fields) {
var
html = '',
escape = $.fn.search.settings.templates.escape // loaded, then never used
;
...escape(...) is never invoked anywhere in the file.
- Fields are concatenated raw.
standardtemplate (line 1455):
if(result[fields.url]) {
html += '<a class="result" href="' + result[fields.url] + '">'; // 1463 attr breakout
}
...
html += ' <img src="' + result[fields.image] + '">' // 1472 attr breakout
html += '<div class="price">' + result[fields.price] + '</div>'; // 1478 raw markup
html += '<div class="title">' + result[fields.title] + '</div>'; // 1481 raw markup
html += '<div class="description">' + result[fields.description] + '</div>'; // 1484 raw markup
...
if(response[fields.action]) {
html += '<a href="' + response[fields.action][fields.actionURL] + '" class="action">' // 1494 attr breakout
+ response[fields.action][fields.actionText] // 1495 raw markup
+ '</a>';
}category template (line 1391) repeats the same for category[fields.categoryName] and each item's url/image/price/title/description.
- The sink is an unescaped
.html().addResults(html)->$results.html(html)(line 913). jQuery's.html()parses the string as markup -- it does not escape.
Data flow: untrusted result field -> raw string concat -> .html() -> live DOM, with the available escaper bypassed.
Proof of concept (reproduced live)
PoC loads the shipped dist/semantic.js (v2.5.0, tag 597843a) into jsdom with jQuery, calls the default template functions directly, asserts the output is unescaped:
=== standard template output ===
<a class="result" href="http://a/" onmouseover="window.__xss_url=1"><div class="content"><div class="title"><img src=x onerror=window.__xss_title=1></div><div class="description">d</div></div></a>
=== category template output ===
<div class="category"><div class="name">Cat</div><div class="results"><a class="result" href="http://a/" onmouseover="window.__xss_url=1"><div class="image"> <img src="http://i/" onload="window.__xss_img=1"></div>...<div class="title"><img src=x onerror=window.__xss_title=1></div>...</a></div></div><a href="http://a/" onclick="window.__xss_act=1" class="action"><b>act</b></a>
PASS standard: raw <img> from title kept unescaped
PASS standard: url breaks out of href (onmouseover)
PASS category: raw <img> from title kept unescaped
PASS category: image attr breakout (onload)
PASS category: action url breakout (onclick)
PASS category: action text raw (<b>)
PASS .html() sink: <img> becomes a real DOM element
XSS CONFIRMED: untrusted search-result fields reach the DOM unescaped (raw markup + attribute breakout).The last check proves the .html() sink: the raw <img src=x onerror=...> string becomes a real <img> DOM element (the onerror handler is live, not text).
Impact
Any consumer of $('.ui.search').search({...}) with a non-trivial source (remote search/API, or a database of user-generated entries) can be XSS'd through result data -- no app code required to introduce it, it's the library default. Attribute breakout means a single field suffices even when "markup" fields look inert; title/description/action.text give full markup injection.
Affected / not affected
- Affected: default
templates.standardandtemplates.category(both shipped defaults), v2.5.0 and earlier. - Not affected (consumer side): apps that supply a fully custom template that escapes fields themselves, or set
template: false.
Suggested fix
Wrap every interpolated field in the already-present escaper in both default templates: escape(result[fields.title]), escape(category[fields.categoryName]), etc. For URL/image attributes, escape or validate a safe scheme (raw href="..." is also a javascript:/attribute-breakout vector).
Disclosure note
No SECURITY.md / private vulnerability reporting found on this repo, so filing as a public issue.
This report was produced with AI assistance (Claude, Anthropic); the PoC (jsdom + jQuery) was run live against the shipped dist/semantic.js from a real clone of the repo at tag 2.5.0, output captured verbatim above.
Source: Semantic-Org/Semantic-UI