From 57c40661793da86222fb7692c6fe3ee1038e2f54 Mon Sep 17 00:00:00 2001 From: MAEDA Go Date: Fri, 9 Oct 2026 14:07:22 +0900 Subject: [PATCH 2/4] Sanitize the HTML generated by the Textile formatter with an allowlist, as the CommonMark formatter does --- .../wiki_formatting/textile/formatter.rb | 3 + .../textile/sanitization_filter.rb | 68 ++++++++++++++++ test/helpers/application_helper_test.rb | 4 +- .../textile/sanitization_filter_test.rb | 80 +++++++++++++++++++ .../wiki_formatting/textile_formatter_test.rb | 6 +- 5 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 lib/redmine/wiki_formatting/textile/sanitization_filter.rb create mode 100644 test/unit/lib/redmine/wiki_formatting/textile/sanitization_filter_test.rb diff --git a/lib/redmine/wiki_formatting/textile/formatter.rb b/lib/redmine/wiki_formatting/textile/formatter.rb index 93598f3c1..1a0e8bad0 100644 --- a/lib/redmine/wiki_formatting/textile/formatter.rb +++ b/lib/redmine/wiki_formatting/textile/formatter.rb @@ -26,6 +26,8 @@ module Redmine Redmine::WikiFormatting::TablesortScrubber.new ] + SANITIZER = SanitizationFilter.new + class Formatter include Redmine::WikiFormatting::SectionHelper @@ -40,6 +42,7 @@ module Redmine def to_html(*rules) html = @filter.to_html(rules) fragment = Loofah.html5_fragment(html) + SANITIZER.call(fragment) scrubbers = SCRUBBERS + post_processor_scrubbers scrubber = Loofah::Scrubber.new do |node| diff --git a/lib/redmine/wiki_formatting/textile/sanitization_filter.rb b/lib/redmine/wiki_formatting/textile/sanitization_filter.rb new file mode 100644 index 000000000..0db5804f2 --- /dev/null +++ b/lib/redmine/wiki_formatting/textile/sanitization_filter.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +module Redmine + module WikiFormatting + module Textile + # Sanitizes the HTML generated by the Textile formatter + class SanitizationFilter < Redmine::WikiFormatting::SanitizationFilter + ALLOWED_CLASS_RE = /\A(wiki-class-\S+|external|email|footnote)\z/ + ALLOWED_ID_RE = /\A(wiki-id-\S+|fn\d+)\z/ + + # Same properties as the ones accepted by RedCloth3::STYLES_RE + STYLE_PROPERTY_RE = /\A(color|(min-|max-)?(width|height)|border|background|padding|margin|font|text|float)(-[a-z]+)*\z/ + STYLE_PROPERTIES = + (Sanitize::Config::RELAXED[:css][:properties].grep(STYLE_PROPERTY_RE) + %w[vertical-align]).freeze + + private + + def default_allowlist + allowlist = super + allowlist[:elements] += %w[font] + allowlist[:attributes][:all] += %w[class id] + allowlist[:attributes]['font'] = %w[face] + allowlist[:css][:properties] |= STYLE_PROPERTIES + allowlist + end + + def add_transformers + super + allowlist[:transformers].push method(:remove_disallowed_classes_and_ids) + end + + def remove_disallowed_classes_and_ids(env) + node = env[:node] + return unless node.element? + + # The class of holds the language for syntax highlighting + if node['class'] && node.name != 'code' + allowed_classes = node['class'].split.grep(ALLOWED_CLASS_RE) + if allowed_classes.any? + node['class'] = allowed_classes.join(' ') + else + node.remove_attribute('class') + end + end + + node.remove_attribute('id') if node['id'] && !ALLOWED_ID_RE.match?(node['id']) + end + end + end + end +end diff --git a/test/helpers/application_helper_test.rb b/test/helpers/application_helper_test.rb index ca60d176f..ed317fe6f 100644 --- a/test/helpers/application_helper_test.rb +++ b/test/helpers/application_helper_test.rb @@ -92,7 +92,7 @@ class ApplicationHelperTest < Redmine::HelperTest '' \ 'http://example.net/path!602815048C7B5C20!302.html', # escaping - 'http://foo"bar' => 'http://foo"bar', + 'http://foo"bar' => 'http://foo"bar', # wrap in angle brackets '' => '<http://foo.bar>', # invalid urls @@ -218,7 +218,7 @@ class ApplicationHelperTest < Redmine::HelperTest '"a link":http://example.net/path!602815048C7B5C20!302.html' => 'a link', # escaping - '"test":http://foo"bar' => 'test', + '"test":http://foo"bar' => 'test', # ends with a hyphen '(see "inline link":http://www.foo.bar/Test-)' => '(see inline link)', diff --git a/test/unit/lib/redmine/wiki_formatting/textile/sanitization_filter_test.rb b/test/unit/lib/redmine/wiki_formatting/textile/sanitization_filter_test.rb new file mode 100644 index 000000000..aed63b49a --- /dev/null +++ b/test/unit/lib/redmine/wiki_formatting/textile/sanitization_filter_test.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +require_relative '../../../../../test_helper' + +class Redmine::WikiFormatting::Textile::SanitizationFilterTest < ActiveSupport::TestCase + def filter(html) + fragment = Redmine::WikiFormatting::HtmlParser.parse(html) + sanitizer = Redmine::WikiFormatting::Textile::SanitizationFilter.new + sanitizer.call(fragment) + fragment.to_s + end + + def test_should_filter_tags + input = %( foo) + assert_equal %( foo), filter(input) + end + + def test_should_sanitize_attributes + input = %( link) + assert_equal %( link), filter(input) + end + + def test_should_remove_links_with_unsafe_scheme + input = %(link) + assert_equal %(link), filter(input) + end + + def test_should_allow_classes_and_ids_generated_by_textile + [ + %(

