Skip to content

Commit

Permalink
Merge pull request #495 from Shopify/safe-dump
Browse files Browse the repository at this point in the history
Implement YAML.safe_dump to make safe_load more usable.
  • Loading branch information
tenderlove authored May 21, 2021
2 parents 38e4b51 + 4419583 commit 5d8b7fb
Show file tree
Hide file tree
Showing 5 changed files with 182 additions and 5 deletions.
76 changes: 75 additions & 1 deletion lib/psych.rb
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,8 @@ class << self; alias :load :unsafe_load; end
# * TrueClass
# * FalseClass
# * NilClass
# * Numeric
# * Integer
# * Float
# * String
# * Array
# * Hash
Expand Down Expand Up @@ -512,6 +513,79 @@ def self.dump o, io = nil, options = {}
visitor.tree.yaml io, options
end

###
# call-seq:
# Psych.safe_dump(o) -> string of yaml
# Psych.safe_dump(o, options) -> string of yaml
# Psych.safe_dump(o, io) -> io object passed in
# Psych.safe_dump(o, io, options) -> io object passed in
#
# Safely dump Ruby object +o+ to a YAML string. Optional +options+ may be passed in
# to control the output format. If an IO object is passed in, the YAML will
# be dumped to that IO object. By default, only the following
# classes are allowed to be serialized:
#
# * TrueClass
# * FalseClass
# * NilClass
# * Integer
# * Float
# * String
# * Array
# * Hash
#
# Arbitrary classes can be allowed by adding those classes to the +permitted_classes+
# keyword argument. They are additive. For example, to allow Date serialization:
#
# Psych.safe_dump(yaml, permitted_classes: [Date])
#
# Now the Date class can be dumped in addition to the classes listed above.
#
# A Psych::DisallowedClass exception will be raised if the object contains a
# class that isn't in the +permitted_classes+ list.
#
# Currently supported options are:
#
# [<tt>:indentation</tt>] Number of space characters used to indent.
# Acceptable value should be in <tt>0..9</tt> range,
# otherwise option is ignored.
#
# Default: <tt>2</tt>.
# [<tt>:line_width</tt>] Max character to wrap line at.
#
# Default: <tt>0</tt> (meaning "wrap at 81").
# [<tt>:canonical</tt>] Write "canonical" YAML form (very verbose, yet
# strictly formal).
#
# Default: <tt>false</tt>.
# [<tt>:header</tt>] Write <tt>%YAML [version]</tt> at the beginning of document.
#
# Default: <tt>false</tt>.
#
# Example:
#
# # Dump an array, get back a YAML string
# Psych.safe_dump(['a', 'b']) # => "---\n- a\n- b\n"
#
# # Dump an array to an IO object
# Psych.safe_dump(['a', 'b'], StringIO.new) # => #<StringIO:0x000001009d0890>
#
# # Dump an array with indentation set
# Psych.safe_dump(['a', ['b']], indentation: 3) # => "---\n- a\n- - b\n"
#
# # Dump an array to an IO with indentation set
# Psych.safe_dump(['a', ['b']], StringIO.new, indentation: 3)
def self.safe_dump o, io = nil, options = {}
if Hash === io
options = io
io = nil
end

visitor = Psych::Visitors::RestrictedYAMLTree.create options
visitor << o
visitor.tree.yaml io, options
end

###
# Dump a list of objects as separate documents to a document stream.
#
Expand Down
4 changes: 2 additions & 2 deletions lib/psych/class_loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def symbolize sym
if @symbols.include? sym
super
else
raise DisallowedClass, 'Symbol'
raise DisallowedClass.new('load', 'Symbol')
end
end

Expand All @@ -96,7 +96,7 @@ def find klassname
if @classes.include? klassname
super
else
raise DisallowedClass, klassname
raise DisallowedClass.new('load', klassname)
end
end
end
Expand Down
4 changes: 2 additions & 2 deletions lib/psych/exception.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ class BadAlias < Exception
end

class DisallowedClass < Exception
def initialize klass_name
super "Tried to load unspecified class: #{klass_name}"
def initialize action, klass_name
super "Tried to #{action} unspecified class: #{klass_name}"
end
end
end
46 changes: 46 additions & 0 deletions lib/psych/visitors/yaml_tree.rb
Original file line number Diff line number Diff line change
Expand Up @@ -535,5 +535,51 @@ def dump_ivars target
end
end
end

class RestrictedYAMLTree < YAMLTree
DEFAULT_PERMITTED_CLASSES = {
TrueClass => true,
FalseClass => true,
NilClass => true,
Integer => true,
Float => true,
String => true,
Array => true,
Hash => true,
}.compare_by_identity.freeze

def initialize emitter, ss, options
super
@permitted_classes = DEFAULT_PERMITTED_CLASSES.dup
Array(options[:permitted_classes]).each do |klass|
@permitted_classes[klass] = true
end
@permitted_symbols = {}.compare_by_identity
Array(options[:permitted_symbols]).each do |symbol|
@permitted_symbols[symbol] = true
end
@aliases = options.fetch(:aliases, false)
end

def accept target
if !@aliases && @st.key?(target)
raise BadAlias, "Tried to dump an aliased object"
end

unless @permitted_classes[target.class]
raise DisallowedClass.new('dump', target.class.name || target.class.inspect)
end

super
end

def visit_Symbol sym
unless @permitted_symbols[sym]
raise DisallowedClass.new('dump', "Symbol(#{sym.inspect})")
end

super
end
end
end
end
57 changes: 57 additions & 0 deletions test/psych/test_psych.rb
Original file line number Diff line number Diff line change
Expand Up @@ -381,4 +381,61 @@ def test_symbolize_names
result = Psych.safe_load(yaml, symbolize_names: true)
assert_equal result, { foo: { bar: "baz", 1 => 2 }, hoge: [{ fuga: "piyo" }] }
end

def test_safe_dump_defaults
yaml = <<-eoyml
---
array:
- 1
float: 13.12
booleans:
- true
- false
eoyml

payload = YAML.safe_dump({
"array" => [1],
"float" => 13.12,
"booleans" => [true, false],
})
assert_equal yaml, payload
end

def test_safe_dump_unpermitted_class
error = assert_raises Psych::DisallowedClass do
YAML.safe_dump(Object.new)
end
assert_equal "Tried to dump unspecified class: Object", error.message

hash_subclass = Class.new(Hash)
error = assert_raises Psych::DisallowedClass do
YAML.safe_dump(hash_subclass.new)
end
assert_equal "Tried to dump unspecified class: #{hash_subclass.inspect}", error.message
end

def test_safe_dump_extra_permitted_classes
assert_equal "--- !ruby/object {}\n", YAML.safe_dump(Object.new, permitted_classes: [Object])
end

def test_safe_dump_symbols
error = assert_raises Psych::DisallowedClass do
YAML.safe_dump(:foo, permitted_classes: [Symbol])
end
assert_equal "Tried to dump unspecified class: Symbol(:foo)", error.message

assert_equal "--- :foo\n", YAML.safe_dump(:foo, permitted_classes: [Symbol], permitted_symbols: [:foo])
end

def test_safe_dump_aliases
x = []
x << x
error = assert_raises Psych::BadAlias do
YAML.safe_dump(x)
end
assert_equal "Tried to dump an aliased object", error.message

assert_equal "--- &1\n" + "- *1\n", YAML.safe_dump(x, aliases: true)
end

end

0 comments on commit 5d8b7fb

Please sign in to comment.