Skip to content

Commit 0923d15

Browse files
committed
Security hardening and safety fixes
Multiple security and robustness fixes across the library: switch client login to POST and add client-side rate limiting with exponential backoff; make login_with_mfa use the same rate limiting; add require_https option to fail on plain HTTP in non-localhost environments. Harden webhooks and MCP server: use constant-time key comparison (secure_compare), stop logging invalid webhook keys, enforce JSON size and nesting limits, default MCP bind to 127.0.0.1, add optional MCP API key auth (X-MCP-API-Key), and limit MCP payload parsing. Prevent remote code execution by blocking dangerous tool methods (moved to Parse::Agent::Tools and validated at call time) and fix load-order crashes. Improve logging safety by redacting sensitive fields from request/response logs. Improve caching safety by hashing session tokens (SHA-256 prefix) for cache keys. Harden query/aggregation handling by validating field names and blocking dangerous pipeline stages and $where usage. Fix transaction rollback tracking by keying original states by object_id. Update tests to be deterministic and adapt to behavior changes. Bump version to 3.3.2.
1 parent cebf633 commit 0923d15

18 files changed

Lines changed: 393 additions & 184 deletions

CHANGELOG.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,35 @@
11
## Parse-Stack Changelog
22

3+
### 3.3.2
4+
5+
#### Security Fixes
6+
7+
- **FIXED**: Login now uses POST instead of GET, preventing passwords from appearing in server logs, browser history, and URL query parameters.
8+
- **FIXED**: Webhook key comparison now uses constant-time `ActiveSupport::SecurityUtils.secure_compare` to prevent timing attacks. Invalid webhook keys are no longer logged.
9+
- **FIXED**: MCP server default binding changed from `0.0.0.0` to `127.0.0.1`, preventing unintended network exposure.
10+
- **FIXED**: Field names in queries are now validated to block MongoDB operator injection (`$where`, `$function`, etc.).
11+
- **FIXED**: Aggregation pipelines now block dangerous stages (`$out`, `$merge`) and `$where` operators inside `$match` stages.
12+
- **FIXED**: Sensitive fields (passwords, tokens, auth data) are now redacted from debug log output.
13+
- **NEW**: Client-side login rate limiting with exponential backoff after repeated failures to mitigate brute force attacks.
14+
- **FIXED**: Session tokens in cache keys are now hashed with SHA-256 instead of stored as plaintext.
15+
- **NEW**: MCP server now supports API key authentication via `MCP_API_KEY` env var or `api_key:` parameter. Requests must include `X-MCP-API-Key` header when configured.
16+
- **FIXED**: JSON payloads in webhooks and MCP server are now limited to 1 MB size and 20 levels of nesting depth to prevent denial-of-service attacks.
17+
- **FIXED**: Tool method invocation in MCP server now blocks dangerous methods (`eval`, `exec`, `system`, `send`, `method`, `binding`, etc.) to prevent code execution via user-controlled method names.
18+
- **FIXED**: Blocked methods list moved to always-loaded `Parse::Agent::Tools` module, fixing load-order crash when MCP server is not enabled.
19+
- **FIXED**: Login rate limiter is now thread-safe (Mutex-protected) with periodic cleanup of expired entries to prevent memory leaks.
20+
- **FIXED**: MCP server now explicitly requires ActiveSupport modules, preventing load-order failures.
21+
- **FIXED**: Session token cache key hash increased from 16 to 32 hex characters (128 bits) to reduce collision risk.
22+
- **FIXED**: MCP `/tools` endpoint now requires API key authentication when configured, preventing unauthenticated schema enumeration.
23+
- **FIXED**: Response body logging is now redacted alongside request logging, preventing session tokens from appearing in debug output.
24+
- **NEW**: `require_https` option for `Parse::Client` raises an error when HTTP is used with a non-localhost server URL. Enable via `require_https: true` or `PARSE_REQUIRE_HTTPS=true`.
25+
- **FIXED**: `login_with_mfa` now applies the same rate limiting and exponential backoff as the standard `login` method.
26+
- **FIXED**: Aggregation pipeline blocklist expanded to also block `$function`, `$accumulator`, `$collMod`, `$createIndex`, and `$dropIndex` stages.
27+
28+
#### Bug Fixes
29+
30+
- **FIXED**: `Parse::Object.transaction` now correctly assigns `objectId`, `createdAt`, and `updatedAt` to all objects in the batch. Previously, only the first unsaved object received its server-assigned ID because `Parse::Object#hash` treats all unsaved objects as equal, causing Hash key collisions in the internal tracking map.
31+
- **FIXED**: `AggregateTestComment` and `AggregateTestPost` test models now use `belongs_to` for pointer fields instead of `property :object`, which caused Parse Server schema mismatch errors when saving pointer values.
32+
333
### 3.3.1
434
- Bundle update
535

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
parse-stack (3.3.1)
4+
parse-stack (3.3.2)
55
activemodel (>= 5, < 9)
66
activesupport (>= 5, < 9)
77
faraday (~> 2.0)