foo

), + %(foo), + %(), + %(

1 foo

), + %(
foo
), + ].each do |input| + assert_equal input, filter(input) + end + end + + def test_should_remove_other_classes_and_ids + input = %() + assert_equal %(

foo

), filter(input) + end + + def test_should_allow_styles_generated_by_textile + [ + %(

foo

), + %(

foo

), + %(

foo

), + ].each do |input| + assert_equal input, filter(input) + end + end + + def test_should_remove_other_styles + [ + %(

foo

), + %(

foo

), + ].each do |input| + assert_equal %(

foo

), filter(input) + end + end +end diff --git a/test/unit/lib/redmine/wiki_formatting/textile_formatter_test.rb b/test/unit/lib/redmine/wiki_formatting/textile_formatter_test.rb index 5bef22d73..c8d1c26e6 100644 --- a/test/unit/lib/redmine/wiki_formatting/textile_formatter_test.rb +++ b/test/unit/lib/redmine/wiki_formatting/textile_formatter_test.rb @@ -299,6 +299,7 @@ class Redmine::WikiFormatting::TextileFormatterTest < Redmine::HelperTest |>. right| |<. left| |<>. justify| + |^. top| RAW expected = <<~EXPECTED @@ -306,6 +307,7 @@ class Redmine::WikiFormatting::TextileFormatterTest < Redmine::HelperTest +
right
left
justify
top
EXPECTED @@ -418,7 +420,7 @@ class Redmine::WikiFormatting::TextileFormatterTest < Redmine::HelperTest raw = '!/images/comment.png"onclick=alert('XSS');"!' expected = - '

' assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '') @@ -828,7 +830,7 @@ class Redmine::WikiFormatting::TextileFormatterTest < Redmine::HelperTest payload = %{https://example.com/?a:"onmouseover=alert(document.domain)//"} html = to_html(payload) - assert_includes html, '"', 'quotes inside the href must stay escaped' + assert_includes html, 'href="https://example.com/?a:%22onmouseover', 'quotes inside the href must stay escaped' assert_empty( Nokogiri::HTML.fragment(html).css('[onmouseover]'), "restore_redmine_links injected an attribute into the rendered HTML: #{html}" -- 2.55.0