diff --git a/Gemfile.lock b/Gemfile.lock index 1cd6c74..96a6368 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -375,10 +375,10 @@ GEM railties (>= 6.0.0) stringio (3.1.8) thor (1.4.0) - thruster (0.1.15-aarch64-linux) - thruster (0.1.15-arm64-darwin) - thruster (0.1.15-x86_64-darwin) - thruster (0.1.15-x86_64-linux) + thruster (0.1.23-aarch64-linux) + thruster (0.1.23-arm64-darwin) + thruster (0.1.23-x86_64-darwin) + thruster (0.1.23-x86_64-linux) tilt (2.6.1) timeout (0.6.1) tsort (0.2.0) diff --git a/app/helpers/content_filters.rb b/app/helpers/content_filters.rb index 926a726..7a2737c 100644 --- a/app/helpers/content_filters.rb +++ b/app/helpers/content_filters.rb @@ -1,3 +1,3 @@ module ContentFilters - TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags) + TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags, SanitizeAttributes) end diff --git a/app/helpers/content_filters/sanitize_attributes.rb b/app/helpers/content_filters/sanitize_attributes.rb new file mode 100644 index 0000000..47dedc1 --- /dev/null +++ b/app/helpers/content_filters/sanitize_attributes.rb @@ -0,0 +1,47 @@ +class ContentFilters::SanitizeAttributes < ActionText::Content::Filter + def applicable? + true + end + + # Scrub attributes on the tags that SanitizeTags allows, using Rails' safe-list + # sanitizer so unsafe URI schemes and event-handler attributes are stripped. + # Runs after SanitizeTags, so passing the same allowed tags makes the tag pass + # a no-op and only attributes are scrubbed. + def apply + sanitizer.sanitize fragment.to_html, tags: allowed_tags, attributes: allowed_attributes + end + + private + # Presentation styling relies on class attributes (e.g. unfurled link embeds), + # which the standard ActionText set doesn't include. + EXTRA_ALLOWED_ATTRIBUTES = %w[ class ] + + # ActionText::ContentHelper.sanitizer is a single process-wide instance, and + # rails-html-sanitizer reuses one mutable permit scrubber that it reconfigures + # from the tags/attributes on every #sanitize call. Sharing it here would race + # our stricter tag set against ActionText's default rendering on concurrent + # requests, so use a dedicated instance of the same sanitizer class to keep the + # scrubber isolated. + def sanitizer + sanitizer_class.new + end + + def sanitizer_class + ActionText::ContentHelper.sanitizer.class + end + + def allowed_tags + ContentFilters::SanitizeTags::ALLOWED_TAGS + end + + def allowed_attributes + standard_allowed_attributes + EXTRA_ALLOWED_ATTRIBUTES + end + + # Mirrors ActionText::ContentHelper#sanitizer_allowed_attributes, which isn't + # exposed at the module level. + def standard_allowed_attributes + ActionText::ContentHelper.allowed_attributes || + (sanitizer_class.allowed_attributes + ActionText::Attachment::ATTRIBUTES).to_a + end +end diff --git a/app/views/messages/_message.html.erb b/app/views/messages/_message.html.erb index db41dcb..b3b1213 100644 --- a/app/views/messages/_message.html.erb +++ b/app/views/messages/_message.html.erb @@ -1,6 +1,7 @@ <%# Be sure to check/update messages/_template.html.erb when changing this file %> -<% cache message do %> +<%# Bump this version when the message presentation filters change what they emit. Editing this line changes the template digest, which busts BOTH this fragment cache and the collection cache that keys on this partial's digest (helper Ruby changes alone don't). %> +<% cache [ message, "presentation-v2" ] do %> <%= message_tag message do %>

<%= local_datetime_tag message.created_at, style: :date %>

diff --git a/test/helpers/content_filters_test.rb b/test/helpers/content_filters_test.rb index 00b2709..14f48de 100644 --- a/test/helpers/content_filters_test.rb +++ b/test/helpers/content_filters_test.rb @@ -61,6 +61,75 @@ class ContentFiltersTest < ActionView::TestCase assert_equal "Hello World", filtered.to_html end + test "message with a link using an unsafe URI scheme" do + message = Message.create! room: rooms(:pets), body: '
x
', client_message_id: "0015", creator: users(:jason) + + filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body) + assert_no_match /javascript:/, filtered.to_html + assert_match /x<\/a>/, filtered.to_html + end + + test "message with an event handler attribute on an allowed tag" do + message = Message.create! room: rooms(:pets), body: '
x y
', client_message_id: "0015", creator: users(:jason) + + filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body) + assert_no_match /onmouseover/, filtered.to_html + assert_no_match /onclick/, filtered.to_html + assert_match /x<\/a>/, filtered.to_html + assert_match /y<\/span>/, filtered.to_html + end + + test "message with a data URI link" do + message = Message.create! room: rooms(:pets), body: '
x
', client_message_id: "0015", creator: users(:jason) + + filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body) + assert_no_match /data:/, filtered.to_html + assert_match /x<\/a>/, filtered.to_html + end + + test "message with a safe link and formatting is preserved" do + body = '
example bold code
' + message = Message.create! room: rooms(:pets), body: body, client_message_id: "0015", creator: users(:jason) + + filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body) + assert_match /example<\/a>/, filtered.to_html + assert_match /bold<\/strong>/, filtered.to_html + assert_match /code<\/code>/, filtered.to_html + assert_match /