Class: Marshalsea::Marshal::LoadGuard
- Inherits:
-
Object
- Object
- Marshalsea::Marshal::LoadGuard
- Defined in:
- lib/marshalsea/marshal/load_guard.rb
Defined Under Namespace
Classes: Observation
Constant Summary collapse
- GATED_HOOKS =
%i[marshal_load _load _load_data].freeze
- DISPATCH_HOOKS =
%i[method_missing respond_to_missing?].freeze
- KEY_HOOKS =
%i[hash eql?].freeze
- DEFAULT_HOOKS =
(GATED_HOOKS + DISPATCH_HOOKS).freeze
- STRICT_HOOKS =
(DEFAULT_HOOKS + KEY_HOOKS).freeze
- EVENTS =
%i[call c_call].freeze
- REASON =
"deserialization hook %s#%s is not permitted"- ANONYMOUS_OWNER =
"(class with no name)"- LIMITATION_NOTICE =
<<~NOTICE SECURITY LIMITATION Marshalsea::Marshal::LoadGuard vetoes a deserialization hook before its body runs, which is the thing a Marshal.load allowlist proc cannot do. It is defense in depth and a tripwire. It is not a boundary, and it never makes Marshal.load on untrusted input safe. It covers the load window only. A class carrying no hook at all is instantiated freely and fires whenever the application later touches it, which is outside any window this guard can see. With the default hook set it does not watch #hash or #eql?. Rebuilding a Hash rehashes its keys, so a key object's #hash runs inside Marshal.load with no deserialization hook involved. Pass strict: true to watch those two as well, and accept that they are among the hottest methods in Ruby: the cost and the false-positive profile both change completely. Marshalsea::Marshal::BoundaryDetector catches that same shape before any bytes are loaded, which is the cheaper place to catch it. The guard is thread-scoped. A load on another thread is not covered. Its cost is not a multiplier. Enabling a TracePoint costs roughly 46 microseconds per load on a stock ruby:4.0-slim, near enough constant, so the ratio is decided by how much work the load itself does. Measured 2026-07-30: 40x on a 45-byte session cookie, 21x on a 142-byte session, 1.1x on a 46 KB document, 1.0x on a 488 KB one. Guarding a large payload is close to free. Guarding a session cookie on every request is not, and a cookie is exactly what this lab deserializes. NOTICE
Instance Attribute Summary collapse
-
#observations ⇒ Object
readonly
Returns the value of attribute observations.
Instance Method Summary collapse
-
#initialize(permitted_class_names: [], strict: false) ⇒ LoadGuard
constructor
A new instance of LoadGuard.
- #load(blob) ⇒ Object
- #watches?(hook) ⇒ Boolean
Constructor Details
#initialize(permitted_class_names: [], strict: false) ⇒ LoadGuard
Returns a new instance of LoadGuard.
72 73 74 75 76 |
# File 'lib/marshalsea/marshal/load_guard.rb', line 72 def initialize(permitted_class_names: [], strict: false) @permitted_class_names = permitted_class_names.map(&:to_s).freeze @hooks = strict ? STRICT_HOOKS : DEFAULT_HOOKS @observations = [].freeze end |
Instance Attribute Details
#observations ⇒ Object (readonly)
Returns the value of attribute observations.
70 71 72 |
# File 'lib/marshalsea/marshal/load_guard.rb', line 70 def observations @observations end |
Instance Method Details
#load(blob) ⇒ Object
78 79 80 81 82 83 84 85 86 87 88 |
# File 'lib/marshalsea/marshal/load_guard.rb', line 78 def load(blob) seen = [] tracer = TracePoint.new(*EVENTS) { |event| inspect_event(event, seen) } result = nil begin tracer.enable { result = ::Marshal.load(blob) } ensure @observations = seen.freeze end result end |
#watches?(hook) ⇒ Boolean
90 91 92 |
# File 'lib/marshalsea/marshal/load_guard.rb', line 90 def watches?(hook) hooks.include?(hook) end |