search module renders untrusted result fields unescaped (stored/reflected XSS in default templates)

Author: router0mailCreated Sep 18, 2026Updated Sep 18, 2026

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:

  1. A working escaper exists and is dead. templates.escape (line 1348) correctly maps & < > " ' \`` to entities. Inside the category` template it's even pulled into a local (line 1394):
javascript
category: function(response, fields) {
  var
    html   = '',
    escape = $.fn.search.settings.templates.escape   // loaded, then never used
  ;
  ...

escape(...) is never invoked anywhere in the file.

  1. Fields are concatenated raw. standard template (line 1455):
javascript
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.

  1. 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.standard and templates.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