From e3506ea883207cc54f436cb82ea0e5623420efef Mon Sep 17 00:00:00 2001 From: Katsuya HIDAKA Date: Thu, 5 Nov 2026 14:19:34 +0900 Subject: Fix broken links to headings containing quotes --- app/helpers/application_helper.rb | 7 ++++-- test/helpers/application_helper_test.rb | 32 ++++++++++++++++++------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 58ebf2b3b..fcc2d0f01 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1444,7 +1444,10 @@ module ApplicationHelper text.gsub!(HEADING_RE) do level, attrs, content = $2.to_i, $3, $4 - item = strip_tags(content).strip + # Remove HTML tags using the same sanitizer as strip_tags, without escaping quotes. + # Keep quotes literal so sanitize_anchor_name removes them rather than leaving + # parts of HTML entities in the anchor (e.g. ' becoming 39). + item = self.class.full_sanitizer.sanitize(content).strip anchor = sanitize_anchor_name(item) # used for single-file wiki export if options[:wiki_links] == :anchor && (obj.is_a?(WikiContent) || @@ -1456,7 +1459,7 @@ module ApplicationHelper if idx > 1 anchor = "#{anchor}-#{idx}" end - @parsed_headings << [level, anchor, item] + @parsed_headings << [level, anchor, ERB::Util.html_escape_once(item)] "\n#{content}" \ "" end diff --git a/test/helpers/application_helper_test.rb b/test/helpers/application_helper_test.rb index 5b3c8f2cc..1b0a28cd3 100644 --- a/test/helpers/application_helper_test.rb +++ b/test/helpers/application_helper_test.rb @@ -1374,15 +1374,29 @@ class ApplicationHelperTest < Redmine::HelperTest end def test_headings_with_special_chars - # This test makes sure that the generated anchor names match the expected - # ones even if the heading text contains unconventional characters - raw = 'h1. Some heading related to version 0.5' - anchor = sanitize_anchor_name("Some-heading-related-to-version-0.5") - expected = - %|\n

Some heading related to version 0.5| + - %|

| - with_settings :text_formatting => 'textile' do - assert_equal expected, textilizable(raw) + # Each value is [expected anchor name, expected HTML-escaped TOC text]. + headings = { + 'Some heading related to version 0.5' => + ['Some-heading-related-to-version-05', 'Some heading related to version 0.5'], + "Bob's guide" => ['Bobs-guide', 'Bob's guide'], + 'The "quoted" guide' => ['The-quoted-guide', 'The "quoted" guide'], + "*Bob's* & B" => ['Bobs-amp-B', 'Bob's & B'], + '<img src=x onerror=alert(1)>' => + ['ltimg-srcx-onerroralert1gt', '<img src=x onerror=alert(1)>'] + } + + {'textile' => 'h1.', 'common_mark' => '#'}.each do |format, prefix| + with_settings :text_formatting => format do + headings.each do |heading, (anchor, escaped_heading)| + raw = "{{toc}}\n\n#{prefix} #{heading}\n\n[[##{anchor}|Wiki link]]" + html = textilizable(raw) + + assert_select_in html, 'a[name=?]', anchor, :count => 1 + assert_select_in html, 'h1 a.wiki-anchor[href=?]', "##{anchor}", :count => 1 + assert_include %(#{escaped_heading}), html + assert_select_in html, 'a.wiki-page[href=?]', "##{anchor}", :text => 'Wiki link' + end + end end end -- 2.51.0