YAML.safe_load and Safe Serialization
YAML is Ruby’s go-to format for configuration files, data exchange, and serialization. Rails uses it, Bundler uses it, Docker Compose uses it. The standard library YAML module handles parsing and generating YAML. But loading YAML from the wrong source can hand an attacker complete control of your server. This guide covers how safe_load works, what it protects against, and how to use it correctly.
Why YAML.load Is Dangerous
YAML has tags that represent Ruby objects. The !ruby/object tag tells the parser to instantiate any Ruby class:
malicious_yaml = <<~YAML
--- !ruby/object:OpenWithShellExpansion
cmd: "echo pwned > /tmp/pwned.txt"
YAML
YAML.load(malicious_yaml)
# Runs the shell command. You've been owned.
YAML.load can instantiate any Ruby object, not just “safe” data types. This is a well-known deserialization vulnerability class. Real-world exploits have compromised servers through YAML loading. If an attacker can feed a YAML string into your application, they can execute arbitrary code.
YAML.safe_load: The Safe Alternative
YAML.safe_load restricts which classes can be deserialized. It raises Psych::DisallowedClass for any tag not on an explicit whitelist.
YAML.safe_load(malicious_yaml)
# => Psych::DisallowedClass: Tried to load unspecified class: OpenWithShellExpansion
The attacker’s payload is neutralized — the program crashes safely instead of running their code.
Basic types are always allowed regardless of permitted_classes: String, Integer, Float, TrueClass, FalseClass, NilClass, Array, Hash.
The safe_load API
YAML.safe_load(yaml, permitted_classes: [], permitted_symbols: [], aliases: false, date: nil, permitted_regexps: [])
All parameters are keyword arguments.
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
permitted_classes | Array of Class/String | [] (Ruby 3.1+), [Symbol, Date] (Ruby 3.0) | Extra classes to allow |
permitted_symbols | Array of Symbol | [] | Bare symbols to allow |
aliases | Boolean | false (Ruby 3.1+), true (Ruby 3.0) | Whether to resolve &anchor / *alias |
date | Class or nil | nil (Ruby 3.1+), Date (Ruby 3.0) | Class to parse bare dates as |
permitted_regexps | Array of Regexp | [] (Ruby 3.2+) | Allowed !ruby/regexp tags |
permitted_classes: Whitelisting Safe Classes
You often need to load custom classes you serialized yourself. Use permitted_classes to explicitly allow them:
yaml_with_dates = <<~YAML
created: 2023-01-15
updated: 2023-06-20
YAML
# Without permission, dates come back as strings
YAML.safe_load(yaml_with_dates)
# => {"created"=>"2023-01-15", "updated"=>"2023-06-20"}
# With Date permitted, they become Date objects
YAML.safe_load(yaml_with_dates, permitted_classes: [Date])
# => {"created"=>#<Date: 2023-01-15>, "updated"=>#<Date: 2023-06-20>}
Common values for permitted_classes:
| Class | Use case |
|---|---|
Date | Config files with date values |
Time | Timestamps |
Symbol | Hash keys serialized as symbols |
Regexp | Ruby 3.2+: !ruby/regexp tag |
Never add classes you don’t need. Each permitted class is a potential attack surface.
Ruby 3.1: The Breaking Change
Ruby 3.1 changed all safe_load defaults to be more restrictive. This broke many applications on upgrade.
What changed:
permitted_classesdefault changed from[Symbol, Date]to[]— no custom classes allowed by defaultaliasesdefault changed fromtruetofalse— YAML aliases must be explicitly enableddatedefault changed fromDatetonil— bare dates return strings, not Date objects
# Ruby 3.0 — Date allowed automatically
YAML.safe_load("--- 2023-01-15")
# => #<Date: 2023-01-15>
# Ruby 3.1+ — Date requires explicit permission
YAML.safe_load("--- 2023-01-15", permitted_classes: [Date])
# => #<Date: 2023-01-15>
If you’re upgrading to Ruby 3.1+ and seeing Psych::DisallowedClass errors on Date objects, add permitted_classes: [Date] to your calls.
Aliases and DoS Protection
YAML aliases let you reference the same object without duplicating it:
yaml_alias = <<~YAML
defaults: &defaults
timeout: 30
retries: 3
production:
<<: *defaults
timeout: 60
YAML
Aliases look useful, but they enable DoS attacks. Attackers can craft YAML with exponential alias expansion, causing the parser to consume huge amounts of CPU and memory:
# Malicious YAML that creates exponential parsing time
# (simplified example — real exploits are more sophisticated)
evil = "--- &a [" * 1000 + "]" * 1000
YAML.safe_load(evil, aliases: true) # Could hang your server
Ruby 3.1+ disables aliases by default. If you need them, explicitly enable them only for trusted YAML:
YAML.safe_load(trusted_yaml, aliases: true)
For any untrusted source, keep aliases disabled.
Common Errors
Psych::DisallowedClass
Raised when the YAML contains a tag for a class not in permitted_classes:
YAML.safe_load("--- !ruby/object:OpenWithShellExpansion\n cmd: id")
# => Psych::DisallowedClass: Tried to load unspecified class: OpenWithShellExpansion
Fix: either remove the untrusted YAML source, or add the class to permitted_classes if you trust it.
Symbol Handling in Ruby 3.1+
Ruby 3.1+ doesn’t allow bare symbols by default. If your YAML has symbol keys and you’re upgrading from Ruby 3.0:
# Symbol keys as hash values work fine (strings are used)
YAML.safe_load("{ foo: 1, bar: 2 }")
# => {"foo"=>1, "bar"=>2}
# But !ruby/symbol tag needs explicit permission
YAML.safe_load("--- !ruby/symbol\nfoo", permitted_symbols: [:foo])
# => {:foo}
Practical Patterns
Loading Application Config
require 'yaml'
def load_config(path)
yaml_content = File.read(path)
YAML.safe_load(yaml_content, permitted_classes: [Date, Symbol])
end
config = load_config('/etc/myapp/config.yml')
Handling Untrusted User YAML
Always use safe_load for user-submitted YAML. Catch and handle the error:
def parse_user_yaml(raw_yaml)
YAML.safe_load(raw_yaml, permitted_classes: [Date])
rescue Psych::DisallowedClass => e
Rails.logger.warn "Blocked disallowed YAML class: #{e.class_name}"
nil
end
Inspecting YAML Before Loading
Debug what tags are in a YAML string without executing it:
require 'yaml'
def yaml_tags(yaml_string)
YAML.parse(yaml_string).children.flat_map do |node|
tag = node.tag&.sub('tag:yaml.org,2002:', '')
[tag] + (node.children.map { |c| c.tag&.sub('tag:yaml.org,2002:', '') }.compact if node.children?)
end.flatten.compact.uniq
end
yaml_tags("--- !ruby/object:Time\nnow: 2023")
# => ["ruby/object", "ruby/object:Time"]
If ruby/object appears in the output and you didn’t expect it — don’t load that YAML.
Related Methods
| Method | Safe? | Notes |
|---|---|---|
YAML.load(yaml) | No | Can instantiate any Ruby object. Never use with untrusted input. |
YAML.unsafe_load(yaml) | No | Ruby 3.1+ alias for YAML.load. |
YAML.dump(object) | Yes | Serializes a Ruby object to YAML string. |
YAML.safe_load(yaml) | Yes | Use for everything. |
Psych.safe_load | Yes | Equivalent to YAML.safe_load. |
See Also
- /reference/modules/yaml — The YAML stdlib module reference
- /guides/ruby-struct-guide — Struct objects and YAML serialization
- /guides/ruby-pattern-matching — Advanced Ruby pattern matching