Project

General

Profile

Feature #44585 » 0002-Sanitize-the-HTML-generated-by-the-Textile-formatter.patch

Go MAEDA, 2026-10-09 07:20

View differences:

lib/redmine/wiki_formatting/textile/formatter.rb
26 26
        Redmine::WikiFormatting::TablesortScrubber.new
27 27
      ]
28 28

  
29
      SANITIZER = SanitizationFilter.new
30

  
29 31
      class Formatter
30 32
        include Redmine::WikiFormatting::SectionHelper
31 33

  
......
40 42
        def to_html(*rules)
41 43
          html = @filter.to_html(rules)
42 44
          fragment = Loofah.html5_fragment(html)
45
          SANITIZER.call(fragment)
43 46

  
44 47
          scrubbers = SCRUBBERS + post_processor_scrubbers
45 48
          scrubber = Loofah::Scrubber.new do |node|
lib/redmine/wiki_formatting/textile/sanitization_filter.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
module Redmine
21
  module WikiFormatting
22
    module Textile
23
      # Sanitizes the HTML generated by the Textile formatter
24
      class SanitizationFilter < Redmine::WikiFormatting::SanitizationFilter
25
        ALLOWED_CLASS_RE = /\A(wiki-class-\S+|external|email|footnote)\z/
26
        ALLOWED_ID_RE = /\A(wiki-id-\S+|fn\d+)\z/
27

  
28
        # Same properties as the ones accepted by RedCloth3::STYLES_RE
29
        STYLE_PROPERTY_RE = /\A(color|(min-|max-)?(width|height)|border|background|padding|margin|font|text|float)(-[a-z]+)*\z/
30
        STYLE_PROPERTIES =
31
          (Sanitize::Config::RELAXED[:css][:properties].grep(STYLE_PROPERTY_RE) + %w[vertical-align]).freeze
32

  
33
        private
34

  
35
        def default_allowlist
36
          allowlist = super
37
          allowlist[:elements] += %w[font]
38
          allowlist[:attributes][:all] += %w[class id]
39
          allowlist[:attributes]['font'] = %w[face]
40
          allowlist[:css][:properties] |= STYLE_PROPERTIES
41
          allowlist
42
        end
43

  
44
        def add_transformers
45
          super
46
          allowlist[:transformers].push method(:remove_disallowed_classes_and_ids)
47
        end
48

  
49
        def remove_disallowed_classes_and_ids(env)
50
          node = env[:node]
51
          return unless node.element?
52

  
53
          # The class of <code> holds the language for syntax highlighting
54
          if node['class'] && node.name != 'code'
55
            allowed_classes = node['class'].split.grep(ALLOWED_CLASS_RE)
56
            if allowed_classes.any?
57
              node['class'] = allowed_classes.join(' ')
58
            else
59
              node.remove_attribute('class')
60
            end
61
          end
62

  
63
          node.remove_attribute('id') if node['id'] && !ALLOWED_ID_RE.match?(node['id'])
64
        end
65
      end
66
    end
67
  end
68
end
test/helpers/application_helper_test.rb
92 92
         '<a class="external" href="http://example.net/path!602815048C7B5C20!302.html">' \
93 93
           'http://example.net/path!602815048C7B5C20!302.html</a>',
94 94
      # escaping
95
      'http://foo"bar' => '<a class="external" href="http://foo&quot;bar">http://foo"bar</a>',
95
      'http://foo"bar' => '<a class="external" href="http://foo%22bar">http://foo"bar</a>',
96 96
      # wrap in angle brackets
97 97
      '<http://foo.bar>' => '&lt;<a class="external" href="http://foo.bar">http://foo.bar</a>&gt;',
98 98
      # invalid urls
......
218 218
      '"a link":http://example.net/path!602815048C7B5C20!302.html' =>
219 219
        '<a href="http://example.net/path!602815048C7B5C20!302.html" class="external">a link</a>',
220 220
      # escaping
221
      '"test":http://foo"bar' => '<a href="http://foo&quot;bar" class="external">test</a>',
221
      '"test":http://foo"bar' => '<a href="http://foo%22bar" class="external">test</a>',
222 222
      # ends with a hyphen
223 223
      '(see "inline link":http://www.foo.bar/Test-)' =>
224 224
        '(see <a href="http://www.foo.bar/Test-" class="external">inline link</a>)',
test/unit/lib/redmine/wiki_formatting/textile/sanitization_filter_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../../../../../test_helper'
21

  
22
class Redmine::WikiFormatting::Textile::SanitizationFilterTest < ActiveSupport::TestCase
23
  def filter(html)
