From cc20e0a6c9097f999060f78d00b74fd1f472f53c Mon Sep 17 00:00:00 2001 From: Adam Heath Date: Fri, 27 Mar 2026 14:19:58 +0800 Subject: [PATCH] Make allowed HTML attributes configurable Add an `additionalAllowedAttributes` option that lets apps specify extra HTML attributes to preserve through both DOMPurify (client) and Action Text sanitization (server). Rails apps configure this in one place via `config.lexxy.additional_allowed_attributes`. The gem emits the setting onto each rendered `` element so a separate JS `Lexxy.configure` call is not needed. Non-Rails apps can use the editor preset or element attribute directly. This is a follow-on to https://github.com/basecamp/lexxy/pull/713 making the solution generic rather than specific to list 'start' attributes. --- docs/configuration.md | 12 ++++++++++ lib/lexxy.rb | 17 +++++++++++++ lib/lexxy/engine.rb | 9 ++----- lib/lexxy/rich_text_area_tag.rb | 2 ++ src/config/dom_purify.js | 9 ++++--- src/config/lexxy.js | 1 + src/elements/editor.js | 4 +++- src/helpers/sanitization_helper.js | 4 ++-- .../fixtures/allowed-html-attributes.html | 24 +++++++++++++++++++ test/browser/tests/editor/load_html.test.js | 23 ++++++++++++++++++ test/system/action_text_load_test.rb | 22 +++++++++++++++++ test/test_helper.rb | 2 +- .../lexxy_configuration_helper.rb | 22 +++++++++++++++++ 13 files changed, 137 insertions(+), 14 deletions(-) create mode 100644 test/browser/fixtures/allowed-html-attributes.html create mode 100644 test/test_helpers/lexxy_configuration_helper.rb diff --git a/docs/configuration.md b/docs/configuration.md index b6d510c78..5a75a925d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -46,6 +46,7 @@ Editors support the following options, configurable using presets and element at - `toolbar`: Pass `false` to disable the toolbar entirely, pass the ID of a `` element to use as an external toolbar, or pass an object to configure individual toolbar buttons. By default, the toolbar is bootstrapped and displayed above the editor. - `toolbar.upload`: Control which upload button(s) appear in the toolbar. Accepts `"file"`, `"image"`, or `"both"` (default). The image button restricts the file picker to images and videos (`accept="image/*,video/*"`), which triggers the native photo/video picker on iOS and Android. The file button opens an unrestricted file picker. - `attachments`: Pass `false` to disable attachments completely. By default, attachments are supported, including paste and drag & drop support. +- `additionalAllowedAttributes`: Pass an array of extra HTML attribute names to preserve when Lexxy serializes HTML. This is additive: the built-in safe defaults still apply. Rails apps should use `config.lexxy.additional_allowed_attributes` instead (see [Rails Sanitization](#rails-sanitization) below). - `markdown`: Pass `false` to disable Markdown support. - `multiLine`: Pass `false` to force single line editing. - `richText`: Pass `false` to disable rich text editing. @@ -66,3 +67,14 @@ Global options apply to all editors in your app and are configured using `Lexxy. {: .important } When overriding configuration, call `Lexxy.configure` immediately after your import statement. Editor elements are registered after the import's call stack completes, so configuration must happen synchronously to take effect. + +## Rails Sanitization + +Rails apps should configure extra allowed HTML attributes in one place using the gem config: + +```ruby +# config/application.rb +config.lexxy.additional_allowed_attributes = %w[start] +``` + +Lexxy uses that setting for both Action Text sanitization on the server and the rendered editor's HTML serialization in the browser, so Rails apps do not need a separate `Lexxy.configure(...)` call for the same attributes. diff --git a/lib/lexxy.rb b/lib/lexxy.rb index 29b8c3de2..e4c4336f7 100644 --- a/lib/lexxy.rb +++ b/lib/lexxy.rb @@ -2,6 +2,9 @@ require "lexxy/engine" module Lexxy + ACTION_TEXT_ALLOWED_TAGS = %w[ video audio source embed table tbody tr th td ].freeze + ACTION_TEXT_ALLOWED_ATTRIBUTES = %w[ controls poster data-language style ].freeze + def self.override_action_text_defaults ActionText::TagHelper.module_eval do alias_method :rich_textarea_tag, :lexxy_rich_textarea_tag @@ -22,4 +25,18 @@ def self.override_action_text_defaults alias_method :render, :lexxy_render end end + + def self.additional_allowed_attributes(config = Rails.application.config.lexxy) + Array(config.additional_allowed_attributes).map(&:to_s) + end + + def self.configure_action_text_sanitizer!(config = Rails.application.config.lexxy) + action_text_content_helper = Class.new.include(ActionText::ContentHelper).new + + ActionText::ContentHelper.allowed_tags = (action_text_content_helper.sanitizer_allowed_tags.dup + ACTION_TEXT_ALLOWED_TAGS).uniq + ActionText::ContentHelper.allowed_attributes = (action_text_content_helper.sanitizer_allowed_attributes.dup + ACTION_TEXT_ALLOWED_ATTRIBUTES + additional_allowed_attributes(config)).uniq + + css_functions = Loofah::HTML5::SafeList::ALLOWED_CSS_FUNCTIONS + css_functions << "var" unless css_functions.include?("var") + end end diff --git a/lib/lexxy/engine.rb b/lib/lexxy/engine.rb index f02b983e4..b433af042 100644 --- a/lib/lexxy/engine.rb +++ b/lib/lexxy/engine.rb @@ -11,6 +11,7 @@ class Engine < ::Rails::Engine isolate_namespace Lexxy config.lexxy = ActiveSupport::OrderedOptions.new + config.lexxy.additional_allowed_attributes = [] config.lexxy.override_action_text_defaults = true initializer "lexxy.initialize" do |app| @@ -35,13 +36,7 @@ class Engine < ::Rails::Engine initializer "lexxy.sanitization" do |app| ActiveSupport.on_load(:action_text_content) do - default_allowed_tags = Class.new.include(ActionText::ContentHelper).new.sanitizer_allowed_tags - ActionText::ContentHelper.allowed_tags = default_allowed_tags + %w[ video audio source embed table tbody tr th td ] - - default_allowed_attributes = Class.new.include(ActionText::ContentHelper).new.sanitizer_allowed_attributes - ActionText::ContentHelper.allowed_attributes = default_allowed_attributes + %w[ controls poster data-language style ] - - Loofah::HTML5::SafeList::ALLOWED_CSS_FUNCTIONS << "var" # Allow CSS variables + Lexxy.configure_action_text_sanitizer!(app.config.lexxy) end end diff --git a/lib/lexxy/rich_text_area_tag.rb b/lib/lexxy/rich_text_area_tag.rb index ae7083d88..34bd91884 100644 --- a/lib/lexxy/rich_text_area_tag.rb +++ b/lib/lexxy/rich_text_area_tag.rb @@ -10,6 +10,8 @@ def lexxy_rich_textarea_tag(name, value = nil, options = {}, &block) options[:name] ||= name options[:value] ||= value options[:class] ||= "lexxy-content" + extra_attrs = Lexxy.additional_allowed_attributes + options[:"additional-allowed-attributes"] ||= extra_attrs.to_json if extra_attrs.any? options[:data] ||= {} options[:data][:direct_upload_url] ||= main_app.rails_direct_uploads_url options[:data][:blob_url_template] ||= main_app.rails_service_blob_url(":signed_id", ":filename") diff --git a/src/config/dom_purify.js b/src/config/dom_purify.js index 8c1ae74f9..6b552d137 100644 --- a/src/config/dom_purify.js +++ b/src/config/dom_purify.js @@ -5,7 +5,7 @@ import Lexxy from "./lexxy" const ALLOWED_HTML_TAGS = [ "a", "b", "blockquote", "br", "code", "div", "em", "figcaption", "figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "li", "mark", "ol", "p", "pre", "q", "s", "strong", "u", "ul", "table", "tbody", "tr", "th", "td" ] -const ALLOWED_HTML_ATTRIBUTES = [ "alt", "caption", "class", "content", "content-type", "contenteditable", +const DEFAULT_ALLOWED_HTML_ATTRIBUTES = [ "alt", "caption", "class", "content", "content-type", "contenteditable", "data-direct-upload-id", "data-sgid", "filename", "filesize", "height", "href", "presentation", "previewable", "sgid", "src", "style", "title", "url", "width" ] @@ -38,10 +38,13 @@ DOMPurify.addHook("uponSanitizeElement", (node, data) => { } }) -export function buildConfig() { +export function buildConfig({ additionalAllowedAttributes = [] } = {}) { return { ALLOWED_TAGS: ALLOWED_HTML_TAGS.concat(Lexxy.global.get("attachmentTagName")), - ALLOWED_ATTR: ALLOWED_HTML_ATTRIBUTES, + ALLOWED_ATTR: Array.from(new Set([ + ...DEFAULT_ALLOWED_HTML_ATTRIBUTES, + ...additionalAllowedAttributes, + ])), ADD_URI_SAFE_ATTR: [ "caption", "filename" ], SAFE_FOR_XML: false // So that it does not strip attributes that contains serialized HTML (like content) } diff --git a/src/config/lexxy.js b/src/config/lexxy.js index e26c591c1..960a5409f 100644 --- a/src/config/lexxy.js +++ b/src/config/lexxy.js @@ -10,6 +10,7 @@ const global = new Configuration({ const presets = new Configuration({ default: { + additionalAllowedAttributes: [], attachments: true, markdown: true, multiLine: true, diff --git a/src/elements/editor.js b/src/elements/editor.js index 7fa0df19f..e2817441b 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -184,7 +184,9 @@ export class LexicalEditorElement extends HTMLElement { get value() { if (!this.cachedValue) { this.editor?.getEditorState().read(() => { - this.cachedValue = sanitize($generateHtmlFromNodes(this.editor, null)) + this.cachedValue = sanitize($generateHtmlFromNodes(this.editor, null), { + additionalAllowedAttributes: this.config.get("additionalAllowedAttributes"), + }) }) } diff --git a/src/helpers/sanitization_helper.js b/src/helpers/sanitization_helper.js index 953315367..3c6c8c2f6 100644 --- a/src/helpers/sanitization_helper.js +++ b/src/helpers/sanitization_helper.js @@ -1,6 +1,6 @@ import DOMPurify from "dompurify" import { buildConfig } from "../config/dom_purify" -export function sanitize(html) { - return DOMPurify.sanitize(html, buildConfig()) +export function sanitize(html, config) { + return DOMPurify.sanitize(html, buildConfig(config)) } diff --git a/test/browser/fixtures/allowed-html-attributes.html b/test/browser/fixtures/allowed-html-attributes.html new file mode 100644 index 000000000..14a6179cf --- /dev/null +++ b/test/browser/fixtures/allowed-html-attributes.html @@ -0,0 +1,24 @@ + + + + + + Lexxy Test + + + +
+
+ +
+ +
+ +
+ +
+
+ + + + diff --git a/test/browser/tests/editor/load_html.test.js b/test/browser/tests/editor/load_html.test.js index 2d383794e..2fbf6d757 100644 --- a/test/browser/tests/editor/load_html.test.js +++ b/test/browser/tests/editor/load_html.test.js @@ -24,6 +24,29 @@ test.describe("Load HTML", () => { await assertEditorHtml(editor, "

hello

there

") }) + test("allows configuring additional html attributes", async ({ page, editor }) => { + // Initially stripped + await editor.setValue('
  1. Third
  2. Fourth
') + + await assertEditorHtml(editor, "
  1. Third
  2. Fourth
") + + // When configured they're maintained + await page.goto("/allowed-html-attributes.html") + await page.waitForSelector("lexxy-editor[connected]") + + await editor.setValue('
  1. Third
  2. Fourth
') + + await assertEditorHtml( + editor, + '
  1. Third
  2. Fourth
', + ) + await assertEditorContent(editor, async (content) => { + const items = content.locator("li") + await expect(items.first()).toHaveText("Third") + await expect(items.last()).toHaveText("Fourth") + }) + }) + test("load HTML with newlines between div elements does not crash", async ({ editor }) => { // Whitespace text nodes (\n) between block elements like
are common in email HTML. // Previously this threw Lexical error #282 because the \n became a TextNode at the root level. diff --git a/test/system/action_text_load_test.rb b/test/system/action_text_load_test.rb index 894071072..cb1209dbd 100644 --- a/test/system/action_text_load_test.rb +++ b/test/system/action_text_load_test.rb @@ -27,4 +27,26 @@ class ActionTextLoadTest < ApplicationSystemTestCase assert_selector "action-text-attachment[content-type='video/mp4']", count: 1 end end + + test "preserves configured HTML attributes through the Action Text round trip" do + with_lexxy_config(additional_allowed_attributes: %w[start]) do + post = Post.create!( + title: "Ordered list", + body: '
  1. Third
  2. Fourth
' + ) + + visit edit_post_path(post) + + assert_editor_html '
  1. Third
  2. Fourth
' + + click_on "Update Post" + + assert_selector "ol[start='3'] li", text: "Third" + assert_selector "ol[start='3'] li", text: "Fourth" + + click_on "Edit this post" + + assert_editor_html '
  1. Third
  2. Fourth
' + end + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 4ca3a879d..1a390bd2a 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -16,6 +16,6 @@ module ActiveSupport class TestCase - include EditorHelper, FocusHelper, HtmlHelper, ToolbarHelper, TrixHelper + include EditorHelper, FocusHelper, HtmlHelper, LexxyConfigurationHelper, ToolbarHelper, TrixHelper end end diff --git a/test/test_helpers/lexxy_configuration_helper.rb b/test/test_helpers/lexxy_configuration_helper.rb new file mode 100644 index 000000000..619d1ea5e --- /dev/null +++ b/test/test_helpers/lexxy_configuration_helper.rb @@ -0,0 +1,22 @@ +module LexxyConfigurationHelper + def with_lexxy_config(**options) + config = Rails.application.config.lexxy + original_values = options.transform_values { nil } + + options.each_key do |key| + original_values[key] = config.public_send(key) + end + + options.each do |key, value| + config.public_send("#{key}=", value) + end + Lexxy.configure_action_text_sanitizer!(config) + + yield + ensure + options.each_key do |key| + config.public_send("#{key}=", original_values[key]) + end + Lexxy.configure_action_text_sanitizer!(config) + end +end