CVE-2023-23913
Description
There is a potential DOM based cross-site scripting issue in rails-ujs which leverages the Clipboard API to target HTML elements that are assigned the contenteditable attribute. This has the potential to occur when pasting malicious HTML content from the clipboard that includes a data-method, data-remote or data-disable-with attribute.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
DOM-based XSS in rails-ujs allows arbitrary JavaScript execution when pasting malicious HTML into contenteditable elements.
Vulnerability
Overview
CVE-2023-23913 is a DOM-based cross-site scripting (XSS) vulnerability in rails-ujs, the unobtrusive JavaScript adapter for Ruby on Rails. The issue arises because rails-ujs leverages the Clipboard API to handle paste events on HTML elements that have the contenteditable attribute. When a user pastes malicious HTML content from the clipboard that includes data-method, data-remote, or data-disable-with attributes, the library processes these attributes without proper sanitization, leading to arbitrary JavaScript execution in the context of the origin [1][2].
Exploitation
To exploit this vulnerability, an attacker must craft a malicious HTML payload containing one of the aforementioned data attributes and convince a victim to paste it into a contenteditable element on a page that uses rails-ujs. The attack requires no authentication beyond the victim's session, and the victim must be using a browser that supports the Clipboard API. The vulnerability affects Rails versions 5.1.0 and later, with fixed versions being 6.1.7.3 and 7.0.4.3 [2].
Impact
Successful exploitation allows the attacker to execute arbitrary JavaScript in the victim's browser, potentially leading to session hijacking, data theft, or other malicious actions within the context of the vulnerable application. The CVSS v3 base score is 6.3 (Medium), reflecting the need for user interaction and the specific conditions required for exploitation [1].
Mitigation
The Rails team has released patches for the 6.1 and 7.0 series, and users are strongly advised to upgrade to the fixed versions. As a workaround, administrators can remove the contenteditable attribute from elements that interact with rails-ujs, though this may affect functionality. Users of unsupported versions (prior to 5.1.0) are not affected, but those on older supported branches should apply the provided patches [2].
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
actionviewRubyGems | >= 5.1.0, < 6.1.7.3 | 6.1.7.3 |
actionviewRubyGems | >= 7.0.0, < 7.0.4.3 | 7.0.4.3 |
Affected products
9- ghsa-coords8 versionspkg:gem/actionviewpkg:rpm/opensuse/rubygem-actionview-5_1&distro=openSUSE%20Leap%2015.4pkg:rpm/opensuse/rubygem-actionview-5_1&distro=openSUSE%20Leap%2015.5pkg:rpm/suse/rubygem-actionview-5_1&distro=SUSE%20Linux%20Enterprise%20High%20Availability%20Extension%2015%20SP1pkg:rpm/suse/rubygem-actionview-5_1&distro=SUSE%20Linux%20Enterprise%20High%20Availability%20Extension%2015%20SP2pkg:rpm/suse/rubygem-actionview-5_1&distro=SUSE%20Linux%20Enterprise%20High%20Availability%20Extension%2015%20SP3pkg:rpm/suse/rubygem-actionview-5_1&distro=SUSE%20Linux%20Enterprise%20High%20Availability%20Extension%2015%20SP4pkg:rpm/suse/rubygem-actionview-5_1&distro=SUSE%20Linux%20Enterprise%20High%20Availability%20Extension%2015%20SP5
>= 5.1.0, < 6.1.7.3+ 7 more
- (no CPE)range: >= 5.1.0, < 6.1.7.3
- (no CPE)range: < 5.1.4-150000.3.9.1
- (no CPE)range: < 5.1.4-150000.3.9.1
- (no CPE)range: < 5.1.4-150000.3.9.1
- (no CPE)range: < 5.1.4-150000.3.9.1
- (no CPE)range: < 5.1.4-150000.3.9.1
- (no CPE)range: < 5.1.4-150000.3.9.1
- (no CPE)range: < 5.1.4-150000.3.9.1
Patches
25037a13614d7Ignore certain data-* attributes in rails-ujs when element is contenteditable
7 files changed · +91 −2
actionview/app/assets/javascripts/rails-ujs/features/disable.coffee+8 −1 modified@@ -1,6 +1,6 @@ #= require_tree ../utils -{ matches, getData, setData, stopEverything, formElements } = Rails +{ matches, getData, setData, stopEverything, formElements, isContentEditable } = Rails Rails.handleDisabledElement = (e) -> element = this @@ -14,6 +14,9 @@ Rails.enableElement = (e) -> else element = e + if isContentEditable(element) + return + if matches(element, Rails.linkDisableSelector) enableLinkElement(element) else if matches(element, Rails.buttonDisableSelector) or matches(element, Rails.formEnableSelector) @@ -24,6 +27,10 @@ Rails.enableElement = (e) -> # Unified function to disable an element (link, button and form) Rails.disableElement = (e) -> element = if e instanceof Event then e.target else e + + if isContentEditable(element) + return + if matches(element, Rails.linkDisableSelector) disableLinkElement(element) else if matches(element, Rails.buttonDisableSelector) or matches(element, Rails.formDisableSelector)
actionview/app/assets/javascripts/rails-ujs/features/method.coffee+4 −0 modified@@ -1,6 +1,7 @@ #= require_tree ../utils { stopEverything } = Rails +{ isContentEditable } = Rails # Handles "data-method" on links such as: # <a href="/users/5" data-method="delete" rel="nofollow" data-confirm="Are you sure?">Delete</a> @@ -9,6 +10,9 @@ Rails.handleMethod = (e) -> method = link.getAttribute('data-method') return unless method + if isContentEditable(this) + return + href = Rails.href(link) csrfToken = Rails.csrfToken() csrfParam = Rails.csrfParam()
actionview/app/assets/javascripts/rails-ujs/features/remote.coffee+6 −1 modified@@ -4,7 +4,8 @@ matches, getData, setData fire, stopEverything ajax, isCrossDomain - serializeElement + serializeElement, + isContentEditable } = Rails # Checks "data-remote" if true to handle the request through a XHR request. @@ -21,6 +22,10 @@ Rails.handleRemote = (e) -> fire(element, 'ajax:stopped') return false + if isContentEditable(element) + fire(element, 'ajax:stopped') + return false + withCredentials = element.getAttribute('data-with-credentials') dataType = element.getAttribute('data-type') or 'script'
actionview/app/assets/javascripts/rails-ujs/utils/dom.coffee+12 −0 modified@@ -29,6 +29,18 @@ Rails.setData = (element, key, value) -> element[expando] ?= {} element[expando][key] = value +Rails.isContentEditable = (element) -> + isEditable = false + loop + if(element.isContentEditable) + isEditable = true + break + + element = element.parentElement + break unless(element) + + return isEditable + # a wrapper for document.querySelectorAll # returns an Array Rails.$ = (selector) ->
actionview/test/ujs/public/test/data-disable-with.js+22 −0 modified@@ -37,6 +37,10 @@ module('data-disable-with', { 'data-url': '/echo', 'data-disable-with': 'clicking...' })) + + $('#qunit-fixture').append($('<div />', { + id: 'edit-div', 'contenteditable': 'true' + })) }, teardown: function() { $(document).unbind('iframe:loaded') @@ -432,3 +436,21 @@ asyncTest('button[data-remote][data-disable-with] re-enables when `ajax:error` e start() }, 30) }) + +asyncTest('form button with "data-disable-with" attribute and contenteditable is not modified', 6, function() { + var form = $('form[data-remote]'), button = $('<button data-disable-with="submitting ..." name="submit2">Submit</button>') + + var contenteditable_div = $('#qunit-fixture').find('div') + form.append(button) + contenteditable_div.append(form) + + App.checkEnabledState(button, 'Submit') + + setTimeout(function() { + App.checkEnabledState(button, 'Submit') + start() + }, 13) + form.triggerNative('submit') + + App.checkEnabledState(button, 'Submit') +})
actionview/test/ujs/public/test/data-method.js+19 −0 modified@@ -5,6 +5,10 @@ module('data-method', { $('#qunit-fixture').append($('<a />', { href: '/echo', 'data-method': 'delete', text: 'destroy!' })) + + $('#qunit-fixture').append($('<div />', { + id: 'edit-div', 'contenteditable': 'true' + })) }, teardown: function() { $(document).unbind('iframe:loaded') @@ -82,4 +86,19 @@ asyncTest('link with "data-method" and cross origin', 1, function() { notEqual(data.authenticity_token, 'cf50faa3fe97702ca1ae') }) +asyncTest('do not interact with contenteditable elements', 6, function() { + var contenteditable_div = $('#qunit-fixture').find('div') + contenteditable_div.append('<a href="http://www.shouldnevershowindocument.com" data-method="delete">') + + var link = $('#edit-div').find('a') + link.triggerNative('click') + + start() + + collection = document.getElementsByTagName('form') + for (const item of collection) { + notEqual(item.action, "http://www.shouldnevershowindocument.com/") + } +}) + })()
actionview/test/ujs/public/test/data-remote.js+20 −0 modified@@ -41,6 +41,9 @@ module('data-remote', { })) .find('form').append($('<input type="text" name="user_name" value="john">')) + $('#qunit-fixture').append($('<div />', { + id: 'edit-div', 'contenteditable': 'true' + })) } }) @@ -508,4 +511,21 @@ asyncTest('inputs inside disabled fieldset are not submitted on remote forms', 3 .triggerNative('submit') }) +asyncTest('clicking on a link with contenteditable attribute does not fire ajaxyness', 0, function() { + var contenteditable_div = $('#qunit-fixture').find('div') + var link = $('a[data-remote]') + contenteditable_div.append(link) + + link + .bindNative('ajax:beforeSend', function() { + ok(false, 'ajax should not be triggered') + }) + .bindNative('click', function(e) { + e.preventDefault() + }) + .triggerNative('click') + + setTimeout(function() { start() }, 20) +}) + })()
73009ea59a81Ignore certain data-* attributes in rails-ujs when element is contenteditable
7 files changed · +91 −2
actionview/app/assets/javascripts/rails-ujs/features/disable.coffee+8 −1 modified@@ -1,6 +1,6 @@ #= require_tree ../utils -{ matches, getData, setData, stopEverything, formElements } = Rails +{ matches, getData, setData, stopEverything, formElements, isContentEditable } = Rails Rails.handleDisabledElement = (e) -> element = this @@ -14,6 +14,9 @@ Rails.enableElement = (e) -> else element = e + if isContentEditable(element) + return + if matches(element, Rails.linkDisableSelector) enableLinkElement(element) else if matches(element, Rails.buttonDisableSelector) or matches(element, Rails.formEnableSelector) @@ -24,6 +27,10 @@ Rails.enableElement = (e) -> # Unified function to disable an element (link, button and form) Rails.disableElement = (e) -> element = if e instanceof Event then e.target else e + + if isContentEditable(element) + return + if matches(element, Rails.linkDisableSelector) disableLinkElement(element) else if matches(element, Rails.buttonDisableSelector) or matches(element, Rails.formDisableSelector)
actionview/app/assets/javascripts/rails-ujs/features/method.coffee+4 −0 modified@@ -1,6 +1,7 @@ #= require_tree ../utils { stopEverything } = Rails +{ isContentEditable } = Rails # Handles "data-method" on links such as: # <a href="/users/5" data-method="delete" rel="nofollow" data-confirm="Are you sure?">Delete</a> @@ -9,6 +10,9 @@ Rails.handleMethod = (e) -> method = link.getAttribute('data-method') return unless method + if isContentEditable(this) + return + href = Rails.href(link) csrfToken = Rails.csrfToken() csrfParam = Rails.csrfParam()
actionview/app/assets/javascripts/rails-ujs/features/remote.coffee+6 −1 modified@@ -4,7 +4,8 @@ matches, getData, setData fire, stopEverything ajax, isCrossDomain - serializeElement + serializeElement, + isContentEditable } = Rails # Checks "data-remote" if true to handle the request through a XHR request. @@ -21,6 +22,10 @@ Rails.handleRemote = (e) -> fire(element, 'ajax:stopped') return false + if isContentEditable(element) + fire(element, 'ajax:stopped') + return false + withCredentials = element.getAttribute('data-with-credentials') dataType = element.getAttribute('data-type') or 'script'
actionview/app/assets/javascripts/rails-ujs/utils/dom.coffee+12 −0 modified@@ -29,6 +29,18 @@ Rails.setData = (element, key, value) -> element[expando] ?= {} element[expando][key] = value +Rails.isContentEditable = (element) -> + isEditable = false + loop + if(element.isContentEditable) + isEditable = true + break + + element = element.parentElement + break unless(element) + + return isEditable + # a wrapper for document.querySelectorAll # returns an Array Rails.$ = (selector) ->
actionview/test/ujs/public/test/data-disable-with.js+22 −0 modified@@ -37,6 +37,10 @@ module('data-disable-with', { 'data-url': '/echo', 'data-disable-with': 'clicking...' })) + + $('#qunit-fixture').append($('<div />', { + id: 'edit-div', 'contenteditable': 'true' + })) }, teardown: function() { $(document).unbind('iframe:loaded') @@ -432,3 +436,21 @@ asyncTest('button[data-remote][data-disable-with] re-enables when `ajax:error` e start() }, 30) }) + +asyncTest('form button with "data-disable-with" attribute and contenteditable is not modified', 6, function() { + var form = $('form[data-remote]'), button = $('<button data-disable-with="submitting ..." name="submit2">Submit</button>') + + var contenteditable_div = $('#qunit-fixture').find('div') + form.append(button) + contenteditable_div.append(form) + + App.checkEnabledState(button, 'Submit') + + setTimeout(function() { + App.checkEnabledState(button, 'Submit') + start() + }, 13) + form.triggerNative('submit') + + App.checkEnabledState(button, 'Submit') +})
actionview/test/ujs/public/test/data-method.js+19 −0 modified@@ -5,6 +5,10 @@ module('data-method', { $('#qunit-fixture').append($('<a />', { href: '/echo', 'data-method': 'delete', text: 'destroy!' })) + + $('#qunit-fixture').append($('<div />', { + id: 'edit-div', 'contenteditable': 'true' + })) }, teardown: function() { $(document).unbind('iframe:loaded') @@ -82,4 +86,19 @@ asyncTest('link with "data-method" and cross origin', 1, function() { notEqual(data.authenticity_token, 'cf50faa3fe97702ca1ae') }) +asyncTest('do not interact with contenteditable elements', 6, function() { + var contenteditable_div = $('#qunit-fixture').find('div') + contenteditable_div.append('<a href="http://www.shouldnevershowindocument.com" data-method="delete">') + + var link = $('#edit-div').find('a') + link.triggerNative('click') + + start() + + collection = document.getElementsByTagName('form') + for (const item of collection) { + notEqual(item.action, "http://www.shouldnevershowindocument.com/") + } +}) + })()
actionview/test/ujs/public/test/data-remote.js+20 −0 modified@@ -41,6 +41,9 @@ module('data-remote', { })) .find('form').append($('<input type="text" name="user_name" value="john">')) + $('#qunit-fixture').append($('<div />', { + id: 'edit-div', 'contenteditable': 'true' + })) } }) @@ -508,4 +511,21 @@ asyncTest('inputs inside disabled fieldset are not submitted on remote forms', 3 .triggerNative('submit') }) +asyncTest('clicking on a link with contenteditable attribute does not fire ajaxyness', 0, function() { + var contenteditable_div = $('#qunit-fixture').find('div') + var link = $('a[data-remote]') + contenteditable_div.append(link) + + link + .bindNative('ajax:beforeSend', function() { + ok(false, 'ajax should not be triggered') + }) + .bindNative('click', function(e) { + e.preventDefault() + }) + .triggerNative('click') + + setTimeout(function() { start() }, 20) +}) + })()
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
10- github.com/advisories/GHSA-xp5h-f8jf-rc8qghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-23913ghsaADVISORY
- bugs.debian.org/cgi-bin/bugreport.cginvdWEB
- discuss.rubyonrails.org/t/cve-2023-23913-dom-based-cross-site-scripting-in-rails-ujs-for-contenteditable-html-elements/82468nvdWEB
- github.com/rails/rails/commit/5037a13614d71727af8a175063bcf6ba1a74bdbdnvdWEB
- github.com/rails/rails/commit/73009ea59a811b28e8ec2a9c9bc24635aa891214ghsaWEB
- github.com/rubysec/ruby-advisory-db/blob/master/gems/actionview/CVE-2023-23913.ymlghsaWEB
- security.netapp.com/advisory/ntap-20240605-0007ghsaWEB
- www.debian.org/security/2023/dsa-5389nvdWEB
- security.netapp.com/advisory/ntap-20240605-0007/nvd
News mentions
0No linked articles in our index yet.