Class: Marshalsea::Marshal::LoadGuard

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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

#observationsObject (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

Returns:

  • (Boolean)


90
91
92
# File 'lib/marshalsea/marshal/load_guard.rb', line 90

def watches?(hook)
  hooks.include?(hook)
end