lib/parse/agent/mcp_server.rb

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
require "webrick"
55
require "json"
6+
require "active_support/core_ext/object/blank"
7+
require "active_support/security_utils"
68

79
module Parse
810
class Agent
@@ -38,6 +40,15 @@ class MCPServer
3840
# Default port for the MCP server
3941
@default_port = 3001
4042

43+
# Maximum allowed request body size (1 MB)
44+
MAX_BODY_SIZE = 1_048_576
45+
46+
# Maximum JSON nesting depth
47+
MAX_JSON_NESTING = 20
48+
49+
# HTTP header for MCP API key authentication
50+
MCP_API_KEY_HEADER = "X-MCP-API-Key"
51+
4152
class << self
4253
attr_accessor :default_port
4354

@@ -47,7 +58,7 @@ class << self
4758
# @param permissions [Symbol] agent permission level
4859
# @param session_token [String, nil] optional session token
4960
# @param host [String] host to bind to
50-
def run(port: nil, permissions: :readonly, session_token: nil, host: "0.0.0.0")
61+
def run(port: nil, permissions: :readonly, session_token: nil, host: "127.0.0.1", api_key: nil)
5162
unless Parse::Agent.mcp_enabled?
5263
raise "MCP server not enabled. Call Parse::Agent.enable_mcp! first"
5364
end
@@ -57,6 +68,7 @@ def run(port: nil, permissions: :readonly, session_token: nil, host: "0.0.0.0")
5768
permissions: permissions,
5869
session_token: session_token,
5970
host: host,
71+
api_key: api_key,
6072
)
6173
server.start
6274
end
@@ -77,9 +89,10 @@ def run(port: nil, permissions: :readonly, session_token: nil, host: "0.0.0.0")
7789
# @param host [String] host to bind to
7890
# @param permissions [Symbol] agent permission level
7991
# @param session_token [String, nil] optional session token
80-
def initialize(port: 3001, host: "0.0.0.0", permissions: :readonly, session_token: nil)
92+
def initialize(port: 3001, host: "127.0.0.1", permissions: :readonly, session_token: nil, api_key: nil)
8193
@port = port
8294
@host = host
95+
@api_key = api_key || ENV["MCP_API_KEY"]
8396
@agent = Parse::Agent.new(permissions: permissions, session_token: session_token)
8497
@server = nil
8598
end
@@ -116,13 +129,20 @@ def setup_routes
116129
# MCP endpoint for all protocol messages
117130
@server.mount_proc("/mcp") { |req, res| handle_mcp_request(req, res) }
118131

119-
# Health check endpoint
132+
# Health check endpoint (unauthenticated - standard for monitoring)
120133
@server.mount_proc("/health") do |_req, res|
121134
json_response(res, { status: "ok", mcp_enabled: true })
122135
end
123136

124-
# Tool list endpoint (convenience)
125-
@server.mount_proc("/tools") do |_req, res|
137+
# Tool list endpoint (requires auth if API key is configured)
138+
@server.mount_proc("/tools") do |req, res|
139+
if @api_key.present?
140+
provided_key = req[MCP_API_KEY_HEADER].to_s
141+
unless ActiveSupport::SecurityUtils.secure_compare(@api_key, provided_key)
142+
error_response(res, 401, "Unauthorized: invalid or missing API key")
143+
next
144+
end
145+
end
126146
json_response(res, @agent.tool_definitions(format: :mcp))
127147
end
128148
end
@@ -133,9 +153,23 @@ def handle_mcp_request(req, res)
133153
return error_response(res, 405, "Method not allowed")
134154
end
135155

