Skip to content

Commit 63ff096

Browse files
tkirda-bisonjunowildernessclaude
committed
fix(security): escape category and value in default formatters (GHSA-hvqh-jw65-wcpq)
`formatGroup` interpolated `category` into HTML un-escaped, and the early-return branch of `formatResult` (reached with `minChars: 0` and an empty current query) returned `suggestion.value` un-escaped. Both results are concatenated into the suggestions container's innerHTML, yielding XSS when the data source is attacker-controllable. Both formatters now build the HTML via `document.createElement` + `textContent` so the browser handles entity escaping for any tainted input. Adds unit tests on both exported formatters plus an end-to-end test that asserts a poisoned `category` produces zero DOM nodes in the rendered container. Refs GHSA-hvqh-jw65-wcpq. Co-Authored-By: junowilderness <1977311+junowilderness@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 02f58a1 commit 63ff096

5 files changed

Lines changed: 71 additions & 7 deletions

File tree

dist/jquery.autocomplete.esm.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,18 @@ function transformResult(response) {
4747
}
4848
function formatResult(suggestion, currentValue) {
4949
if (!currentValue) {
50-
return suggestion.value;
50+
const span = document.createElement("span");
51+
span.textContent = suggestion.value;
52+
return span.innerHTML;
5153
}
5254
const pattern = "(" + utils.escapeRegExChars(currentValue) + ")";
5355
return suggestion.value.replace(new RegExp(pattern, "gi"), "<strong>$1</strong>").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/&lt;(\/?strong)&gt;/g, "<$1>");
5456
}
5557
function formatGroup(_suggestion, category) {
56-
return '<div class="autocomplete-group">' + category + "</div>";
58+
const div = document.createElement("div");
59+
div.className = "autocomplete-group";
60+
div.textContent = category;
61+
return div.outerHTML;
5762
}
5863

5964
// src/defaults.ts

dist/jquery.autocomplete.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,18 @@
5555
}
5656
function formatResult(suggestion, currentValue) {
5757
if (!currentValue) {
58-
return suggestion.value;
58+
const span = document.createElement("span");
59+
span.textContent = suggestion.value;
60+
return span.innerHTML;
5961
}
6062
const pattern = "(" + utils.escapeRegExChars(currentValue) + ")";
6163
return suggestion.value.replace(new RegExp(pattern, "gi"), "<strong>$1</strong>").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/&lt;(\/?strong)&gt;/g, "<$1>");
6264
}
6365
function formatGroup(_suggestion, category) {
64-
return '<div class="autocomplete-group">' + category + "</div>";
66+
const div = document.createElement("div");
67+
div.className = "autocomplete-group";
68+
div.textContent = category;
69+
return div.outerHTML;
6570
}
6671

6772
// src/defaults.ts

dist/jquery.autocomplete.min.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/format.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ export function transformResult(response: string | AutocompleteResponse): Autoco
1515

1616
export function formatResult(suggestion: Suggestion, currentValue: string): string {
1717
if (!currentValue) {
18-
return suggestion.value;
18+
// Same escaping channel as formatGroup — let the browser handle entities
19+
// so an HTML-bearing suggestion.value can't break out of the text node.
20+
const span = document.createElement("span");
21+
span.textContent = suggestion.value;
22+
return span.innerHTML;
1923
}
2024

2125
const pattern = "(" + utils.escapeRegExChars(currentValue) + ")";
@@ -30,5 +34,8 @@ export function formatResult(suggestion: Suggestion, currentValue: string): stri
3034
}
3135

3236
export function formatGroup(_suggestion: Suggestion, category: string): string {
33-
return '<div class="autocomplete-group">' + category + "</div>";
37+
const div = document.createElement("div");
38+
div.className = "autocomplete-group";
39+
div.textContent = category;
40+
return div.outerHTML;
3441
}

test/autocomplete.test.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
22

3+
import { formatGroup, formatResult } from "../src/format.ts";
4+
35
const $ = globalThis.jQuery;
46

57
// Clear mockjax handlers and any leftover suggestion containers between tests
@@ -837,6 +839,51 @@ describe("Autocomplete groupBy", () => {
837839
});
838840
});
839841

842+
describe("XSS in default formatters (GHSA-hvqh-jw65-wcpq)", () => {
843+
const PAYLOAD = "<img src=x onerror=\"alert('XSS')\">";
844+
845+
afterEach(() => {
846+
$(".autocomplete-suggestions").remove();
847+
});
848+
849+
it("formatGroup escapes the category", () => {
850+
const html = formatGroup({ value: "x", data: null }, PAYLOAD);
851+
expect(html).not.toContain("<img");
852+
expect(html).toContain("&lt;img");
853+
});
854+
855+
it("formatResult escapes the value when currentValue is empty", () => {
856+
const html = formatResult({ value: PAYLOAD, data: null }, "");
857+
expect(html).not.toContain("<img");
858+
expect(html).toContain("&lt;img");
859+
});
860+
861+
it("does not inject DOM nodes when groupBy category is poisoned", () => {
862+
const input = document.createElement("input");
863+
document.body.appendChild(input);
864+
const ac = new $.Autocomplete(input, {
865+
lookup: [
866+
{ value: "Apple", data: { category: PAYLOAD } },
867+
{ value: "Avocado", data: { category: "Safe" } },
868+
],
869+
groupBy: "category",
870+
minChars: 1,
871+
triggerSelectOnValidInput: false,
872+
});
873+
874+
input.value = "A";
875+
ac.onValueChange();
876+
877+
// Poisoned category renders as text inside the group header — no img
878+
// element is created.
879+
const container = ac.suggestionsContainer;
880+
expect(container.querySelectorAll("img").length).toBe(0);
881+
expect(container.querySelector(".autocomplete-group").textContent).toBe(PAYLOAD);
882+
883+
document.body.removeChild(input);
884+
});
885+
});
886+
840887
describe("When options.preserveInput is true", () => {
841888
const input = $("<input />");
842889
let instance;

0 commit comments

Comments
 (0)