Repository navigation
YAML.safe_load instantiates arbitrary classes; permitted_classes: [] permits everything and Psych::DisallowedClass is never raised #775
Description
Summary
YAML.safe_load instantiates arbitrary classes from the document. The two spellings anyone writes to mean "deny everything" both permit everything, and a non-empty allow-list silently degrades a disallowed object into a Hash instead of raising.
Psych::DisallowedClass exists as a constant and is raised nowhere, so rescue Psych::DisallowedClass can never fire.
This is the most serious finding of the security audit tracked in #768, because unlike the rest of that audit it is about data, not source. A host that accepts untrusted YAML and calls safe_load — the whole point of which is that it is safe to do that — gets arbitrary class instantiation with attacker-set instance variables.
Measurement
require "yaml"
class Gadget; attr_accessor :foo; end
class Allowed; end
y = "--- !ruby/object:Gadget\nfoo: bar\n"| call | rbgo | MRI 4.0.5 |
|---|---|---|
YAML.safe_load(y) |
#<Gadget:0x…> |
Psych::DisallowedClass |
YAML.safe_load(y, permitted_classes: []) |
#<Gadget:0x…> |
Psych::DisallowedClass |
YAML.safe_load(y, permitted_classes: [Allowed]) |
{foo: "bar"} |
Psych::DisallowedClass |
YAML.safe_load(y, permitted_classes: [Gadget]) |
#<Gadget:0x…> |
#<Gadget @foo="bar"> |
YAML.safe_load(":foo") |
:foo |
Psych::DisallowedClass |
defined?(Psych::DisallowedClass) |
"constant" |
"constant" |
Row 2 is the one to look at twice: permitted_classes: [] is the most restrictive setting expressible, and it permits everything. Row 3 is the second-worst: with a working allow-list the caller gets a Hash where it asked for a refusal, so the failure arrives later as type confusion rather than immediately as an exception.
Row 5 means unbounded symbol creation from a document, which MRI also refuses by default.
Why, as far as I have read
Two independent causes, either of which alone would produce row 2:
internal/vm/yaml.go— the kwarg reader returnsnilwhenpermitted_classesis absent, commented as permitting all classes. MRI's default permitted set is[TrueClass, FalseClass, NilClass, Integer, Float, String, Array, Hash], not "everything".go-ruby-yaml/yaml.go—WithPermittedClasses(names ...string)doesappend(o.permittedClasses, names...). With zero names that appends nothing and leaves the slicenil, and the later guard isif o.permittedClasses != nil, so the restriction is skipped entirely. An empty allow-list and an absent one are indistinguishable once they are bothnil.
The design assumption is stated in the source — "the loader is already safe by construction, so the allow-list is the only observable difference from load". The measurement above is the counter-example: the loader is not safe by construction with respect to !ruby/object:.
Mitigating, and worth stating so the severity is not overstated: the YAML path does not call init_with or marshal_load. I checked for a gadget whose init_with prints, and it did not fire. So this is arbitrary class instantiation with attacker-controlled ivars, not direct method dispatch during load. It is the classic starting position for a gadget chain rather than immediate code execution.
Suggested shape
- Make the distinction between "no allow-list given" and "an empty allow-list" representable — a
*[]string, or an explicitrestrict boolalongside the slice. The currentnilconflation is the whole defect. - Default
safe_loadto MRI's permitted set rather than to everything. - Raise
Psych::DisallowedClassinstead of degrading to aHash, so the constant stops being decorative andrescueworks. - Refuse
Symbolunlessaliases/permitted_symbolssay otherwise, as MRI does.
A test worth having regardless of the fix: assert that safe_load with an empty allow-list raises, since that is the case both causes above get wrong and the one a reader is least likely to try by hand.
🤖 Generated with Claude Code
Activity
fix: SafeLoad must refuse, and "deny everything" must be expressible (#…
fix(yaml): safe_load must refuse unpermitted classes (#775) (#786)
fix(yaml): load and load_file are restricted, as Psych 4 made them (#791