156+
# C4: API key authentication
157+
if @api_key.present?
158+
provided_key = req[MCP_API_KEY_HEADER].to_s
159+
unless ActiveSupport::SecurityUtils.secure_compare(@api_key, provided_key)
160+
return error_response(res, 401, "Unauthorized: invalid or missing API key")
161+
end
162+
end
163+
164+
# C5: Payload size limit
165+
raw_body = req.body || "{}"
166+
if raw_body.bytesize > MAX_BODY_SIZE
167+
return error_response(res, 413, "Payload too large (max #{MAX_BODY_SIZE} bytes)")
168+
end
169+
136170
begin
137-
body = JSON.parse(req.body || "{}")
138-
rescue JSON::ParserError => e
171+
body = JSON.parse(raw_body, max_nesting: MAX_JSON_NESTING)
172+
rescue JSON::ParserError, JSON::NestingError => e
139173
return error_response(res, 400, "Invalid JSON: #{e.message}")
140174
end
141175

lib/parse/agent/tools.rb

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ class Agent
1717
module Tools
1818
extend self
1919

20+
# Methods that are dangerous and should never be invoked via tools.
21+
# Defined here (rather than MCPServer) so it's always available.
22+
BLOCKED_METHODS = %w[
23+
eval exec system ` send __send__ public_send instance_eval class_eval
24+
module_eval define_method remove_method undef_method
25+
open fork spawn syscall load require require_relative
26+
const_get const_set remove_const method binding
27+
instance_variable_set instance_variable_get
28+
].freeze
29+
2030
# Default timeout for tool operations (seconds)
2131
DEFAULT_TIMEOUT = 30
2232

@@ -478,8 +488,12 @@ def with_timeout(tool_name)
478488
raise Agent::ToolTimeoutError.new(tool_name, timeout)
479489
end
480490

481-
# Call a method with arguments, handling both positional and keyword args
491+
# Call a method with arguments, handling both positional and keyword args.
492+
# Validates that the method is not on the blocked list to prevent
493+
# code execution via user-controlled method names.
494+
# @raise [ArgumentError] if the method is blocked.
482495
def call_with_args(target, method_sym, args)
496+
validate_method_name!(method_sym)
483497
if args.empty?
484498
target.public_send(method_sym)
485499
else
@@ -493,6 +507,15 @@ def call_with_args(target, method_sym, args)
493507
end
494508
end
495509

510+
# Validates that a method name is not on the blocked list.
511+
# @param method_name [Symbol, String] the method name to validate.
512+
# @raise [ArgumentError] if the method is blocked.
513+
def validate_method_name!(method_name)
514+
if BLOCKED_METHODS.include?(method_name.to_s)
515+
raise ArgumentError, "Method '#{method_name}' is blocked for security reasons"
516+
end
517+
end
518+
496519
# Serialize method results for JSON output
497520
def serialize_result(result)
498521
case result

lib/parse/api/users.rb

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,19 +106,20 @@ def request_password_reset(email, headers: {}, **opts)
106106
request :post, REQUEST_PASSWORD_RESET, body: body, opts: opts, headers: headers
107107
end
108108

109-
# Login a user.
109+
# Login a user. Implements client-side rate limiting with exponential
110+
# backoff after repeated failures to mitigate brute force attacks.
110111
# @param username [String] the Parse user username.
111112
# @param password [String] the Parse user's associated password.
112113
# @param headers [Hash] additional HTTP headers to send with the request.
113114
# @param opts [Hash] additional options to pass to the {Parse::Client} request.
114115
# @return [Parse::Response]
115116
def login(username, password, headers: {}, **opts)
116-
# Probably pass Installation-ID as header
117-
query = { username: username, password: password }
117+
check_login_rate_limit!(username)
118+
body = { username: username, password: password }
118119
headers.merge!({ Parse::Protocol::REVOCABLE_SESSION => "1" })
119-
# headers.merge!( { Parse::Protocol::INSTALLATION_ID => ''} )
120-
response = request :get, LOGIN_PATH, query: query, headers: headers, opts: opts
120+
response = request :post, LOGIN_PATH, body: body, headers: headers, opts: opts
121121
response.parse_class = Parse::Model::CLASS_USER
122+
track_login_attempt(username, response.success?)
122123
response
123124
end
124125

@@ -137,6 +138,7 @@ def login(username, password, headers: {}, **opts)
137138
# @example
138139
# response = client.login_with_mfa("john", "password123", "123456")
139140
def login_with_mfa(username, password, mfa_token, headers: {}, **opts)
141+
check_login_rate_limit!(username)
140142
# Parse Server expects authData to be sent with POST for MFA login
141143
body = {
142144
username: username,
@@ -150,6 +152,7 @@ def login_with_mfa(username, password, mfa_token, headers: {}, **opts)
150152
headers.merge!({ Parse::Protocol::REVOCABLE_SESSION => "1" })
151153
response = request :post, LOGIN_PATH, body: body, headers: headers, opts: opts
152154
response.parse_class = Parse::Model::CLASS_USER
155+
track_login_attempt(username, response.success?)
153156
response
154157
end
155158

@@ -176,6 +179,68 @@ def signup(username, password, email = nil, body: {}, **opts)
176179
body[:email] = email || body[:email]
177180
create_user(body, opts)
178181
end
182+
private
183+
184+
# @!visibility private
185+
# Thread-safe tracker for login rate limiting. Keys are usernames, values are
186+
# { failures: Integer, locked_until: Time }.
187+
def login_rate_limits
188+
@login_rate_limit_mutex ||= Mutex.new
189+
@login_rate_limits ||= {}
190+
end
191+
192+
# Maximum consecutive failures before lockout.
193+
LOGIN_MAX_FAILURES = 5
194+
# Base delay in seconds for exponential backoff.
195+
LOGIN_BASE_DELAY = 2
196+
# Maximum number of tracked usernames before cleanup.
197+
LOGIN_RATE_LIMIT_MAX_ENTRIES = 10_000
198+
# Entries older than this (seconds) are eligible for cleanup.
199+
LOGIN_RATE_LIMIT_TTL = 600
200+
201+
# Checks if a login attempt is allowed for the given username.
202+
# @raise [RuntimeError] if the account is temporarily locked out.
203+
def check_login_rate_limit!(username)
204+
@login_rate_limit_mutex ||= Mutex.new
205+
@login_rate_limit_mutex.synchronize do
206+
entry = login_rate_limits[username]
207+
return unless entry
208+
if entry[:locked_until] && Time.now < entry[:locked_until]
209+
wait = (entry[:locked_until] - Time.now).ceil
210+
raise "Login rate limited for '#{username}'. Try again in #{wait} seconds."
211+
end
212+
end
213+
end
214+
215+
# Records a login attempt result and applies exponential backoff on failure.
216+
def track_login_attempt(username, success)
217+
@login_rate_limit_mutex ||= Mutex.new
218+
@login_rate_limit_mutex.synchronize do
219+
if success
220+
login_rate_limits.delete(username)
221+
else
222+
entry = login_rate_limits[username] || { failures: 0, locked_until: nil }
223+
entry[:failures] += 1
224+
if entry[:failures] >= LOGIN_MAX_FAILURES
225+
delay = LOGIN_BASE_DELAY**(entry[:failures] - LOGIN_MAX_FAILURES + 1)
226+
delay = [delay, 300].min # cap at 5 minutes
227+
entry[:locked_until] = Time.now + delay
228+
end
229+
login_rate_limits[username] = entry
230+
end
231+
# Periodic cleanup of expired entries to prevent memory leak
232+
cleanup_login_rate_limits if login_rate_limits.size > LOGIN_RATE_LIMIT_MAX_ENTRIES
233+
end
234+
end
235+
236+
# Removes expired entries from the rate limit tracker.
237+
def cleanup_login_rate_limits
238+
now = Time.now
239+
login_rate_limits.delete_if do |_username, entry|
240+
entry[:locked_until].nil? || (now - entry[:locked_until]) > LOGIN_RATE_LIMIT_TTL
241+
end
242+
end
243+
179244
end # Users
180245
end #API
181246
end #Parse

lib/parse/client.rb

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -303,11 +303,18 @@ def initialize(opts = {})
303303
@api_key = opts[:api_key] || opts[:rest_api_key] || ENV["PARSE_SERVER_REST_API_KEY"] || ENV["PARSE_API_KEY"]
304304
@master_key = opts[:master_key] || ENV["PARSE_SERVER_MASTER_KEY"] || ENV["PARSE_MASTER_KEY"]
305305

306-
# Security warning for HTTP usage (except localhost/127.0.0.1 for development)
306+
@require_https = opts.fetch(:require_https, ENV["PARSE_REQUIRE_HTTPS"] == "true")
307+
308+
# Security check for HTTP usage (except localhost/127.0.0.1 for development)
307309
if @server_url&.start_with?("http://") && !@server_url.match?(%r{^http://(localhost|127\.0\.0\.1)(:|/)})
308-
warn "[Parse::Client] SECURITY WARNING: Using HTTP instead of HTTPS for Parse server. " \
309-
"This exposes credentials and data to network interception. " \
310-
"Use HTTPS in production: #{@server_url}"
310+
if @require_https
311+
raise ArgumentError, "[Parse::Client] HTTPS required but server URL uses HTTP: #{@server_url}. " \
312+
"Set require_https: false or use an HTTPS URL."
313+
else
314+
warn "[Parse::Client] SECURITY WARNING: Using HTTP instead of HTTPS for Parse server. " \
315+
"This exposes credentials and data to network interception. " \
316+
"Use HTTPS in production: #{@server_url}"
317+
end
311318
end
312319

313320
# Determine the HTTP adapter to use

lib/parse/client/body_builder.rb

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,24 @@ class BodyBuilder < Faraday::Middleware
3232
HTTP_METHOD_OVERRIDE = "X-Http-Method-Override"
3333
# Maximum url length for most server requests before HTTP Method Override is used.
3434
MAX_URL_LENGTH = 2_000.freeze
35+
# Fields that should be redacted from log output.
36+
SENSITIVE_FIELDS = %w[password token sessionToken session_token access_token authData].freeze
37+
SENSITIVE_PATTERN = /(#{SENSITIVE_FIELDS.join("|")})(["']?\s*[=:>]\s*["']?)([^"&\s,}\]]+)/i
38+
3539
class << self
3640
# Allows logging. Set to `true` to enable logging, `false` to disable.
3741
# You may specify `:debug` for additional verbosity.
3842
# @return [Boolean]
3943
attr_accessor :logging
4044
end
4145

46+
# Redacts sensitive fields from a string for safe logging.
47+
# @param str [String] the string to redact.
48+
# @return [String] the redacted string.
49+
def self.redact(str)
50+
str.to_s.gsub(SENSITIVE_PATTERN) { "#{$1}#{$2}[FILTERED]" }
51+
end
52+
4253
# Thread-safety
4354
# @!visibility private
4455
def call(env)
@@ -67,21 +78,21 @@ def call!(env)
6778
end
6879

6980
if self.class.logging
70-
puts "[Request #{env.method.upcase}] #{env[:url]}"
81+
puts "[Request #{env.method.upcase}] #{self.class.redact(env[:url].to_s)}"
7182
env[:request_headers].each do |k, v|
7283
next if k == Parse::Protocol::MASTER_KEY
7384
puts "[Header] #{k} : #{v}"
7485
end
7586

76-
puts "[Request Body] #{env[:body]}"
87+
puts "[Request Body] #{self.class.redact(env[:body].to_s)}"
7788
end
7889
@app.call(env).on_complete do |response_env|
7990
# on a response, create a new Parse::Response and replace the :body
8091
# of the env
8192
# @todo CHECK FOR HTTP STATUS CODES
8293
if self.class.logging
8394
puts "[[Response #{response_env[:status]}]] ----------------------------------"
84-
puts response_env.body
95+
puts self.class.redact(response_env.body.to_s)
8596
puts "[[Response]] --------------------------------------\n"
8697
end
8798

lib/parse/client/caching.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
require "faraday"
55
require "moneta"
6+
require "digest"
67
require_relative "protocol"
78

89
module Parse
@@ -127,7 +128,8 @@ def call!(env)
127128

128129
if @request_headers.key?(SESSION_TOKEN)
129130
@session_token = @request_headers[SESSION_TOKEN]
130-
@cache_key = "#{@session_token}:#{@cache_key}" # prefix tokens
131+
hashed_token = Digest::SHA256.hexdigest(@session_token.to_s)[0, 32]
132+
@cache_key = "#{hashed_token}:#{@cache_key}" # prefix with hashed token
131133
elsif @request_headers.key?(MASTER_KEY)
132134
@cache_key = "mk:#{@cache_key}" # prefix for master key requests
133135
end

0 commit comments

Comments
 (0)