From 3e666467d8525eaec7efdbf5c87cdd90ea7524d0 Mon Sep 17 00:00:00 2001 From: Sutou Kouhei Date: Wed, 9 Sep 2026 16:42:36 +0900 Subject: [PATCH] Reject invalid character references in entity expansion Text.expand and BaseParser#unnormalize packed numeric character references directly with Array#pack without validating the code point. Unlike literal references, which Text.check validates, the entity-expansion path (Text#value, attribute values, and entity values) could therefore: * emit XML-forbidden characters such as NUL, other control characters, and the noncharacters U+FFFE / U+FFFF, and * emit invalid UTF-8, or leak a bare RangeError from Array#pack, for code points beyond U+10FFFF. These happen lazily at value-access time, so the document parses and only fails (or silently corrupts) later. Validate the code point via the new Text.expand_character_reference against VALID_CHAR and raise REXML::ParseException for invalid values, so the expansion path behaves consistently with the literal path. The error message echoes the original reference verbatim to keep decimal and hexadecimal forms distinguishable. Co-Authored-By: Claude Opus 4.8 --- lib/rexml/parsers/baseparser.rb | 3 +- lib/rexml/text.rb | 18 ++++++- test/test_text.rb | 91 +++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/lib/rexml/parsers/baseparser.rb b/lib/rexml/parsers/baseparser.rb index 5498b711..d87f281d 100644 --- a/lib/rexml/parsers/baseparser.rb +++ b/lib/rexml/parsers/baseparser.rb @@ -3,6 +3,7 @@ require_relative '../undefinednamespaceexception' require_relative '../security' require_relative '../source' +require_relative '../text' require 'set' require "strscan" @@ -584,7 +585,7 @@ def unnormalize( string, entities=nil, filter=nil, expanding: nil ) else code_point = Integer(m, 10) end - [code_point].pack('U*') + Text.expand_character_reference(code_point, "&##{m};") } matches.collect!{|x|x[0]}.compact! if filter diff --git a/lib/rexml/text.rb b/lib/rexml/text.rb index d0ff549a..7e31046c 100644 --- a/lib/rexml/text.rb +++ b/lib/rexml/text.rb @@ -387,12 +387,26 @@ def Text::unnormalize( string, doctype=nil, filter=nil, illegal=nil, entity_expa } end + # Expands a numeric character reference's code point to a String. + # Code points that are not valid XML characters are rejected, matching + # the validation Text.check applies to literal references. This keeps the + # entity-expansion path from emitting XML-forbidden characters (NUL, + # control chars, U+FFFE/U+FFFF, code points beyond U+10FFFF, ...). + def Text.expand_character_reference(code_point, reference) + case code_point + when *VALID_CHAR + [code_point].pack('U*') + else + raise ParseException.new("Illegal character reference: <#{reference}>") + end + end + def Text.expand(ref, doctype, filter, expanding: nil) if ref[1] == ?# if ref[2] == ?x - [ref[3...-1].to_i(16)].pack('U*') + expand_character_reference(ref[3...-1].to_i(16), ref) else - [ref[2...-1].to_i].pack('U*') + expand_character_reference(ref[2...-1].to_i, ref) end elsif ref == '&' '&' diff --git a/test/test_text.rb b/test/test_text.rb index f5df2513..a589baf8 100644 --- a/test/test_text.rb +++ b/test/test_text.rb @@ -105,5 +105,96 @@ def test_read_with_substitution_illegal end end end + + def test_expand_character_reference_decimal + assert_equal("A", Text.expand("A", nil, nil)) + end + + def test_expand_character_reference_hexadecimal + assert_equal("A", Text.expand("A", nil, nil)) + end + + def test_expand_character_reference_supplementary_plane + assert_equal("\u{1F600}", Text.expand("😀", nil, nil)) + end + + def test_expand_character_reference_forbidden_null + exception = assert_raise(REXML::ParseException) do + Text.expand("�", nil, nil) + end + assert_equal("Illegal character reference: <�>", exception.to_s) + end + + def test_expand_character_reference_forbidden_start_of_heading + exception = assert_raise(REXML::ParseException) do + Text.expand("", nil, nil) + end + assert_equal("Illegal character reference: <>", exception.to_s) + end + + def test_expand_character_reference_forbidden_backspace + exception = assert_raise(REXML::ParseException) do + Text.expand("", nil, nil) + end + assert_equal("Illegal character reference: <>", exception.to_s) + end + + def test_expand_character_reference_forbidden_vertical_tab_decimal + exception = assert_raise(REXML::ParseException) do + Text.expand(" ", nil, nil) + end + assert_equal("Illegal character reference: < >", exception.to_s) + end + + def test_expand_character_reference_forbidden_vertical_tab_hexadecimal + exception = assert_raise(REXML::ParseException) do + Text.expand(" ", nil, nil) + end + assert_equal("Illegal character reference: < >", exception.to_s) + end + + def test_expand_character_reference_forbidden_noncharacter_fffe + exception = assert_raise(REXML::ParseException) do + Text.expand("￾", nil, nil) + end + assert_equal("Illegal character reference: <￾>", exception.to_s) + end + + def test_expand_character_reference_forbidden_noncharacter_ffff + exception = assert_raise(REXML::ParseException) do + Text.expand("￿", nil, nil) + end + assert_equal("Illegal character reference: <￿>", exception.to_s) + end + + def test_expand_character_reference_forbidden_beyond_unicode + exception = assert_raise(REXML::ParseException) do + Text.expand("�", nil, nil) + end + assert_equal("Illegal character reference: <�>", exception.to_s) + end + + def test_expand_character_reference_forbidden_out_of_range + exception = assert_raise(REXML::ParseException) do + Text.expand("�", nil, nil) + end + assert_equal("Illegal character reference: <�>", exception.to_s) + end + + def test_unnormalize_forbidden_character_reference + exception = assert_raise(REXML::ParseException) do + Text.unnormalize("safe text � more") + end + assert_equal("Illegal character reference: <�>", exception.to_s) + end + + def test_entity_value_forbidden_character_reference + document = REXML::Document.new( + "]>&e;") + exception = assert_raise(REXML::ParseException) do + document.root.children.first.value + end + assert_equal("Illegal character reference: <�>", exception.to_s) + end end end