Class: Marshalsea::Marshal::BoundaryDetector
- Inherits:
-
Object
- Object
- Marshalsea::Marshal::BoundaryDetector
- 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
-
#initialize(policy: POLICY_STRICT_ALLOWLIST, allowed_class_names: [], limits: Limits.new, reporter: nil) ⇒ BoundaryDetector
constructor
A new instance of BoundaryDetector.
- #inspect_stream(input) ⇒ Object
Constructor Details
#initialize(policy: POLICY_STRICT_ALLOWLIST, allowed_class_names: [], limits: Limits.new, reporter: nil) ⇒ BoundaryDetector
Returns a new instance of BoundaryDetector.
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 |