Class: Marshalsea::Marshal::BoundaryDetector

Inherits:
Object
  • Object
show all
Defined in:
lib/marshalsea/marshal/boundary_detector.rb

Defined Under Namespace

Classes: Decision

Constant Summary collapse

POLICY_STRICT_ALLOWLIST =
:strict_allowlist
POLICY_DENY_SINKS_ONLY =
:deny_sinks_only
POLICY_OBSERVE_AND_LOG =
:observe_and_log
POLICIES =
[POLICY_STRICT_ALLOWLIST, POLICY_DENY_SINKS_ONLY, POLICY_OBSERVE_AND_LOG].freeze
REASON_INPUT_TYPE =
"input is not a String"
REASON_MALFORMED =
"stream is not canonical Marshal: %s"
REASON_SINK =
"stream reaches %s#%s during load, before any allowlist can run"
REASON_UNAPPROVED =
"stream references unapproved class %s"
REASON_ROLE_ANOMALY =
"stream is not canonical Marshal: %s, so Marshal.load refuses it " \
"and there is nothing here to permit"
REASON_KEY_HASH =
"stream puts %s in a hash key, so its #hash runs during load, before " \
"any allowlist can act"
REASON_KEY_EQL =
"stream puts %s in a hash key, so its #eql? runs during load as soon as " \
"two keys collide, before any allowlist can act"
REASON_RANGE_ENDPOINT =
"stream puts %s in a Range endpoint, so its #<=> runs during " \
"load, before any allowlist can act"
REASON_NONCANONICAL_VERSION =
"stream declares Marshal %d.%d; every Ruby that can produce " \
"this format emits %d.%d"
REASON_MAX_NAME_BYTES =
96
REASON_MAX_NAMES =
8
REASON_TRUNCATED_MARKER =
"[truncated"
REASON_TRUNCATED =
"#{REASON_TRUNCATED_MARKER}, +%d bytes]".freeze
REASON_ELIDED_NAMES =
", and %d more"
REASON_NAME_SEPARATOR =
", "
LIMITATION_NOTICE =
<<~NOTICE
  SECURITY LIMITATION

  Marshalsea::Marshal::BoundaryDetector examines a bounded snapshot of Marshal bytes and
  applies a caller-selected policy before deserialization. An ACCEPT decision means
  only that this snapshot matched that policy.

  Acceptance does not make the payload safe, trusted, authenticated, or free of
  gadget behavior. This detector does not sandbox Ruby, audit the current
  implementations of allowlisted classes, freeze the runtime class graph, or prevent
  callbacks and implicit method dispatch that its parser or policy fails to model.
  Class allowlisting compares serialized names. It does not prove that the
  corresponding Ruby code is harmless.

  A payload carrying no sink tag can still reach dangerous code. The published
  CVE-2026-41316 chain produces zero sink tags because ERB defines no marshal_load.
  It is caught by class allowlisting alone, and an application that allowlists ERB
  will accept it.
NOTICE

Instance Method Summary collapse

Constructor Details

#initialize(policy: POLICY_STRICT_ALLOWLIST, allowed_class_names: [], limits: Limits.new, reporter: nil) ⇒ BoundaryDetector

Returns a new instance of BoundaryDetector.

Raises:

  • (ArgumentError)


92
93
94
95
96
97
98
99
100
101
# File 'lib/marshalsea/marshal/boundary_detector.rb', line 92

def initialize(policy: POLICY_STRICT_ALLOWLIST, allowed_class_names: [], limits: Limits.new, reporter: nil)
  raise ArgumentError, "unknown policy #{policy}" unless POLICIES.include?(policy)
  raise ReporterRequiredError, "#{POLICY_OBSERVE_AND_LOG} requires a reporter" if
    policy == POLICY_OBSERVE_AND_LOG && reporter.nil?

  @policy = policy
  @allowed_class_names = allowed_class_names.map(&:to_s).freeze
  @limits = limits
  @reporter = reporter
end

Instance Method Details

#inspect_stream(input) ⇒ Object



103
104
105
106
107
108
109
110
111
# File 'lib/marshalsea/marshal/boundary_detector.rb', line 103

def inspect_stream(input)
  return reject(REASON_INPUT_TYPE) unless input.is_a?(String)

  snapshot = input.dup.force_encoding(Encoding::BINARY).freeze
  result = Parser.new(snapshot, limits: limits).parse
  evaluate(result, snapshot)
rescue StreamError => e
  reject(format(REASON_MALFORMED, e.class.name.split("::").last))
end