24
    fragment = Redmine::WikiFormatting::HtmlParser.parse(html)
25
    sanitizer = Redmine::WikiFormatting::Textile::SanitizationFilter.new
26
    sanitizer.call(fragment)
27
    fragment.to_s
28
  end
29

  
30
  def test_should_filter_tags
31
    input = %(<script>alert(1)</script><iframe src="https://example.com/"></iframe><input type="text"> <b>foo</b>)
32
    assert_equal %( <b>foo</b>), filter(input)
33
  end
34

  
35
  def test_should_sanitize_attributes
36
    input = %(<img src="foo.png" onerror="alert(1)"> <a href="foo" onclick="alert(1)">link</a>)
37
    assert_equal %(<img src="foo.png"> <a href="foo">link</a>), filter(input)
38
  end
39

  
40
  def test_should_remove_links_with_unsafe_scheme
41
    input = %(<a href="javascript:alert(1)">link</a>)
42
    assert_equal %(<a>link</a>), filter(input)
43
  end
44

  
45
  def test_should_allow_classes_and_ids_generated_by_textile
46
    [
47
      %(<p class="wiki-class-foo wiki-class-bar" id="wiki-id-baz">foo</p>),
48
      %(<a class="external" href="https://example.com/">foo</a>),
49
      %(<a class="email" href="mailto:foo@example.com">foo</a>),
50
      %(<p id="fn1" class="footnote"><sup>1</sup> foo</p>),
51
      %(<pre><code class="ruby">foo</code></pre>),
52
    ].each do |input|
53
      assert_equal input, filter(input)
54
    end
55
  end
56

  
57
  def test_should_remove_other_classes_and_ids
58
    input = %(<p class="wiki-class-foo flash error" id="main-menu">foo</p>)
59
    assert_equal %(<p class="wiki-class-foo">foo</p>), filter(input)
60
  end
61

  
62
  def test_should_allow_styles_generated_by_textile
63
    [
64
      %(<p style="vertical-align:top;">foo</p>),
65
      %(<p style="background:#fdd;">foo</p>),
66
      %(<p style="border-top-left-radius: 10px 5px;">foo</p>),
67
    ].each do |input|
68
      assert_equal input, filter(input)
69
    end
70
  end
71

  
72
  def test_should_remove_other_styles
73
    [
74
      %(<p style="position:fixed;">foo</p>),
75
      %(<p style="background:url(https://example.com/foo.png);">foo</p>),
76
    ].each do |input|
77
      assert_equal %(<p>foo</p>), filter(input)
78
    end
79
  end
80
end
test/unit/lib/redmine/wiki_formatting/textile_formatter_test.rb
299 299
      |>. right|
300 300
      |<. left|
301 301
      |<>. justify|
302
      |^. top|
302 303
    RAW
303 304
    expected = <<~EXPECTED
304 305
      <table>
......
306 307
        <tr><td style="text-align:right;">right</td></tr>
307 308
        <tr><td style="text-align:left;">left</td></tr>
308 309
        <tr><td style="text-align:justify;">justify</td></tr>
310
        <tr><td style="vertical-align:top;">top</td></tr>
309 311
        </tbody>
310 312
      </table>
311 313
    EXPECTED
......
418 420
    raw =
419 421
      '!/images/comment.png"onclick=&#x61;&#x6c;&#x65;&#x72;&#x74;&#x28;&#x27;&#x58;&#x53;&#x53;&#x27;&#x29;;&#x22;!'
420 422
    expected =
421
      '<p><img src="/images/comment.png&quot;onclick=' \
423
      '<p><img src="/images/comment.png%22onclick=' \
422 424
        '&amp;#x61;&amp;#x6c;&amp;#x65;&amp;#x72;&amp;#x74;&amp;#x28;' \
423 425
        '&amp;#x27;&amp;#x58;&amp;#x53;&amp;#x53;&amp;#x27;&amp;#x29;;&amp;#x22;" alt=""></p>'
424 426
    assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '')
......
828 830
    payload = %{https://example.com/?a:"onmouseover=alert(document.domain)//"}
829 831
    html = to_html(payload)
830 832

  
831
    assert_includes html, '&quot;', 'quotes inside the href must stay escaped'
833
    assert_includes html, 'href="https://example.com/?a:%22onmouseover', 'quotes inside the href must stay escaped'
832 834
    assert_empty(
833 835
      Nokogiri::HTML.fragment(html).css('[onmouseover]'),
834 836
      "restore_redmine_links injected an attribute into the rendered HTML: #{html}"
(2-2/3)