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.
- checksums.yaml +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- 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
|