diff --git a/java/src/json/ext/Parser.java b/java/src/json/ext/Parser.java index 6fa5d4383..eb5ce3a9a 100644 --- a/java/src/json/ext/Parser.java +++ b/java/src/json/ext/Parser.java @@ -164,7 +164,10 @@ private RubyString convertEncoding(ThreadContext context, RubyString source) { */ @JRubyMethod public IRubyObject parse(ThreadContext context, IRubyObject source) { - return new ParserSession(this, convertEncoding(context, source.convertToString()), info).parse(context); + // Like rb_str_new_frozen in parser.c: the caller's string becomes shared, so mutating it during parsing + // (e.g. from a decimal_class callback) copies its bytes instead of changing the ones being parsed. + RubyString frozenSource = convertEncoding(context, source.convertToString()).newFrozen(); + return new ParserSession(this, frozenSource, info).parse(context); } private RubyFloat createFloat(final ThreadContext context, final ByteList num) { diff --git a/test/json/json_parser_test.rb b/test/json/json_parser_test.rb index 3f8575c8a..0549f6f4c 100644 --- a/test/json/json_parser_test.rb +++ b/test/json/json_parser_test.rb @@ -1069,6 +1069,23 @@ def test_mutating_source_string_during_parsing assert_equal expected, actual end + def test_mutating_source_string_with_known_coderange_during_parsing + expected = ([1] * 100) + [2.3] + ([1] * 100) + source = JSON.generate(expected) + source.ascii_only? # caches the coderange, which lets JRuby's String#tr! write in place + expected.delete_at(100) + + fake_decimal_class = Class.new + fake_decimal_class.define_method(:initialize) do |number| + source.tr!('1', '0') + number.to_f + end + + actual = JSON.parse(source, decimal_class: fake_decimal_class) + actual.delete_at(100) + assert_equal expected, actual + end + def test_adversary_encode fake_string = Class.new(String) do def encode(*)