parse-stack-next 4.5.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.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. metadata +377 -0
@@ -0,0 +1,199 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "active_support"
5
+ require "active_support/inflector"
6
+ require "active_support/core_ext/object"
7
+ require "active_support/core_ext/string"
8
+ require "active_support/core_ext"
9
+ require "ipaddr"
10
+ require "resolv"
11
+ require "uri"
12
+ require_relative "../model/file"
13
+
14
+ module Parse
15
+ # Interface to the CloudCode webhooks API.
16
+ class Webhooks
17
+ # Module to support registering Parse CloudCode webhooks.
18
+ module Registration
19
+ # The set of allowed trigger types.
20
+ ALLOWED_HOOKS = Parse::API::Hooks::TRIGGER_NAMES + [:function]
21
+
22
+ # @!visibility private
23
+ # Validates that a webhook endpoint URL is safe to register with
24
+ # Parse Server. Rejects non-http(s) schemes, embedded userinfo, and
25
+ # hostnames that resolve to loopback, link-local, RFC1918, CGNAT,
26
+ # multicast, or cloud-metadata addresses (covered by
27
+ # +Parse::File::BLOCKED_CIDRS+). Without these checks an attacker who
28
+ # can reach +register_webhook!+ could redirect Parse Server's trigger
29
+ # POSTs to an internal host (e.g. the cloud metadata service) and
30
+ # exfiltrate request bodies. Returns the input URL unchanged on
31
+ # success.
32
+ # @raise [ArgumentError] on any disallowed input.
33
+ def assert_webhook_url_safe!(url)
34
+ raise ArgumentError, "Webhook URL is required" if url.nil? || url.to_s.empty?
35
+ uri = begin
36
+ URI.parse(url.to_s)
37
+ rescue URI::InvalidURIError => e
38
+ raise ArgumentError, "Invalid webhook URL: #{e.message}"
39
+ end
40
+ unless %w[http https].include?(uri.scheme)
41
+ raise ArgumentError, "Webhook URL must be http(s) (got #{uri.scheme.inspect})"
42
+ end
43
+ host = uri.host
44
+ if host.nil? || host.empty?
45
+ raise ArgumentError, "Webhook URL missing host"
46
+ end
47
+ if uri.userinfo
48
+ raise ArgumentError, "Webhook URL must not include userinfo credentials"
49
+ end
50
+ if Parse::Webhooks.allow_private_webhook_urls
51
+ return url
52
+ end
53
+ addrs = Parse::File.resolve_addresses(host)
54
+ if addrs.empty?
55
+ raise ArgumentError, "Webhook URL host #{host} could not be resolved"
56
+ end
57
+ addrs.each do |ip|
58
+ if Parse::File::BLOCKED_CIDRS.any? { |cidr| cidr.include?(ip) }
59
+ raise ArgumentError,
60
+ "Refusing to register webhook with private/internal " \
61
+ "address #{ip} for host #{host}"
62
+ end
63
+ end
64
+ url
65
+ end
66
+
67
+ # removes all registered webhook functions with Parse Server.
68
+ def remove_all_functions!
69
+ client.functions.results.sort_by { |f| f["functionName"] }.each do |f|
70
+ next unless f["url"].present?
71
+ client.delete_function f["functionName"]
72
+ yield(f["functionName"]) if block_given?
73
+ end
74
+ end
75
+
76
+ # removes all registered webhook triggers with Parse Server.
77
+ def remove_all_triggers!
78
+ client.triggers.results.sort_by { |f| [f["triggerName"], f["className"]] }.each do |f|
79
+ next unless f["url"].present?
80
+ triggerName = f["triggerName"]
81
+ className = f[Parse::Model::KEY_CLASS_NAME]
82
+ client.delete_trigger triggerName, className
83
+ yield(f["triggerName"], f[Parse::Model::KEY_CLASS_NAME]) if block_given?
84
+ end
85
+ end
86
+
87
+ # Registers all webhook functions registered with Parse::Stack with Parse server.
88
+ # @param endpoint [String] a https url that points to the webhook server.
89
+ def register_functions!(endpoint)
90
+ unless endpoint.present? && (endpoint.starts_with?("http://") || endpoint.starts_with?("https://"))
91
+ raise ArgumentError, "The HOOKS_URL must be http/s: '#{endpoint}''"
92
+ end
93
+ assert_webhook_url_safe!(endpoint)
94
+ endpoint += "/" unless endpoint.ends_with?("/")
95
+ functionsMap = {}
96
+ client.functions.results.each do |f|
97
+ next unless f["url"].present?
98
+ functionsMap[f["functionName"]] = f["url"]
99
+ end
100
+
101
+ routes.function.keys.sort.each do |functionName|
102
+ url = endpoint + functionName
103
+ if functionsMap[functionName].present? #you may need to update
104
+ next if functionsMap[functionName] == url
105
+ client.update_function(functionName, url)
106
+ else
107
+ client.create_function(functionName, url)
108
+ end
109
+ yield(functionName) if block_given?
110
+ end
111
+ end
112
+
113
+ # Registers all webhook triggers registered with Parse::Stack with Parse server.
114
+ # @param endpoint [String] a https url that points to the webhook server.
115
+ # @param include_wildcard [Boolean] Allow wildcard registrations
116
+ def register_triggers!(endpoint, include_wildcard: false)
117
+ unless endpoint.present? && (endpoint.starts_with?("http://") || endpoint.starts_with?("https://"))
118
+ raise ArgumentError, "The HOOKS_URL must be http/s: '#{endpoint}''"
119
+ end
120
+ assert_webhook_url_safe!(endpoint)
121
+ endpoint += "/" unless endpoint.ends_with?("/")
122
+ all_triggers = Parse::API::Hooks::TRIGGER_NAMES_LOCAL
123
+
124
+ current_triggers = {}
125
+ all_triggers.each { |t| current_triggers[t] = {} }
126
+
127
+ client.triggers.each do |t|
128
+ next unless t["url"].present?
129
+ trigger_name = t["triggerName"].underscore.to_sym
130
+ current_triggers[trigger_name] ||= {}
131
+ current_triggers[trigger_name][t["className"]] = t["url"]
132
+ end
133
+
134
+ all_triggers.each do |trigger|
135
+ classNames = routes[trigger].keys.dup
136
+ if include_wildcard && classNames.include?("*") #then create the list for all classes
137
+ classNames.delete "*" #delete the wildcard before we expand it
138
+ classNames = classNames + Parse.registered_classes
139
+ classNames.uniq!
140
+ end
141
+
142
+ classNames.sort.each do |className|
143
+ next if className == "*"
144
+ url = endpoint + "#{trigger}/#{className}"
145
+ if current_triggers[trigger][className].present? #then you may need to update
146
+ next if current_triggers[trigger][className] == url
147
+ client.update_trigger(trigger, className, url)
148
+ else
149
+ client.create_trigger(trigger, className, url)
150
+ end
151
+ yield(trigger.columnize, className) if block_given?
152
+ end
153
+ end
154
+ end
155
+
156
+ # Registers a webhook trigger with a given endpoint url.
157
+ # @param trigger [Symbol] Trigger type based on Parse::API::Hooks::TRIGGER_NAMES or :function.
158
+ # @param name [String] the name of the webhook.
159
+ # @param url [String] the https url endpoint that will handle the request.
160
+ # @see Parse::API::Hooks::TRIGGER_NAMES
161
+ def register_webhook!(trigger, name, url)
162
+ trigger = trigger.to_s.camelize(:lower).to_sym
163
+ raise ArgumentError, "Invalid hook trigger #{trigger}" unless ALLOWED_HOOKS.include?(trigger)
164
+ assert_webhook_url_safe!(url)
165
+ if trigger == :function
166
+ response = client.fetch_function(name)
167
+ # if it is either an error (which has no results) or there is a result but
168
+ # no registered item with a URL (which implies either none registered or only cloud code registered)
169
+ # then create it.
170
+ if response.results.none? { |d| d.has_key?("url") }
171
+ response = client.create_function(name, url)
172
+ else
173
+ # update it
174
+ response = client.update_function(name, url)
175
+ end
176
+ warn "Webhook Registration warning: #{response.result["warning"]}" if response.result.has_key?("warning")
177
+ warn "Failed to register Cloud function #{name} with #{url}" if response.error?
178
+ return response
179
+ else # must be trigger
180
+ response = client.fetch_trigger(trigger, name)
181
+ # if it is either an error (which has no results) or there is a result but
182
+ # no registered item with a URL (which implies either none registered or only cloud code registered)
183
+ # then create it.
184
+ if response.results.none? { |d| d.has_key?("url") }
185
+ # create it
186
+ response = client.create_trigger(trigger, name, url)
187
+ else
188
+ # update it
189
+ response = client.update_trigger(trigger, name, url)
190
+ end
191
+
192
+ warn "Webhook Registration warning: #{response.result["warning"]}" if response.result.has_key?("warning")
193
+ warn "Webhook Registration error: #{response.error}" if response.error?
194
+ return response
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,189 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+ require "openssl"
6
+ require "monitor"
7
+ require "active_support/security_utils"
8
+
9
+ module Parse
10
+ class Webhooks
11
+ # NEW-EXT-4: webhook freshness and replay protection.
12
+ #
13
+ # Parse Server's default webhook delivery is authenticated only by the
14
+ # static +X-Parse-Webhook-Key+ header. A captured POST is therefore
15
+ # indefinitely replayable -- a Ruby-initiated save bearing an +_RB_+
16
+ # request id will continue to suppress server-side after_* callbacks
17
+ # every time it is replayed, and a generic trigger payload can be
18
+ # delivered repeatedly to fire double-charges or other side effects.
19
+ #
20
+ # This module adds two layers on top of the existing static-key check:
21
+ #
22
+ # 1. **Always-on body+request-id dedup.** A bounded LRU records a
23
+ # SHA-256 of +(request_id || "")+ joined with the request body. A
24
+ # duplicate seen within +replay_window_seconds+ is rejected with
25
+ # +"Webhook replay detected."+. Cooperation with Parse Server is not
26
+ # required; this protects against in-window replays only, but those
27
+ # are the cheapest attack to mount (proxy retries, captured fast
28
+ # loops, retransmits).
29
+ #
30
+ # 2. **Opt-in HMAC freshness verification.** When a +signing_secret+ is
31
+ # configured (programmatically or via
32
+ # +PARSE_WEBHOOK_SIGNING_SECRET+) the dispatcher requires two extra
33
+ # headers on every request:
34
+ #
35
+ # * +X-Parse-Webhook-Timestamp+ -- decimal Unix epoch seconds.
36
+ # * +X-Parse-Webhook-Signature+ -- hex-encoded HMAC-SHA256 of the
37
+ # bytes +"#{timestamp}.#{body}"+ keyed with the signing secret.
38
+ #
39
+ # Requests outside +signing_max_skew_seconds+ (default 300) or with
40
+ # an invalid signature are rejected. Once enabled, this gives full
41
+ # binding between the body and the time of delivery and closes the
42
+ # replay window beyond the freshness skew.
43
+ #
44
+ # Operators wanting layer 2 must arrange for Parse Server to add these
45
+ # headers. Parse Server does not natively sign webhook deliveries, so
46
+ # this is typically done with a thin Cloud Code wrapper or an egress
47
+ # proxy. Until enabled, layer 1 still applies.
48
+ module ReplayProtection
49
+ # @!visibility private
50
+ HEADER_TIMESTAMP = "HTTP_X_PARSE_WEBHOOK_TIMESTAMP"
51
+ # @!visibility private
52
+ HEADER_SIGNATURE = "HTTP_X_PARSE_WEBHOOK_SIGNATURE"
53
+ # @!visibility private
54
+ DEFAULT_REPLAY_WINDOW = 300
55
+ # @!visibility private
56
+ DEFAULT_REPLAY_CACHE_SIZE = 10_000
57
+ # @!visibility private
58
+ DEFAULT_MAX_SKEW = 300
59
+
60
+ class << self
61
+ attr_writer :signing_secret, :signing_max_skew_seconds,
62
+ :replay_window_seconds, :replay_cache_size
63
+
64
+ # Shared HMAC secret used to verify +X-Parse-Webhook-Signature+.
65
+ # When nil/empty, signature verification is skipped (layer 1 still
66
+ # applies). Defaults to +ENV["PARSE_WEBHOOK_SIGNING_SECRET"]+.
67
+ def signing_secret
68
+ return @signing_secret if defined?(@signing_secret) && !@signing_secret.nil?
69
+ ENV["PARSE_WEBHOOK_SIGNING_SECRET"]
70
+ end
71
+
72
+ # Maximum allowed clock skew (in seconds) between the timestamp
73
+ # header and the receiver. Requests outside this window are
74
+ # rejected as stale when +signing_secret+ is set.
75
+ def signing_max_skew_seconds
76
+ @signing_max_skew_seconds || DEFAULT_MAX_SKEW
77
+ end
78
+
79
+ # How long a +(request_id, body)+ digest stays in the dedup cache.
80
+ # Duplicates seen within this window are rejected.
81
+ def replay_window_seconds
82
+ @replay_window_seconds || DEFAULT_REPLAY_WINDOW
83
+ end
84
+
85
+ # Maximum number of entries retained in the dedup LRU. Older
86
+ # entries are evicted to keep memory bounded.
87
+ def replay_cache_size
88
+ @replay_cache_size || DEFAULT_REPLAY_CACHE_SIZE
89
+ end
90
+
91
+ # Reset all configuration (intended for tests).
92
+ # @!visibility private
93
+ def reset!
94
+ @signing_secret = nil
95
+ @signing_max_skew_seconds = nil
96
+ @replay_window_seconds = nil
97
+ @replay_cache_size = nil
98
+ @cache = nil
99
+ end
100
+
101
+ # Clear the dedup cache (intended for tests).
102
+ # @!visibility private
103
+ def clear_cache!
104
+ cache.clear
105
+ end
106
+
107
+ # @!visibility private
108
+ def cache
109
+ @cache ||= LruCache.new
110
+ end
111
+
112
+ # @!visibility private
113
+ # Returns nil when the request passes both replay and signature
114
+ # checks; otherwise returns a short error string suitable for the
115
+ # webhook error response. The headers come from +env+ so this
116
+ # works with any Rack request.
117
+ def verify!(env, body_str, request_id)
118
+ secret = signing_secret
119
+ if secret && !secret.empty?
120
+ ts_header = env[HEADER_TIMESTAMP].to_s
121
+ sig_header = env[HEADER_SIGNATURE].to_s
122
+ return "Missing webhook signature." if ts_header.empty? || sig_header.empty?
123
+ return "Invalid webhook timestamp." unless ts_header =~ /\A-?\d{1,12}\z/
124
+ ts = ts_header.to_i
125
+ skew = (Time.now.to_i - ts).abs
126
+ return "Stale webhook timestamp." if skew > signing_max_skew_seconds
127
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{body_str}")
128
+ unless ActiveSupport::SecurityUtils.secure_compare(expected, sig_header)
129
+ return "Invalid webhook signature."
130
+ end
131
+ end
132
+
133
+ digest = Digest::SHA256.hexdigest("#{request_id}\x1f#{body_str}")
134
+ if cache.seen?(digest, replay_window_seconds)
135
+ return "Webhook replay detected."
136
+ end
137
+ cache.record(digest, replay_cache_size)
138
+ nil
139
+ end
140
+ end
141
+
142
+ # Bounded, thread-safe LRU keyed on a digest string with per-entry
143
+ # insertion timestamps. Used only by ReplayProtection; intentionally
144
+ # private to avoid leaking another caching primitive into the public
145
+ # API. Ruby Hashes preserve insertion order, so a delete+insert on
146
+ # touch is enough to maintain LRU ordering.
147
+ class LruCache
148
+ include MonitorMixin
149
+
150
+ def initialize
151
+ super()
152
+ @entries = {}
153
+ end
154
+
155
+ def seen?(key, window_seconds)
156
+ synchronize do
157
+ ts = @entries[key]
158
+ return false unless ts
159
+ if Time.now.to_i - ts > window_seconds
160
+ @entries.delete(key)
161
+ return false
162
+ end
163
+ @entries.delete(key)
164
+ @entries[key] = ts # touch
165
+ true
166
+ end
167
+ end
168
+
169
+ def record(key, max_size)
170
+ synchronize do
171
+ @entries.delete(key)
172
+ @entries[key] = Time.now.to_i
173
+ while @entries.size > max_size
174
+ @entries.shift
175
+ end
176
+ end
177
+ end
178
+
179
+ def clear
180
+ synchronize { @entries.clear }
181
+ end
182
+
183
+ def size
184
+ synchronize { @entries.size }
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end