diff --git a/src/jquery.contextMenu.js b/src/jquery.contextMenu.js
index 03d0b7b0..f5e428bd 100644
--- a/src/jquery.contextMenu.js
+++ b/src/jquery.contextMenu.js
@@ -1772,7 +1772,7 @@
if (!opt.$node) {
opt.$menu.css('display', 'none').addClass('context-menu-root');
}
- opt.$menu.appendTo(opt.appendTo || document.body);
+ opt.$menu.appendTo(resolveSelector(opt.appendTo || document.body));
},
resize: function ($menu, nested) {
var domMenu;
@@ -2265,6 +2265,23 @@
(selector.nodeType === 1 || (typeof selector.jquery !== 'undefined' && typeof selector.length === 'number'));
}
+ // resolve a caller-supplied "selector-ish" option (`context`, `appendTo`,
+ // the element passed to `fromMenu()`, ...) to a jQuery object without ever
+ // letting a string be evaluated as HTML. `$(string)` builds a detached DOM
+ // fragment whenever the string looks like markup instead of running it as a
+ // CSS selector, which turns something like `` into
+ // executing code. `.find()` only ever accepts a selector, so strings are
+ // routed through it. Elements, jQuery objects, `document` and everything
+ // else are handed to `$()` unchanged.
+ // See https://github.com/swisnl/jQuery-contextMenu/issues/731
+ function resolveSelector(target) {
+ if (typeof target === 'string') {
+ return $(document).find(target);
+ }
+
+ return $(target);
+ }
+
// remove every `elementSelectors` entry (and its bound handler) registered
// under the given namespace. Used to tear down direct element/jQuery-object
// bindings from any destroy code path, regardless of whether the menu was
@@ -2364,7 +2381,7 @@
o.context = document;
} else {
// you never know what they throw at you...
- $context = $(o.context).first();
+ $context = resolveSelector(o.context).first();
o.context = $context.get(0);
_hasContext = !$(o.context).is(document);
}
@@ -2883,7 +2900,7 @@
// convert html5 menu
$.contextMenu.fromMenu = function (element) {
- var $this = $(element),
+ var $this = resolveSelector(element),
items = {};
menuChildren(items, $this.children());
diff --git a/test/unit/issue-731-selector-html-injection.test.js b/test/unit/issue-731-selector-html-injection.test.js
new file mode 100644
index 00000000..d95bc527
--- /dev/null
+++ b/test/unit/issue-731-selector-html-injection.test.js
@@ -0,0 +1,251 @@
+// Regression tests for https://github.com/swisnl/jQuery-contextMenu/issues/731
+//
+// jQuery evaluates a string that looks like markup as HTML to build instead of
+// running it as a CSS selector, so any caller-supplied string that is meant to
+// be a selector must never reach `$(...)` directly.
+//
+// The payload below is an
with an invalid data URI, so the browser fires
+// its `error` handler without needing a network round trip. The handler sets a
+// flag, which is what the assertions check. Feeding the payload to a hardened
+// call site is expected to either do nothing or make jQuery throw its usual
+// "unrecognized expression" selector error - both are fine, as long as no
+// element is built and no script runs.
+var XSS_PAYLOAD = '
';
+
+function xssRan() {
+ return window.__contextMenuXss731 === true;
+}
+
+function imageCount() {
+ return document.querySelectorAll('img').length;
+}
+
+function assertNotEvaluatedAsHtml(assert, imagesBefore, fn) {
+ var done = assert.async();
+
+ try {
+ fn();
+ } catch (e) {
+ assert.ok(
+ /unrecognized expression|Syntax error/i.test(e.message || ''),
+ 'the payload was rejected as an invalid selector, not built as HTML'
+ );
+ }
+
+ assert.equal(imageCount(), imagesBefore, 'no
was added to the document');
+
+ setTimeout(function() {
+ assert.notOk(xssRan(), 'the onerror payload never ran');
+ done();
+ }, 250);
+}
+
+QUnit.module('issue 731 - selector strings are never parsed as HTML', {
+ beforeEach: function() {
+ window.__contextMenuXss731 = false;
+ this.imagesBefore = imageCount();
+ },
+ afterEach: function() {
+ $.contextMenu('destroy');
+ try {
+ delete window.__contextMenuXss731;
+ } catch (e) {
+ window.__contextMenuXss731 = false;
+ }
+ $('img[data-xss-731]').remove();
+ var $fixture = $('#qunit-fixture');
+ if ($fixture.length) {
+ $fixture.html('');
+ }
+ }
+});
+
+QUnit.test('$.contextMenu("destroy", {context: html}) does not evaluate the string as HTML', function(assert) {
+ assertNotEvaluatedAsHtml(assert, this.imagesBefore, function() {
+ $.contextMenu('destroy', {context: XSS_PAYLOAD});
+ });
+});
+
+QUnit.test('$.contextMenu("update", {context: html}) does not evaluate the string as HTML', function(assert) {
+ assertNotEvaluatedAsHtml(assert, this.imagesBefore, function() {
+ $.contextMenu('update', {context: XSS_PAYLOAD});
+ });
+});
+
+QUnit.test('$.contextMenu("create", {context: html}) does not evaluate the string as HTML', function(assert) {
+ assertNotEvaluatedAsHtml(assert, this.imagesBefore, function() {
+ $.contextMenu({
+ selector: '.issue-731-trigger',
+ context: XSS_PAYLOAD,
+ items: {copy: {name: 'Copy'}}
+ });
+ });
+});
+
+QUnit.test('appendTo does not evaluate the string as HTML', function(assert) {
+ assertNotEvaluatedAsHtml(assert, this.imagesBefore, function() {
+ $.contextMenu({
+ selector: '.issue-731-trigger',
+ appendTo: XSS_PAYLOAD,
+ items: {copy: {name: 'Copy'}}
+ });
+ });
+});
+
+QUnit.test('$.contextMenu.fromMenu does not evaluate the string as HTML', function(assert) {
+ assertNotEvaluatedAsHtml(assert, this.imagesBefore, function() {
+ $.contextMenu.fromMenu(XSS_PAYLOAD);
+ });
+});
+
+QUnit.module('issue 731 - supported selector inputs keep working', {
+ afterEach: function() {
+ $.contextMenu('destroy');
+ var $fixture = $('#qunit-fixture');
+ if ($fixture.length) {
+ $fixture.html('');
+ }
+ $('#issue-731-container').remove();
+ }
+});
+
+// Build a container with one trigger inside it and an identical trigger
+// outside it. A `context` that was resolved as a selector scopes the
+// registration to the container, so only the inner trigger opens a menu. A
+// `context` that got parsed as HTML instead resolves to a detached node, and
+// then neither trigger would.
+function setupScopedFixture() {
+ var $fixture = $('#qunit-fixture');
+ var fixture = {
+ $container: $('