VYPR
Medium severity6.3OSV Advisory· Published Jan 9, 2025· Updated Apr 15, 2026

CVE-2023-23913

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.

PackageAffected versionsPatched versions
actionviewRubyGems
>= 5.1.0, < 6.1.7.36.1.7.3
actionviewRubyGems
>= 7.0.0, < 7.0.4.37.0.4.3

Affected products

9

Patches

2
5037a13614d7

Ignore certain data-* attributes in rails-ujs when element is contenteditable

https://github.com/rails/railsZack DeveauJan 16, 2023via ghsa
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)
    +})
    +
     })()
    
73009ea59a81

Ignore certain data-* attributes in rails-ujs when element is contenteditable

https://github.com/rails/railsZack DeveauJan 16, 2023via ghsa
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

News mentions

0

No linked articles in our index yet.