From 56e83592762c70204756935700f89a2e028f37e2 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 8 Jun 2026 17:52:28 +0900 Subject: [PATCH 1/3] Route !ruby/encoding through the class loader safe_load resolved !ruby/encoding directly via ::Encoding.find, bypassing the permitted_classes check that !ruby/object:Encoding already honors. Load it through the class loader so Encoding is only deserialized when permitted. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/psych/class_loader.rb | 1 + lib/psych/visitors/to_ruby.rb | 1 + test/psych/test_safe_load.rb | 13 +++++++++++++ 3 files changed, 15 insertions(+) diff --git a/lib/psych/class_loader.rb b/lib/psych/class_loader.rb index c8f50972..3a866ae4 100644 --- a/lib/psych/class_loader.rb +++ b/lib/psych/class_loader.rb @@ -9,6 +9,7 @@ class ClassLoader # :nodoc: DATA = 'Data' unless RUBY_VERSION < "3.2" DATE = 'Date' DATE_TIME = 'DateTime' + ENCODING = 'Encoding' EXCEPTION = 'Exception' OBJECT = 'Object' PSYCH_OMAP = 'Psych::Omap' diff --git a/lib/psych/visitors/to_ruby.rb b/lib/psych/visitors/to_ruby.rb index 475444e5..4a0b84f6 100644 --- a/lib/psych/visitors/to_ruby.rb +++ b/lib/psych/visitors/to_ruby.rb @@ -87,6 +87,7 @@ def deserialize o DateTime.civil(*t.to_a[0, 6].reverse, Rational(t.utc_offset, 86400)) + (t.subsec/86400) when '!ruby/encoding' + class_loader.encoding ::Encoding.find o.value when "!ruby/object:Complex" class_loader.complex diff --git a/test/psych/test_safe_load.rb b/test/psych/test_safe_load.rb index e6ca1e14..ac62df3b 100644 --- a/test/psych/test_safe_load.rb +++ b/test/psych/test_safe_load.rb @@ -74,6 +74,19 @@ def test_symbol assert_equal :foo, Psych.safe_load('--- !ruby/symbol foo', permitted_classes: [Symbol]) end + def test_encoding + yaml = "--- !ruby/encoding UTF-8\n" + assert_raise(Psych::DisallowedClass) do + Psych.safe_load yaml + end + assert_raise(Psych::DisallowedClass) do + Psych.safe_load yaml, permitted_classes: [] + end + + assert_equal Encoding::UTF_8, Psych.safe_load(yaml, permitted_classes: [Encoding]) + assert_equal Encoding::UTF_8, Psych.safe_load(yaml, permitted_classes: %w{ Encoding }) + end + def test_foo assert_raise(Psych::DisallowedClass) do Psych.safe_load '--- !ruby/object:Foo {}', permitted_classes: [Foo] From 29bbc28d22c61655690527f7ddcaea7c95449e8d Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 8 Jun 2026 17:53:15 +0900 Subject: [PATCH 2/3] Reject non-Hash classes for hash-with-ivars tags !ruby/hash-with-ivars, !ruby/hash and !map are only emitted for Hash subclasses, but the loader allocated whatever class the tag named and populated its ivars directly. That let a permitted non-Hash class be instantiated with attacker-chosen ivars, bypassing its init_with validation. Verify the resolved class is a Hash subclass before use. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/psych/visitors/to_ruby.rb | 17 +++++++++++++++-- test/psych/test_hash.rb | 25 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/lib/psych/visitors/to_ruby.rb b/lib/psych/visitors/to_ruby.rb index 4a0b84f6..e9e62e13 100644 --- a/lib/psych/visitors/to_ruby.rb +++ b/lib/psych/visitors/to_ruby.rb @@ -302,7 +302,7 @@ def visit_Psych_Nodes_Mapping o set when /^!ruby\/hash-with-ivars(?::(.*))?$/ - hash = $1 ? resolve_class($1).allocate : {} + hash = $1 ? resolve_subclass($1, Hash).allocate : {} register o, hash o.children.each_slice(2) do |key, value| case key.value @@ -317,7 +317,7 @@ def visit_Psych_Nodes_Mapping o hash when /^!map:(.*)$/, /^!ruby\/hash:(.*)$/ - revive_hash register(o, resolve_class($1).allocate), o + revive_hash register(o, resolve_subclass($1, Hash).allocate), o when '!omap', 'tag:yaml.org,2002:omap' map = register(o, class_loader.psych_omap.new) @@ -469,6 +469,19 @@ def init_with o, h, node def resolve_class klassname class_loader.load klassname end + + # Resolve +klassname+ and ensure it is +parent+ or one of its + # subclasses. Tags such as !ruby/hash-with-ivars are only ever emitted + # for subclasses of a specific core class; without this check a crafted + # document could name an unrelated (but permitted) class and have its + # state populated directly, bypassing the class's own init_with. + def resolve_subclass klassname, parent + klass = resolve_class(klassname) + if klass && !(klass <= parent) + raise ArgumentError, "Invalid tag: expected a subclass of #{parent}, got #{klass}" + end + klass + end end class NoAliasRuby < ToRuby diff --git a/test/psych/test_hash.rb b/test/psych/test_hash.rb index 31eba858..7f46c551 100644 --- a/test/psych/test_hash.rb +++ b/test/psych/test_hash.rb @@ -92,6 +92,31 @@ def test_map assert_equal X, x.class end + class NotAHash + def init_with(coder) + @string = coder.map["string"].to_s + end + end + + def test_hash_with_ivars_rejects_non_hash_class + assert_raise(ArgumentError) do + Psych.unsafe_load <<~eoyml + --- !ruby/hash-with-ivars:#{NotAHash} + ivars: + '@string': ["a surprise array!"] + eoyml + end + end + + def test_hash_tag_rejects_non_hash_class + assert_raise(ArgumentError) do + Psych.unsafe_load "--- !ruby/hash:#{NotAHash} {}\n" + end + assert_raise(ArgumentError) do + Psych.unsafe_load "--- !map:#{NotAHash} {}\n" + end + end + def test_self_referential @hash['self'] = @hash assert_cycle(@hash) From 33e9659591003f718003a360504b4b3559738156 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 8 Jun 2026 17:55:02 +0900 Subject: [PATCH 3/3] Reject non-Array/String classes for their subclass tags !ruby/array, !seq and !ruby/string carry the same exposure just fixed for the hash tags: the loader allocated the named class and replaced its contents without checking the class was actually an Array or String subclass. Apply the same subclass check so a permitted unrelated class can no longer be allocated and populated through these tags. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/psych/visitors/to_ruby.rb | 8 ++++---- test/psych/test_array.rb | 12 ++++++++++++ test/psych/test_string.rb | 12 ++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/lib/psych/visitors/to_ruby.rb b/lib/psych/visitors/to_ruby.rb index e9e62e13..79565dfc 100644 --- a/lib/psych/visitors/to_ruby.rb +++ b/lib/psych/visitors/to_ruby.rb @@ -72,7 +72,7 @@ def deserialize o when '!binary', 'tag:yaml.org,2002:binary' o.value.unpack('m').first when /^!(?:str|ruby\/string)(?::(.*))?$/, 'tag:yaml.org,2002:str' - klass = resolve_class($1) + klass = resolve_subclass($1, String) if klass klass.allocate.replace o.value else @@ -157,7 +157,7 @@ def visit_Psych_Nodes_Sequence o } map when /^!(?:seq|ruby\/array):(.*)$/ - klass = resolve_class($1) + klass = resolve_subclass($1, Array) list = register(o, klass.allocate) o.children.each { |c| list.push accept c } list @@ -247,7 +247,7 @@ def visit_Psych_Nodes_Mapping o end when /^!(?:str|ruby\/string)(?::(.*))?$/, 'tag:yaml.org,2002:str' - klass = resolve_class($1) + klass = resolve_subclass($1, String) members = {} string = nil @@ -268,7 +268,7 @@ def visit_Psych_Nodes_Mapping o end init_with(string, members.map { |k,v| [k.to_s.sub(/^@/, ''),v] }, o) when /^!ruby\/array:(.*)$/ - klass = resolve_class($1) + klass = resolve_subclass($1, Array) list = register(o, klass.allocate) members = Hash[o.children.map { |c| accept c }.each_slice(2).to_a] diff --git a/test/psych/test_array.rb b/test/psych/test_array.rb index 0dc82439..064ace46 100644 --- a/test/psych/test_array.rb +++ b/test/psych/test_array.rb @@ -52,6 +52,18 @@ def test_backwards_with_syck assert_equal X, x.class end + class NotAnArray + end + + def test_seq_tag_rejects_non_array_class + assert_raise(ArgumentError) do + Psych.unsafe_load "--- !seq:#{NotAnArray} []\n" + end + assert_raise(ArgumentError) do + Psych.unsafe_load "--- !ruby/array:#{NotAnArray} []\n" + end + end + def test_self_referential @list << @list assert_cycle(@list) diff --git a/test/psych/test_string.rb b/test/psych/test_string.rb index cfd235a5..73fb3933 100644 --- a/test/psych/test_string.rb +++ b/test/psych/test_string.rb @@ -182,6 +182,18 @@ def test_subclass_with_attributes assert_equal 1, y.val end + class NotAString + end + + def test_string_tag_rejects_non_string_class + assert_raise(ArgumentError) do + Psych.unsafe_load "--- !ruby/string:#{NotAString} foo\n" + end + assert_raise(ArgumentError) do + Psych.unsafe_load "--- !ruby/string:#{NotAString}\nstr: foo\n" + end + end + def test_string_with_base_60 yaml = Psych.dump '01:03:05' assert_match "'01:03:05'", yaml