marshalsea 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ # ©AngelaMos | 2026
2
+ # boundary_detector.rb
3
+ # frozen_string_literal: true
4
+
5
+ module Marshalsea
6
+ module Marshal
7
+ class ReporterRequiredError < StandardError; end
8
+
9
+ class BoundaryDetector
10
+ POLICY_STRICT_ALLOWLIST = :strict_allowlist
11
+ POLICY_DENY_SINKS_ONLY = :deny_sinks_only
12
+ POLICY_OBSERVE_AND_LOG = :observe_and_log
13
+
14
+ POLICIES = [POLICY_STRICT_ALLOWLIST, POLICY_DENY_SINKS_ONLY, POLICY_OBSERVE_AND_LOG].freeze
15
+
16
+ REASON_INPUT_TYPE = "input is not a String"
17
+ REASON_MALFORMED = "stream is not canonical Marshal: %s"
18
+ REASON_SINK = "stream reaches %s#%s during load, before any allowlist can run"
19
+ REASON_UNAPPROVED = "stream references unapproved class %s"
20
+ REASON_ROLE_ANOMALY = "stream is not canonical Marshal: %s, so Marshal.load refuses it " \
21
+ "and there is nothing here to permit"
22
+ REASON_KEY_HASH = "stream puts %s in a hash key, so its #hash runs during load, before " \
23
+ "any allowlist can act"
24
+ REASON_KEY_EQL = "stream puts %s in a hash key, so its #eql? runs during load as soon as " \
25
+ "two keys collide, before any allowlist can act"
26
+ REASON_RANGE_ENDPOINT = "stream puts %s in a Range endpoint, so its #<=> runs during " \
27
+ "load, before any allowlist can act"
28
+ REASON_NONCANONICAL_VERSION = "stream declares Marshal %d.%d; every Ruby that can produce " \
29
+ "this format emits %d.%d"
30
+
31
+ REASON_MAX_NAME_BYTES = 96
32
+ REASON_MAX_NAMES = 8
33
+ REASON_TRUNCATED_MARKER = "[truncated"
34
+ REASON_TRUNCATED = "#{REASON_TRUNCATED_MARKER}, +%d bytes]".freeze
35
+ REASON_ELIDED_NAMES = ", and %d more"
36
+ REASON_NAME_SEPARATOR = ", "
37
+
38
+ LIMITATION_NOTICE = <<~NOTICE
39
+ SECURITY LIMITATION
40
+
41
+ Marshalsea::Marshal::BoundaryDetector examines a bounded snapshot of Marshal bytes and
42
+ applies a caller-selected policy before deserialization. An ACCEPT decision means
43
+ only that this snapshot matched that policy.
44
+
45
+ Acceptance does not make the payload safe, trusted, authenticated, or free of
46
+ gadget behavior. This detector does not sandbox Ruby, audit the current
47
+ implementations of allowlisted classes, freeze the runtime class graph, or prevent
48
+ callbacks and implicit method dispatch that its parser or policy fails to model.
49
+ Class allowlisting compares serialized names. It does not prove that the
50
+ corresponding Ruby code is harmless.
51
+
52
+ A payload carrying no sink tag can still reach dangerous code. The published
53
+ CVE-2026-41316 chain produces zero sink tags because ERB defines no marshal_load.
54
+ It is caught by class allowlisting alone, and an application that allowlists ERB
55
+ will accept it.
56
+ NOTICE
57
+
58
+ class Decision
59
+ STATE_PROCEED = :proceed
60
+ STATE_BLOCKED = :blocked
61
+ STATE_OBSERVED = :observed
62
+
63
+ STATES = [STATE_PROCEED, STATE_BLOCKED, STATE_OBSERVED].freeze
64
+ STATE_PREDICATES = %i[proceed? blocked? observed?].freeze
65
+
66
+ UNKNOWN_STATE = "unknown decision state %p, expected one of %s"
67
+
68
+ attr_reader :state, :reason, :snapshot, :result
69
+
70
+ def initialize(state:, reason: nil, snapshot: nil, result: nil)
71
+ raise ArgumentError, format(UNKNOWN_STATE, state, STATES.join(", ")) unless STATES.include?(state)
72
+
73
+ @state = state
74
+ @reason = reason
75
+ @snapshot = snapshot
76
+ @result = result
77
+ end
78
+
79
+ def proceed?
80
+ state == STATE_PROCEED
81
+ end
82
+
83
+ def blocked?
84
+ state == STATE_BLOCKED
85
+ end
86
+
87
+ def observed?
88
+ state == STATE_OBSERVED
89
+ end
90
+ end
91
+
92
+ def initialize(policy: POLICY_STRICT_ALLOWLIST, allowed_class_names: [], limits: Limits.new, reporter: nil)
93
+ raise ArgumentError, "unknown policy #{policy}" unless POLICIES.include?(policy)
94
+ raise ReporterRequiredError, "#{POLICY_OBSERVE_AND_LOG} requires a reporter" if
95
+ policy == POLICY_OBSERVE_AND_LOG && reporter.nil?
96
+
97
+ @policy = policy
98
+ @allowed_class_names = allowed_class_names.map(&:to_s).freeze
99
+ @limits = limits
100
+ @reporter = reporter
101
+ end
102
+
103
+ def inspect_stream(input)
104
+ return reject(REASON_INPUT_TYPE) unless input.is_a?(String)
105
+
106
+ snapshot = input.dup.force_encoding(Encoding::BINARY).freeze
107
+ result = Parser.new(snapshot, limits: limits).parse
108
+ evaluate(result, snapshot)
109
+ rescue StreamError => e
110
+ reject(format(REASON_MALFORMED, e.class.name.split("::").last))
111
+ end
112
+
113
+ private
114
+
115
+ attr_reader :policy, :allowed_class_names, :limits, :reporter
116
+
117
+ def evaluate(result, snapshot)
118
+ violation = violation_for(result)
119
+ return accept(snapshot, result) unless violation
120
+
121
+ return observe(violation, snapshot, result) if policy == POLICY_OBSERVE_AND_LOG
122
+
123
+ reject(violation)
124
+ end
125
+
126
+ def quoted(name)
127
+ raw = name.to_s.dup.force_encoding(Encoding::BINARY)
128
+ return raw.inspect if raw.bytesize <= REASON_MAX_NAME_BYTES
129
+
130
+ "#{raw.byteslice(0, REASON_MAX_NAME_BYTES).inspect}" \
131
+ "#{format(REASON_TRUNCATED, raw.bytesize - REASON_MAX_NAME_BYTES)}"
132
+ end
133
+
134
+ def quoted_list(names)
135
+ shown = names.first(REASON_MAX_NAMES).map { |name| quoted(name) }.join(REASON_NAME_SEPARATOR)
136
+ elided = names.length - REASON_MAX_NAMES
137
+ return shown unless elided.positive?
138
+
139
+ "#{shown}#{format(REASON_ELIDED_NAMES, elided)}"
140
+ end
141
+
142
+ def violation_for(result)
143
+ anomaly = result.role_anomalies.first
144
+ return format(REASON_ROLE_ANOMALY, anomaly) if anomaly
145
+
146
+ sink = result.sinks.first
147
+ return format(REASON_SINK, quoted(sink.class_name), sink.sink_method) if sink
148
+
149
+ hashed = result.hash_dispatching_keys.first
150
+ return format(REASON_KEY_HASH, quoted(hashed.effective_class_name)) if hashed
151
+
152
+ compared = result.eql_dispatching_keys.first
153
+ return format(REASON_KEY_EQL, quoted(compared.effective_class_name)) if compared
154
+
155
+ endpoint = result.range_endpoint_dispatchers.first
156
+ return format(REASON_RANGE_ENDPOINT, quoted(endpoint.effective_class_name)) if endpoint
157
+
158
+ return nil if policy == POLICY_DENY_SINKS_ONLY
159
+
160
+ unless result.canonical_version?
161
+ return format(REASON_NONCANONICAL_VERSION, result.major, result.minor,
162
+ Constants::MAJOR_VERSION, Constants::MINOR_VERSION)
163
+ end
164
+
165
+ unapproved = result.class_names.reject { |name| allowed_class_names.include?(name) }
166
+ return format(REASON_UNAPPROVED, quoted_list(unapproved)) unless unapproved.empty?
167
+
168
+ nil
169
+ end
170
+
171
+ def observe(violation, snapshot, result)
172
+ reporter.call(violation)
173
+ Decision.new(state: Decision::STATE_OBSERVED, reason: violation,
174
+ snapshot: snapshot, result: result)
175
+ end
176
+
177
+ def accept(snapshot, result)
178
+ Decision.new(state: Decision::STATE_PROCEED, snapshot: snapshot, result: result)
179
+ end
180
+
181
+ def reject(reason)
182
+ Decision.new(state: Decision::STATE_BLOCKED, reason: reason)
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,91 @@
1
+ # ©AngelaMos | 2026
2
+ # constants.rb
3
+ # frozen_string_literal: true
4
+
5
+ module Marshalsea
6
+ module Marshal
7
+ module Constants
8
+ MAJOR_VERSION = 4
9
+ MINOR_VERSION = 8
10
+ HEADER_LENGTH = 2
11
+
12
+ TAG_NIL = "0"
13
+ TAG_TRUE = "T"
14
+ TAG_FALSE = "F"
15
+ TAG_FIXNUM = "i"
16
+ TAG_BIGNUM = "l"
17
+ TAG_FLOAT = "f"
18
+ TAG_STRING = '"'
19
+ TAG_SYMBOL = ":"
20
+ TAG_SYMLINK = ";"
21
+ TAG_OBJECT_LINK = "@"
22
+ TAG_ARRAY = "["
23
+ TAG_HASH = "{"
24
+ TAG_HASH_DEFAULT = "}"
25
+ TAG_REGEXP = "/"
26
+ TAG_OBJECT = "o"
27
+ TAG_USERDEF = "u"
28
+ TAG_USERMARSHAL = "U"
29
+ TAG_USERCLASS = "C"
30
+ TAG_EXTENDED = "e"
31
+ TAG_CLASS = "c"
32
+ TAG_MODULE = "m"
33
+ TAG_MODULE_OLD = "M"
34
+ TAG_STRUCT = "S"
35
+ TAG_DATA = "d"
36
+ TAG_IVAR = "I"
37
+
38
+ NUL_BYTE = "\x00"
39
+
40
+ FLOAT_NAN = "nan"
41
+ FLOAT_INFINITY = "inf"
42
+ FLOAT_NEGATIVE_INFINITY = "-inf"
43
+
44
+ FLOAT_LEADING_SPACE = "[ \t\n\v\f\r]*"
45
+ FLOAT_HEX_PREFIX = /\A#{FLOAT_LEADING_SPACE}[+-]?0[xX](?:\h+(?:\.\h*)?|\.\h+)(?:[pP][+-]?\d+)?/
46
+ FLOAT_DECIMAL_PREFIX = /\A#{FLOAT_LEADING_SPACE}[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/
47
+
48
+ BIGNUM_SIGN_POSITIVE = "+"
49
+ BIGNUM_SIGN_NEGATIVE = "-"
50
+ BIGNUM_SIGNS = [BIGNUM_SIGN_POSITIVE, BIGNUM_SIGN_NEGATIVE].freeze
51
+ BIGNUM_WORD_BYTES = 2
52
+
53
+ FIXNUM_INLINE_OFFSET = 5
54
+ FIXNUM_MAX_INLINE = 4
55
+ FIXNUM_MIN_INLINE = -4
56
+ FIXNUM_MAX_WIDTH = 4
57
+
58
+ BYTE_SIGN_THRESHOLD = 128
59
+ BYTE_MODULUS = 256
60
+ BITS_PER_BYTE = 8
61
+
62
+ DEFAULT_MAX_DEPTH = 256
63
+
64
+ ROLE_LENGTH = "byte length"
65
+ ROLE_ARRAY = "array element"
66
+ ROLE_HASH = "hash entry"
67
+ ROLE_IVAR = "instance variable"
68
+ ROLE_STRUCT = "struct member"
69
+ ROLE_BIGNUM = "bignum word"
70
+
71
+ ROLE_ANOMALY = "%s name slot holds %s, not a symbol"
72
+
73
+ CLASS_NAME_TYPES = %i[symbol symlink].freeze
74
+
75
+ RANGE_CLASS_NAME = "Range"
76
+ RANGE_ENDPOINT_IVARS = %i[begin end].freeze
77
+
78
+ REGISTERED_WRAPPER_TYPES = %i[data].freeze
79
+
80
+ SINK_TAGS = [TAG_USERDEF, TAG_USERMARSHAL, TAG_DATA].freeze
81
+
82
+ SINK_METHODS = {
83
+ TAG_USERDEF => "_load",
84
+ TAG_USERMARSHAL => "marshal_load",
85
+ TAG_DATA => "_load_data"
86
+ }.freeze
87
+
88
+ GATED_SINK_TAGS = [TAG_USERDEF, TAG_USERMARSHAL, TAG_DATA].freeze
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,29 @@
1
+ # ©AngelaMos | 2026
2
+ # errors.rb
3
+ # frozen_string_literal: true
4
+
5
+ module Marshalsea
6
+ module Marshal
7
+ class StreamError < StandardError; end
8
+
9
+ class TruncatedStreamError < StreamError; end
10
+
11
+ class UnsupportedVersionError < StreamError; end
12
+
13
+ class UnknownTagError < StreamError; end
14
+
15
+ class TrailingBytesError < StreamError; end
16
+
17
+ class InvalidLinkError < StreamError; end
18
+
19
+ class DepthLimitError < StreamError; end
20
+
21
+ class MalformedCountError < StreamError; end
22
+
23
+ class MalformedValueError < StreamError; end
24
+
25
+ class LimitExceededError < StreamError; end
26
+
27
+ class InputTypeError < StreamError; end
28
+ end
29
+ end
@@ -0,0 +1,40 @@
1
+ # ©AngelaMos | 2026
2
+ # float_body.rb
3
+ # frozen_string_literal: true
4
+
5
+ module Marshalsea
6
+ module Marshal
7
+ module FloatBody
8
+ module_function
9
+
10
+ def decode(body)
11
+ text = body.b
12
+ named = named_value(text)
13
+ return [named, nil] if named
14
+
15
+ token = prefix(text)
16
+ tail = text.byteslice(token.bytesize..)
17
+ [value_of(token), tail.empty? ? nil : tail]
18
+ end
19
+
20
+ def named_value(text)
21
+ case text.byteslice(0, text.index(Constants::NUL_BYTE) || text.bytesize)
22
+ when Constants::FLOAT_NAN then Float::NAN
23
+ when Constants::FLOAT_INFINITY then Float::INFINITY
24
+ when Constants::FLOAT_NEGATIVE_INFINITY then -Float::INFINITY
25
+ end
26
+ end
27
+
28
+ def prefix(text)
29
+ match = Constants::FLOAT_HEX_PREFIX.match(text) ||
30
+ Constants::FLOAT_DECIMAL_PREFIX.match(text)
31
+ match ? match[0] : ""
32
+ end
33
+
34
+ def value_of(token)
35
+ stripped = token.strip
36
+ stripped.empty? ? 0.0 : Float(stripped)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,172 @@
1
+ # ©AngelaMos | 2026
2
+ # limits.rb
3
+ # frozen_string_literal: true
4
+
5
+ module Marshalsea
6
+ module Marshal
7
+ class Limits
8
+ DEFAULT_MAX_BYTES = 1_048_576
9
+ DEFAULT_MAX_DEPTH = 64
10
+ DEFAULT_MAX_NODES = 10_000
11
+ DEFAULT_MAX_REGISTERED_OBJECTS = 4_096
12
+ DEFAULT_MAX_SYMBOL_DEFINITIONS = 256
13
+ DEFAULT_MAX_COLLECTION_ENTRIES = 1_024
14
+ DEFAULT_MAX_SCALAR_BYTES = 262_144
15
+ DEFAULT_MAX_TOTAL_SCALAR_BYTES = 524_288
16
+ DEFAULT_MAX_OBJECT_LINKS = 2_048
17
+ DEFAULT_MAX_SYMBOL_REFERENCES = 2_048
18
+ DEFAULT_MAX_SYMBOL_NAME_BYTES = 1_024
19
+ DEFAULT_MAX_CLASS_NAME_BYTES = 1_024
20
+ DEFAULT_MAX_INSTANCE_VARIABLES = 256
21
+ DEFAULT_MAX_STRUCT_MEMBERS = 256
22
+
23
+ ROLE_BYTES = "stream bytes"
24
+ ROLE_NODES = "nodes"
25
+ ROLE_REGISTERED = "registered objects"
26
+ ROLE_SYMBOLS = "symbol definitions"
27
+ ROLE_ENTRIES = "collection entries"
28
+ ROLE_SCALAR = "scalar bytes"
29
+ ROLE_TOTAL_SCALAR = "total scalar bytes"
30
+ ROLE_LINKS = "object links"
31
+ ROLE_SYMBOL_REFERENCES = "symbol references"
32
+ ROLE_SYMBOL_NAME = "symbol name bytes"
33
+ ROLE_CLASS_NAME = "class name bytes"
34
+ ROLE_INSTANCE_VARIABLES = "instance variables"
35
+ ROLE_STRUCT_MEMBERS = "struct members"
36
+
37
+ UNBOUNDED = Float::INFINITY
38
+
39
+ attr_reader :max_bytes, :max_depth, :max_nodes, :max_registered_objects,
40
+ :max_symbol_definitions, :max_collection_entries,
41
+ :max_scalar_bytes, :max_total_scalar_bytes, :max_object_links,
42
+ :max_symbol_references, :max_symbol_name_bytes, :max_class_name_bytes,
43
+ :max_instance_variables, :max_struct_members
44
+
45
+ def initialize(
46
+ max_bytes: DEFAULT_MAX_BYTES,
47
+ max_depth: DEFAULT_MAX_DEPTH,
48
+ max_nodes: DEFAULT_MAX_NODES,
49
+ max_registered_objects: DEFAULT_MAX_REGISTERED_OBJECTS,
50
+ max_symbol_definitions: DEFAULT_MAX_SYMBOL_DEFINITIONS,
51
+ max_collection_entries: DEFAULT_MAX_COLLECTION_ENTRIES,
52
+ max_scalar_bytes: DEFAULT_MAX_SCALAR_BYTES,
53
+ max_total_scalar_bytes: DEFAULT_MAX_TOTAL_SCALAR_BYTES,
54
+ max_object_links: DEFAULT_MAX_OBJECT_LINKS,
55
+ max_symbol_references: DEFAULT_MAX_SYMBOL_REFERENCES,
56
+ max_symbol_name_bytes: DEFAULT_MAX_SYMBOL_NAME_BYTES,
57
+ max_class_name_bytes: DEFAULT_MAX_CLASS_NAME_BYTES,
58
+ max_instance_variables: DEFAULT_MAX_INSTANCE_VARIABLES,
59
+ max_struct_members: DEFAULT_MAX_STRUCT_MEMBERS
60
+ )
61
+ @max_bytes = max_bytes
62
+ @max_depth = max_depth
63
+ @max_nodes = max_nodes
64
+ @max_registered_objects = max_registered_objects
65
+ @max_symbol_definitions = max_symbol_definitions
66
+ @max_collection_entries = max_collection_entries
67
+ @max_scalar_bytes = max_scalar_bytes
68
+ @max_total_scalar_bytes = max_total_scalar_bytes
69
+ @max_object_links = max_object_links
70
+ @max_symbol_references = max_symbol_references
71
+ @max_symbol_name_bytes = max_symbol_name_bytes
72
+ @max_class_name_bytes = max_class_name_bytes
73
+ @max_instance_variables = max_instance_variables
74
+ @max_struct_members = max_struct_members
75
+ end
76
+
77
+ STACK_SAFE_MAX_DEPTH = Constants::DEFAULT_MAX_DEPTH
78
+
79
+ def self.permissive
80
+ new(
81
+ max_bytes: UNBOUNDED,
82
+ max_depth: STACK_SAFE_MAX_DEPTH,
83
+ max_nodes: UNBOUNDED,
84
+ max_registered_objects: UNBOUNDED,
85
+ max_symbol_definitions: UNBOUNDED,
86
+ max_collection_entries: UNBOUNDED,
87
+ max_scalar_bytes: UNBOUNDED,
88
+ max_total_scalar_bytes: UNBOUNDED,
89
+ max_object_links: UNBOUNDED,
90
+ max_symbol_references: UNBOUNDED,
91
+ max_symbol_name_bytes: UNBOUNDED,
92
+ max_class_name_bytes: UNBOUNDED,
93
+ max_instance_variables: UNBOUNDED,
94
+ max_struct_members: UNBOUNDED
95
+ )
96
+ end
97
+ end
98
+
99
+ class Budget
100
+ def initialize(limits)
101
+ @limits = limits
102
+ @nodes = 0
103
+ @registered = 0
104
+ @symbols = 0
105
+ @links = 0
106
+ @symbol_references = 0
107
+ @scalar_total = 0
108
+ end
109
+
110
+ def node!
111
+ @nodes += 1
112
+ check(@nodes, limits.max_nodes, Limits::ROLE_NODES)
113
+ end
114
+
115
+ def registered!
116
+ @registered += 1
117
+ check(@registered, limits.max_registered_objects, Limits::ROLE_REGISTERED)
118
+ end
119
+
120
+ def symbol!
121
+ @symbols += 1
122
+ check(@symbols, limits.max_symbol_definitions, Limits::ROLE_SYMBOLS)
123
+ end
124
+
125
+ def link!
126
+ @links += 1
127
+ check(@links, limits.max_object_links, Limits::ROLE_LINKS)
128
+ end
129
+
130
+ def symbol_reference!
131
+ @symbol_references += 1
132
+ check(@symbol_references, limits.max_symbol_references, Limits::ROLE_SYMBOL_REFERENCES)
133
+ end
134
+
135
+ def symbol_name!(size)
136
+ check(size, limits.max_symbol_name_bytes, Limits::ROLE_SYMBOL_NAME)
137
+ end
138
+
139
+ def class_name!(size)
140
+ check(size, limits.max_class_name_bytes, Limits::ROLE_CLASS_NAME)
141
+ end
142
+
143
+ def instance_variables!(count)
144
+ check(count, limits.max_instance_variables, Limits::ROLE_INSTANCE_VARIABLES)
145
+ end
146
+
147
+ def struct_members!(count)
148
+ check(count, limits.max_struct_members, Limits::ROLE_STRUCT_MEMBERS)
149
+ end
150
+
151
+ def entries!(count)
152
+ check(count, limits.max_collection_entries, Limits::ROLE_ENTRIES)
153
+ end
154
+
155
+ def scalar!(size)
156
+ check(size, limits.max_scalar_bytes, Limits::ROLE_SCALAR)
157
+ @scalar_total += size
158
+ check(@scalar_total, limits.max_total_scalar_bytes, Limits::ROLE_TOTAL_SCALAR)
159
+ end
160
+
161
+ private
162
+
163
+ attr_reader :limits
164
+
165
+ def check(value, ceiling, role)
166
+ return if value <= ceiling
167
+
168
+ raise LimitExceededError, "#{role} #{value} exceeds #{ceiling}"
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,120 @@
1
+ # ©AngelaMos | 2026
2
+ # load_guard.rb
3
+ # frozen_string_literal: true
4
+
5
+ module Marshalsea
6
+ module Marshal
7
+ class GuardedLoadError < StandardError; end
8
+
9
+ class LoadGuard
10
+ GATED_HOOKS = %i[marshal_load _load _load_data].freeze
11
+ DISPATCH_HOOKS = %i[method_missing respond_to_missing?].freeze
12
+ KEY_HOOKS = %i[hash eql?].freeze
13
+
14
+ DEFAULT_HOOKS = (GATED_HOOKS + DISPATCH_HOOKS).freeze
15
+ STRICT_HOOKS = (DEFAULT_HOOKS + KEY_HOOKS).freeze
16
+
17
+ EVENTS = %i[call c_call].freeze
18
+
19
+ REASON = "deserialization hook %s#%s is not permitted"
20
+ ANONYMOUS_OWNER = "(class with no name)"
21
+
22
+ LIMITATION_NOTICE = <<~NOTICE
23
+ SECURITY LIMITATION
24
+
25
+ Marshalsea::Marshal::LoadGuard vetoes a deserialization hook before its body runs,
26
+ which is the thing a Marshal.load allowlist proc cannot do. It is defense in depth
27
+ and a tripwire. It is not a boundary, and it never makes Marshal.load on untrusted
28
+ input safe.
29
+
30
+ It covers the load window only. A class carrying no hook at all is instantiated
31
+ freely and fires whenever the application later touches it, which is outside any
32
+ window this guard can see.
33
+
34
+ With the default hook set it does not watch #hash or #eql?. Rebuilding a Hash
35
+ rehashes its keys, so a key object's #hash runs inside Marshal.load with no
36
+ deserialization hook involved. Pass strict: true to watch those two as well, and
37
+ accept that they are among the hottest methods in Ruby: the cost and the
38
+ false-positive profile both change completely. Marshalsea::Marshal::BoundaryDetector
39
+ catches that same shape before any bytes are loaded, which is the cheaper place
40
+ to catch it.
41
+
42
+ The guard is thread-scoped. A load on another thread is not covered.
43
+
44
+ Its cost is not a multiplier. Enabling a TracePoint costs roughly 46 microseconds
45
+ per load on a stock ruby:4.0-slim, near enough constant, so the ratio is decided by
46
+ how much work the load itself does. Measured 2026-07-30: 40x on a 45-byte session
47
+ cookie, 21x on a 142-byte session, 1.1x on a 46 KB document, 1.0x on a 488 KB one.
48
+ Guarding a large payload is close to free. Guarding a session cookie on every
49
+ request is not, and a cookie is exactly what this lab deserializes.
50
+ NOTICE
51
+
52
+ class Observation
53
+ attr_reader :class_name, :method_name
54
+
55
+ def initialize(class_name:, method_name:, permitted:)
56
+ @class_name = class_name
57
+ @method_name = method_name
58
+ @permitted = permitted
59
+ end
60
+
61
+ def permitted?
62
+ @permitted
63
+ end
64
+
65
+ def to_s
66
+ "#{class_name}##{method_name}"
67
+ end
68
+ end
69
+
70
+ attr_reader :observations
71
+
72
+ def initialize(permitted_class_names: [], strict: false)
73
+ @permitted_class_names = permitted_class_names.map(&:to_s).freeze
74
+ @hooks = strict ? STRICT_HOOKS : DEFAULT_HOOKS
75
+ @observations = [].freeze
76
+ end
77
+
78
+ def load(blob)
79
+ seen = []
80
+ tracer = TracePoint.new(*EVENTS) { |event| inspect_event(event, seen) }
81
+ result = nil
82
+ begin
83
+ tracer.enable { result = ::Marshal.load(blob) }
84
+ ensure
85
+ @observations = seen.freeze
86
+ end
87
+ result
88
+ end
89
+
90
+ def watches?(hook)
91
+ hooks.include?(hook)
92
+ end
93
+
94
+ private
95
+
96
+ attr_reader :permitted_class_names, :hooks
97
+
98
+ def inspect_event(event, seen)
99
+ return unless hooks.include?(event.method_id)
100
+
101
+ owner = owner_name(event.self)
102
+ label = owner || ANONYMOUS_OWNER
103
+ permitted = !owner.nil? && permitted_class_names.include?(owner)
104
+ seen << Observation.new(class_name: label, method_name: event.method_id,
105
+ permitted: permitted)
106
+ return if permitted
107
+
108
+ raise GuardedLoadError, format(REASON, label, event.method_id)
109
+ end
110
+
111
+ def owner_name(receiver)
112
+ owner = receiver.is_a?(Module) ? receiver : receiver.class
113
+ name = owner.name
114
+ name if name.is_a?(String) && !name.empty?
115
+ rescue StandardError
116
+ nil
117
+ end
118
+ end
119
+ end
120
+ end