parse-stack-next 5.7.2 → 5.7.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b773e88a55f8771a7d898978652e9be673ec496627fcbaa0b0eb68b950486812
4
- data.tar.gz: 7813d865cb6cc09b6c44070186dbf67383304d9407a5de58fdbb723cce4dc081
3
+ metadata.gz: ef10cccc964e68942152a64d489ea8044a99eb0b76a3fea00d75c311ddf94d23
4
+ data.tar.gz: 357793ece65c1bb58147e160fb8c95782e3496deac3a1b843a82faf9dcd06737
5
5
  SHA512:
6
- metadata.gz: 1ffa5266044c369c673c8c5e4e57d4b37d882674eae6d8f7c79f3b1a8318a75dbd76d97036337d8a5416cfdcc022c1db3353e20de53857eaf8e37ac29913dfa7
7
- data.tar.gz: e897bb63c7030751cbb2a9720e6fde91e23f6c118a6656e9063a84df4ec7305db1e3f8dd141e600f2e9765fb3660fdd26b0c36c2c321184c9743c6be976498c9
6
+ metadata.gz: 44ed4c55b5388cd068c723f01bfa02c3efa4fafe61adfa328197a76d0bcf4006eeab313d7d3a42a8b2088b43a4a6a25bd9b89868fe183daa744c3f324c0ef0f2
7
+ data.tar.gz: 721eebb0297114c0e439babab1c0c39bb1bb9697788ff4197b6d9cf072d80b1fb7d3783f1c629ea0062314321b0695565bd7e252c4e7c39e1940bcf93adabcb7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,108 @@
1
1
  ## parse-stack-next Changelog
2
2
 
3
+ ### 5.7.4
4
+
5
+ #### Reset connections are retried instead of surfacing a raw Faraday error
6
+
7
+ A focused fix release for the request retry mechanism. A stale keep-alive
8
+ connection (a pooled persistent connection closed by the server or a load
9
+ balancer after idling) failed the next request with a raw
10
+ `Faraday::ConnectionFailed` / `Errno::ECONNRESET` instead of retrying, even
11
+ though an immediate re-send on a fresh connection succeeds. Reset connections
12
+ now retry under the same idempotency rules as read timeouts.
13
+
14
+ - **FIXED**: `Parse::Client#request` now rescues `Faraday::ConnectionFailed`
15
+ and inspects the wrapped cause. Reset-class causes (`Errno::ECONNRESET`,
16
+ `Errno::EPIPE`, `Errno::ECONNABORTED`, and the `EOFError` raised when the
17
+ remote end closes a keep-alive socket cleanly) are transient, so idempotent
18
+ requests (GET, DELETE, op-free PUT, and any write covered by asserted
19
+ server-side request-id dedup) retry with the standard backoff. Connection
20
+ refused and DNS failures keep the previous fail-fast behavior and propagate
21
+ the raw `Faraday::ConnectionFailed` with no retry latency.
22
+ - **CHANGED**: A reset connection that persists through the whole retry budget
23
+ now raises `Parse::Error::ConnectionError` (consistent with the read-timeout
24
+ path) instead of the raw `Faraday::ConnectionFailed`. Code that rescued
25
+ `Faraday::ConnectionFailed` to catch resets should rescue
26
+ `Parse::Error::ConnectionError` instead; refused and DNS failures still
27
+ raise `Faraday::ConnectionFailed`.
28
+
29
+ ### 5.7.3
30
+
31
+ #### Stored values can no longer drive the operator's terminal
32
+
33
+ - **NEW**: `Parse::TerminalSafe` is a canonical sanitizer for untrusted text
34
+ that is about to be written to a terminal, a log record, or an IRB `inspect`
35
+ line. `Parse::TerminalSafe.sanitize(str)` escapes ESC, BEL, backspace,
36
+ carriage return, the remaining C0 controls, DEL, the C1 controls (the 8-bit
37
+ CSI/OSC/DCS introducers, which a sanitizer that only looks for `0x1B`
38
+ misses), the zero-width characters, and the Unicode bidirectional overrides
39
+ and isolates. Tabs and newlines are preserved.
40
+ `Parse::TerminalSafe.sanitize_line(str)` escapes newlines and the Unicode
41
+ line and paragraph separators as well, for text interpolated into a single
42
+ log record. Control characters are escaped rather than deleted, so an
43
+ operator can still see that something tried. Non-UTF-8 and invalid-encoding
44
+ input is coerced first, so the sanitizer never raises on a binary response
45
+ body.
46
+ - **FIXED**: Values read back from Parse Server reached the terminal with their
47
+ control bytes intact. A row whose field contained an OSC 52 sequence could
48
+ write an attacker-chosen payload into the operator's system clipboard, and
49
+ CSI and carriage-return sequences could clear the screen or overwrite lines
50
+ the operator had already read, so what was displayed was not what was stored.
51
+ Every such path now renders through `Parse::TerminalSafe`: the conversational
52
+ agent's answer and tool trace (`Parse::Agent::MCPClient::Result#to_s` and
53
+ `#inspect`, which run merely by evaluating `mcp.ask(...)` in IRB), the
54
+ request/response bodies and header values written by
55
+ `Parse::Middleware::Logging` and by the separate `Parse.logging = true`
56
+ printer in `Parse::Middleware::BodyBuilder`, the REST error text in logged
57
+ error summaries and in `Parse::Client`'s warning path, `Parse::Query`'s error
58
+ and explain warnings, the webhook request, payload, response, handler-error,
59
+ and afterSave-callback lines, and the event and handler-error lines emitted
60
+ by `Parse.watch`. Sanitization applies to rendering only: `result.text`,
61
+ `object.title`, and the parsed response body keep their exact bytes, so a
62
+ caller writing to a non-terminal surface is unaffected.
63
+ - **FIXED**: The LLM provider failure paths in `Parse::Agent::MCPClient`
64
+ interpolated the raw provider response body into the exception message, and a
65
+ malformed success body raised a `JSON::ParserError` quoting the offending
66
+ bytes verbatim. IRB prints both raw, so a hostile or compromised LLM endpoint
67
+ could still land control sequences on the terminal through the failure path.
68
+ Both are escaped now, and the quoted body is capped.
69
+ - **FIXED**: Untrusted text interpolated into a log record could contain a raw
70
+ newline and forge a second, attacker-authored log entry. Log records now use
71
+ the newline-escaping form, and the escape is applied before the body-length
72
+ cap so a truncated record stays on one line too.
73
+ - **CHANGED**: `rake mcp:chat` escapes the answer, the tool-call trace, the
74
+ `/history` and `/compact` output, and error messages before printing them.
75
+
76
+ #### `parse-console --url` no longer trusts the document it fetches
77
+
78
+ - **BREAKING**: `parse-console --url` copied every key in the fetched JSON
79
+ document into the process environment, letting whoever served or tampered
80
+ with that document set arbitrary environment variables for the console
81
+ process, including ones the console never reads but Ruby, OpenSSL, or a
82
+ later `require` does. Only `PARSE_SERVER_URL`,
83
+ `PARSE_SERVER_APPLICATION_ID`, `PARSE_APP_ID`, `PARSE_SERVER_REST_API_KEY`,
84
+ `PARSE_API_KEY`, `PARSE_SERVER_MASTER_KEY`, and `PARSE_MASTER_KEY` are
85
+ copied now, and each value must be a string. **Migration:** a remote config
86
+ that carried additional variables must set them in the shell instead.
87
+ - **FIXED**: `parse-console --url` parsed the fetched document with
88
+ `JSON.load`, which honors `json_class` additions and will instantiate
89
+ arbitrary already-loaded classes from the document. It uses `JSON.parse` now.
90
+ - **CHANGED**: `parse-console --url` refuses plaintext HTTP unless the host is
91
+ loopback. The document carries the master key, so over plaintext anyone on
92
+ the path reads it and can substitute a server URL of their choosing. The
93
+ check runs against `URI#hostname`, so an IPv6 loopback literal and an
94
+ uppercase host both resolve correctly.
95
+ - **FIXED**: `parse-console --url` fetches the document with a streaming
96
+ request under a 1 MiB cap, and revalidates the scheme and host on every
97
+ redirect hop (bounded at five). The previous open-uri call buffered the
98
+ entire response before any read limit applied, and followed redirects itself,
99
+ so a permitted loopback URL could bounce to arbitrary plaintext HTTP on the
100
+ public internet without the scheme check running again.
101
+ - **FIXED**: `parse-console` echoed the supplied URL before validating it, and
102
+ printed the (possibly remotely supplied) server URL and application ID
103
+ verbatim after connecting. All three are escaped now, as is the error output
104
+ from the fetch path, which can quote the fetched bytes.
105
+
3
106
  ### 5.7.2
4
107
 
5
108
  #### `between` accepts Ruby Range values
data/bin/parse-console CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  require 'optparse'
4
4
  require 'json'
5
- require 'open-uri'
5
+ require 'net/http'
6
+ require 'uri'
6
7
  require 'active_support'
7
8
  require 'active_support/core_ext'
9
+ require 'parse/terminal_safe'
8
10
 
9
11
  DEFAULT_CONFIG_FILE = 'config.json'
10
12
  DEFAULT_CONFIG_CONTENTS = {
@@ -18,6 +20,88 @@ DEFAULT_CONFIG_CONTENTS = {
18
20
  }]
19
21
  }.freeze
20
22
 
23
+ # Only these keys are copied out of a remote config document and into the
24
+ # process environment. The loader used to copy every key it was handed, which
25
+ # let whoever served (or tampered with) the document set arbitrary env vars for
26
+ # the console process, including ones the console never reads but Ruby,
27
+ # OpenSSL, or a later `require` does.
28
+ REMOTE_CONFIG_ENV_ALLOWLIST = %w[
29
+ PARSE_SERVER_URL
30
+ PARSE_SERVER_APPLICATION_ID PARSE_APP_ID
31
+ PARSE_SERVER_REST_API_KEY PARSE_API_KEY
32
+ PARSE_SERVER_MASTER_KEY PARSE_MASTER_KEY
33
+ ].freeze
34
+
35
+ # A remote config carries the master key. Over plaintext HTTP anyone on the path
36
+ # reads it and can substitute a server URL of their choosing, so require TLS
37
+ # except when pointing at the loopback interface.
38
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
39
+
40
+ REMOTE_CONFIG_MAX_BYTES = 1_048_576
41
+ REMOTE_CONFIG_MAX_REDIRECTS = 5
42
+
43
+ # SEC-20: never hand a user-supplied string to bare Kernel#open, where
44
+ # `open("|command")` executes a subprocess. Parse an explicit URI and require an
45
+ # HTTP(S) scheme instead.
46
+ def validate_config_uri!(uri)
47
+ unless uri.is_a?(URI::HTTP) # URI::HTTPS < URI::HTTP, so this admits both
48
+ raise "Refusing to load config from a non-HTTP(S) URL: #{uri.to_s.inspect}"
49
+ end
50
+ # `hostname` (not `host`) so an IPv6 literal arrives as "::1" rather than
51
+ # "[::1]"; downcased so "LOCALHOST" is recognized too.
52
+ unless uri.is_a?(URI::HTTPS) || LOOPBACK_HOSTS.include?(uri.hostname.to_s.downcase)
53
+ raise "Refusing to fetch credentials over plaintext HTTP: #{uri.to_s.inspect}. " \
54
+ "Use https, or a loopback host for local testing."
55
+ end
56
+ uri
57
+ end
58
+
59
+ # Fetch a remote config document, streaming it under a hard byte cap and
60
+ # revalidating every redirect hop.
61
+ #
62
+ # Both properties are the reason this is hand-rolled rather than an open-uri
63
+ # one-liner. open-uri buffers the whole response before yielding the IO, so a
64
+ # read cap on the returned handle limits only what is read back out of a body
65
+ # that was already downloaded in full; and it follows redirects itself, so a
66
+ # permitted `http://localhost/...` could bounce to arbitrary plaintext HTTP on
67
+ # the public internet without the scheme check ever running again.
68
+ def fetch_remote_config_body(url)
69
+ uri = validate_config_uri!(URI.parse(url))
70
+ redirects = 0
71
+
72
+ loop do
73
+ body = nil
74
+ Net::HTTP.start(uri.hostname, uri.port,
75
+ use_ssl: uri.is_a?(URI::HTTPS),
76
+ open_timeout: 10, read_timeout: 30) do |http|
77
+ http.request(Net::HTTP::Get.new(uri)) do |res|
78
+ case res
79
+ when Net::HTTPRedirection
80
+ location = res['location'].to_s
81
+ raise "Redirect with no Location header." if location.empty?
82
+ redirects += 1
83
+ if redirects > REMOTE_CONFIG_MAX_REDIRECTS
84
+ raise "Too many redirects (limit #{REMOTE_CONFIG_MAX_REDIRECTS})."
85
+ end
86
+ uri = validate_config_uri!(URI.join(uri.to_s, location))
87
+ when Net::HTTPSuccess
88
+ buffer = +''
89
+ res.read_body do |chunk|
90
+ buffer << chunk
91
+ if buffer.bytesize > REMOTE_CONFIG_MAX_BYTES
92
+ raise "Config exceeds #{REMOTE_CONFIG_MAX_BYTES} bytes; refusing to buffer more."
93
+ end
94
+ end
95
+ body = buffer
96
+ else
97
+ raise "Config fetch failed: HTTP #{res.code}."
98
+ end
99
+ end
100
+ end
101
+ return body if body
102
+ end
103
+ end
104
+
21
105
  opts = { verbose: false, pry: false }
22
106
  opt_parser = OptionParser.new do |o|
23
107
 
@@ -73,26 +157,33 @@ opt_parser = OptionParser.new do |o|
73
157
  end
74
158
 
75
159
  end
76
- o.on('--url URL', 'Load the env config from a url.') do |url|
160
+ o.on('--url URL', 'Load the env config from an https url.') do |url|
77
161
  begin
78
- puts "Loading config: #{url}"
79
- # SEC-20: do NOT pass a user-supplied string to bare Kernel#open
80
- # `open("|command")` executes a subprocess. Parse an explicit URI and
81
- # require an HTTP(S) scheme, then use open-uri's URI::HTTP#open (a real
82
- # network fetch), never the Kernel form.
83
- uri = URI.parse(url)
84
- unless uri.is_a?(URI::HTTP) # URI::HTTPS < URI::HTTP, so this admits both
85
- raise "Refusing to load config from a non-HTTP(S) URL: #{url.inspect}"
86
- end
87
- json = JSON.load(uri.open)
162
+ # Echo the URL only in escaped form. It is operator-supplied but not yet
163
+ # validated at this point, and a pasted URL is exactly the kind of string
164
+ # that carries a control sequence.
165
+ puts "Loading config: #{Parse::TerminalSafe.sanitize_line(url)}"
166
+ # JSON.parse, never JSON.load: `load` honors `json_class` additions and
167
+ # will instantiate arbitrary loaded classes from the document.
168
+ json = JSON.parse(fetch_remote_config_body(url))
88
169
  raise "Contents not a JSON hash." unless json.is_a?(Hash)
89
- json.each { |k,v| ENV[k.upcase] = v }
170
+ json.each do |k, v|
171
+ key = k.to_s.upcase
172
+ next unless REMOTE_CONFIG_ENV_ALLOWLIST.include?(key)
173
+ unless v.is_a?(String)
174
+ raise "Config key #{key} must be a string, got #{v.class}."
175
+ end
176
+ ENV[key] = v
177
+ end
90
178
  opts[:server_url] ||= ENV['PARSE_SERVER_URL']
91
179
  opts[:app_id] ||= ENV['PARSE_SERVER_APPLICATION_ID'] || ENV['PARSE_APP_ID']
92
180
  opts[:api_key] ||= ENV['PARSE_SERVER_REST_API_KEY'] || ENV['PARSE_API_KEY']
93
181
  opts[:master_key] ||= ENV['PARSE_SERVER_MASTER_KEY'] || ENV['PARSE_MASTER_KEY']
94
182
  rescue Exception => e
95
- $stderr.puts "Error: Invalid JSON format for #{url} (#{e})"
183
+ # The message can quote the fetched document, so escape it: this is the
184
+ # one place where remote bytes reach the operator's terminal.
185
+ $stderr.puts "Error: Invalid JSON format for #{Parse::TerminalSafe.sanitize_line(url)} " \
186
+ "(#{Parse::TerminalSafe.sanitize_line(e.message)})"
96
187
  exit 1
97
188
  end
98
189
  end
@@ -120,8 +211,9 @@ Parse.setup server_url: opts[:server_url],
120
211
  api_key: opts[:api_key],
121
212
  master_key: opts[:master_key]
122
213
  Parse.logging = true if opts[:verbose]
123
- puts "Server : #{Parse.client.server_url}"
124
- puts "App Id : #{Parse.client.app_id}"
214
+ # Both of these can have come from a remote config document, so escape them.
215
+ puts "Server : #{Parse::TerminalSafe.sanitize_line(Parse.client.server_url)}"
216
+ puts "App Id : #{Parse::TerminalSafe.sanitize_line(Parse.client.app_id)}"
125
217
  puts "Master : #{Parse.client.master_key.present?}"
126
218
 
127
219
  if Parse.client.master_key.present?
@@ -208,9 +208,12 @@ def chat_loop(backend: :anthropic)
208
208
  chunks = retrieve(agent, question)
209
209
  answer = ChatAnswerer.public_send(backend, question, chunks)
210
210
 
211
- puts "\n#{answer}\n"
211
+ # The answer is model output grounded in retrieved rows, and the object ids
212
+ # come from the database. Both are untrusted for terminal purposes: escape
213
+ # control sequences before writing them to a TTY.
214
+ puts "\n#{Parse::TerminalSafe.sanitize(answer)}\n"
212
215
  sources = chunks.map { |c| c.dig(:metadata, :object_id) }.uniq.join(", ")
213
- puts " (sources: #{sources})\n\n"
216
+ puts " (sources: #{Parse::TerminalSafe.sanitize_line(sources)})\n\n"
214
217
  end
215
218
  end
216
219
 
@@ -6,6 +6,7 @@ require "uri"
6
6
  require "json"
7
7
  require "securerandom"
8
8
  require_relative "mcp_dispatcher"
9
+ require_relative "../terminal_safe"
9
10
 
10
11
  module Parse
11
12
  class Agent
@@ -65,17 +66,25 @@ module Parse
65
66
  end
66
67
 
67
68
  # Pretty-print for IRB: tool trace, answer, then per-call usage line.
69
+ #
70
+ # Every interpolated part is attacker-influenced. The answer is LLM
71
+ # output that was itself conditioned on tenant rows, and tool arguments
72
+ # can echo stored values. Merely evaluating `mcp.ask(...)` in IRB writes
73
+ # this string to the terminal, so control sequences are escaped here.
74
+ # `text` itself is untouched: callers that render into a non-terminal
75
+ # surface still get the exact bytes.
68
76
  def to_s
69
77
  parts = []
70
78
  if tool_calls.any?
71
79
  parts << "─── tool calls (#{tool_calls.size}) ───"
72
80
  tool_calls.each_with_index do |tc, i|
73
81
  args_str = tc[:arguments].is_a?(Hash) ? tc[:arguments].inspect : tc[:arguments].to_s
74
- parts << " #{i + 1}. #{tc[:name]}(#{args_str})"
82
+ parts << " #{i + 1}. #{Parse::TerminalSafe.sanitize_line(tc[:name])}" \
83
+ "(#{Parse::TerminalSafe.sanitize_line(args_str)})"
75
84
  end
76
85
  end
77
86
  parts << "─── answer ───"
78
- parts << text.to_s
87
+ parts << Parse::TerminalSafe.sanitize(text)
79
88
  parts << "─── usage ───" << " #{usage}" if usage && usage.total_tokens.positive?
80
89
  parts.join("\n")
81
90
  end
@@ -311,6 +320,39 @@ module Parse
311
320
 
312
321
  private
313
322
 
323
+ # Maximum bytes of a provider response body quoted back in an exception.
324
+ LLM_ERROR_BODY_CAP = 2_000
325
+
326
+ # Check the HTTP status and parse the body, raising with a terminal-safe
327
+ # message on either failure.
328
+ #
329
+ # The provider's response body is untrusted output on both paths. An
330
+ # error body is echoed into the exception message, and a malformed
331
+ # success body produces a `JSON::ParserError` whose message quotes the
332
+ # offending bytes verbatim. Either exception is printed raw by IRB and by
333
+ # most logging setups, so an LLM endpoint (or a model repeating what a
334
+ # tenant row told it to say) could otherwise still land control sequences
335
+ # on the operator's terminal through the failure path.
336
+ #
337
+ # @param res [Net::HTTPResponse]
338
+ # @param label [String] provider name used in the message.
339
+ # @return [Hash] the parsed body.
340
+ def parse_llm_response!(res, label)
341
+ body = res.body.to_s
342
+ unless res.code.to_i.between?(200, 299)
343
+ quoted = Parse::TerminalSafe.sanitize_line(body[0, LLM_ERROR_BODY_CAP])
344
+ raise "#{label} failed: HTTP #{res.code} #{quoted}"
345
+ end
346
+
347
+ begin
348
+ JSON.parse(body)
349
+ rescue JSON::ParserError => e
350
+ raise JSON::ParserError,
351
+ "#{label} returned an unparseable body: " \
352
+ "#{Parse::TerminalSafe.sanitize_line(e.message)[0, LLM_ERROR_BODY_CAP]}"
353
+ end
354
+ end
355
+
314
356
  # Fetch the agent's MCP tool catalog and translate it into the LLM's
315
357
  # native function-calling schema. Cached per call (could be memoized
316
358
  # if tool lists grow large, but they're usually small).
@@ -447,11 +489,7 @@ module Parse
447
489
  res = Net::HTTP.start(uri.hostname, uri.port,
448
490
  use_ssl: uri.scheme == "https",
449
491
  read_timeout: @timeout) { |h| h.request(req) }
450
- unless res.code.to_i.between?(200, 299)
451
- raise "LLM call failed: HTTP #{res.code} #{res.body}"
452
- end
453
-
454
- parsed = JSON.parse(res.body)
492
+ parsed = parse_llm_response!(res, "LLM call")
455
493
  msg = parsed.dig("choices", 0, "message") || {}
456
494
  calls = Array(msg["tool_calls"]).map do |tc|
457
495
  args = tc.dig("function", "arguments")
@@ -497,11 +535,7 @@ module Parse
497
535
  res = Net::HTTP.start(uri.hostname, uri.port,
498
536
  use_ssl: uri.scheme == "https",
499
537
  read_timeout: @timeout) { |h| h.request(req) }
500
- unless res.code.to_i.between?(200, 299)
501
- raise "Anthropic call failed: HTTP #{res.code} #{res.body}"
502
- end
503
-
504
- parsed = JSON.parse(res.body)
538
+ parsed = parse_llm_response!(res, "Anthropic call")
505
539
  blocks = Array(parsed["content"])
506
540
  text = blocks.select { |b| b["type"] == "text" }.map { |b| b["text"] }.join("\n")
507
541
  calls = blocks.select { |b| b["type"] == "tool_use" }.map do |b|
@@ -9,6 +9,7 @@ require "active_support/core_ext"
9
9
  require "active_model/serializers/json"
10
10
  require "json"
11
11
  require "set"
12
+ require_relative "../terminal_safe"
12
13
 
13
14
  module Parse
14
15
 
@@ -317,17 +318,23 @@ module Parse
317
318
  env[:body] = env[:body].to_json
318
319
  end
319
320
 
321
+ # `Parse.logging = true` routes here, so this legacy printer sees the
322
+ # same tenant-controlled bytes the Faraday logging middleware does and
323
+ # needs the same terminal-escape handling.
320
324
  if self.class.logging
321
- puts "[Request #{env.method.upcase}] #{self.class.redact(env[:url].to_s)}"
325
+ puts "[Request #{env.method.upcase}] " \
326
+ "#{Parse::TerminalSafe.sanitize_line(self.class.redact(env[:url].to_s))}"
322
327
  env[:request_headers].each do |k, v|
323
328
  if REDACTED_HEADERS.include?(k.to_s.downcase)
324
- puts "[Header] #{k} : [FILTERED]"
329
+ puts "[Header] #{Parse::TerminalSafe.sanitize_line(k)} : [FILTERED]"
325
330
  else
326
- puts "[Header] #{k} : #{v}"
331
+ puts "[Header] #{Parse::TerminalSafe.sanitize_line(k)} : " \
332
+ "#{Parse::TerminalSafe.sanitize_line(v)}"
327
333
  end
328
334
  end
329
335
 
330
- puts "[Request Body] #{self.class.redact(env[:body].to_s)}"
336
+ puts "[Request Body] " \
337
+ "#{Parse::TerminalSafe.sanitize_line(self.class.redact(env[:body].to_s))}"
331
338
  end
332
339
  @app.call(env).on_complete do |response_env|
333
340
  # on a response, create a new Parse::Response and replace the :body
@@ -335,7 +342,7 @@ module Parse
335
342
  # @todo CHECK FOR HTTP STATUS CODES
336
343
  if self.class.logging
337
344
  puts "[[Response #{response_env[:status]}]] ----------------------------------"
338
- puts self.class.redact(response_env.body.to_s)
345
+ puts Parse::TerminalSafe.sanitize(self.class.redact(response_env.body.to_s))
339
346
  puts "[[Response]] --------------------------------------\n"
340
347
  end
341
348
 
@@ -4,6 +4,7 @@
4
4
  require "faraday"
5
5
  require "logger"
6
6
  require_relative "url_redaction"
7
+ require_relative "../terminal_safe"
7
8
 
8
9
  module Parse
9
10
  module Middleware
@@ -167,7 +168,8 @@ module Parse
167
168
  if Parse::Middleware::BodyBuilder::REDACTED_HEADERS.include?(key.to_s.downcase)
168
169
  logger.debug " [#{prefix} Header] #{key}: [FILTERED]"
169
170
  else
170
- logger.debug " [#{prefix} Header] #{key}: #{value}"
171
+ logger.debug " [#{prefix} Header] #{Parse::TerminalSafe.sanitize_line(key)}: " \
172
+ "#{Parse::TerminalSafe.sanitize_line(value)}"
171
173
  end
172
174
  end
173
175
  end
@@ -196,6 +198,13 @@ module Parse
196
198
  # so truncation can't split a token across the boundary and slip past.
197
199
  content = Parse::Middleware::BodyBuilder.redact(content)
198
200
 
201
+ # Request and response bodies carry tenant-stored values verbatim. A
202
+ # stored ESC sequence would execute against the operator's terminal the
203
+ # moment they tail the log, so escape control characters and newlines
204
+ # here. Done BEFORE the length cap so the record is one line whether or
205
+ # not it was truncated.
206
+ content = Parse::TerminalSafe.sanitize_line(content)
207
+
199
208
  if content.length > max_length
200
209
  logger.debug " [#{prefix} Body] #{content[0...max_length]}... (truncated, #{content.length} total)"
201
210
  elsif content.length > 0
@@ -216,15 +225,21 @@ module Parse
216
225
  end
217
226
  end
218
227
 
228
+ # The error text is whatever the server (or a stored value echoed back by
229
+ # the server) says, so it is untrusted. Escape terminal control sequences
230
+ # AND newlines: this is interpolated into a one-line log record, and a
231
+ # raw LF would let the text forge a second, attacker-authored entry.
219
232
  def error_summary(response_env)
220
233
  body = response_env[:body]
221
- if body.is_a?(Parse::Response) && body.error?
222
- "#{body.code}: #{body.error}"
223
- elsif body.is_a?(Hash)
224
- body["error"] || body[:error] || "Unknown error"
225
- else
226
- "HTTP #{response_env[:status]}"
227
- end
234
+ summary =
235
+ if body.is_a?(Parse::Response) && body.error?
236
+ "#{body.code}: #{body.error}"
237
+ elsif body.is_a?(Hash)
238
+ body["error"] || body[:error] || "Unknown error"
239
+ else
240
+ "HTTP #{response_env[:status]}"
241
+ end
242
+ Parse::TerminalSafe.sanitize_line(summary)
228
243
  end
229
244
 
230
245
  def sanitize_url(url)
data/lib/parse/client.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "faraday"
4
+ require_relative "terminal_safe"
4
5
 
5
6
  # Attempt to load the persistent connection adapter for better performance.
6
7
  # Falls back gracefully to the default adapter if not available.
@@ -528,11 +529,19 @@ module Parse
528
529
  # @param name [String, nil] optional cloud-function or job name for context.
529
530
  # @return [nil]
530
531
  def _safe_warn(tag, response, name: nil)
532
+ # The server's error text and the request description both carry stored
533
+ # values through verbatim, and this lands in a log file or on a
534
+ # terminal. Escape control characters and newlines so a stored value
535
+ # can neither drive the terminal nor forge a second log record.
531
536
  err = Parse::Middleware::BodyBuilder.redact(response.error.to_s)[0, SAFE_WARN_MAX_ERROR_LENGTH]
537
+ err = Parse::TerminalSafe.sanitize_line(err)
532
538
  msg = if name
533
- "[Parse:#{tag}] `#{name}` [#{response.code}] #{err} (HTTP #{response.http_status})"
539
+ "[Parse:#{tag}] `#{Parse::TerminalSafe.sanitize_line(name)}` " \
540
+ "[#{response.code}] #{err} (HTTP #{response.http_status})"
534
541
  else
535
- "[Parse:#{tag}] [E-#{response.code}] #{response.request} : #{err} (#{response.http_status})"
542
+ "[Parse:#{tag}] [E-#{response.code}] " \
543
+ "#{Parse::TerminalSafe.sanitize_line(response.request)} : #{err} " \
544
+ "(#{response.http_status})"
536
545
  end
537
546
  logger = Parse::Middleware::Logging.logger
538
547
  if logger
@@ -1415,6 +1424,30 @@ module Parse
1415
1424
  retry
1416
1425
  end
1417
1426
  raise
1427
+ rescue Faraday::ConnectionFailed => e
1428
+ # `Faraday::ConnectionFailed` covers two very different failures under
1429
+ # one class, so it is split on the wrapped cause (see
1430
+ # #connection_reset_error?):
1431
+ #
1432
+ # - RESET mid-flight (`Errno::ECONNRESET` / `Errno::EPIPE` /
1433
+ # `Errno::ECONNABORTED` / `EOFError`): the classic stale
1434
+ # keep-alive failure. A pooled persistent connection idled past
1435
+ # the server's (or an LB's) keep-alive window and was closed
1436
+ # remotely, and the next request on it dies at the socket.
1437
+ # Transient by nature (a fresh connection succeeds immediately),
1438
+ # so it retries under the same idempotency rules as a read
1439
+ # timeout: the outcome is unknown, so only idempotent requests
1440
+ # are re-sent.
1441
+ #
1442
+ # - REFUSED (and DNS failure): the server is down or misconfigured.
1443
+ # Retrying only adds backoff latency and `[Parse:Retry]` noise
1444
+ # before the inevitable error, so it propagates raw and fast.
1445
+ raise unless connection_reset_error?(e)
1446
+ if _retry_count > 0 && idempotent_retry?(method, body, headers)
1447
+ _retry_count = consume_retry_with_backoff(_retry_count, _retry_max, _request)
1448
+ retry
1449
+ end
1450
+ raise Parse::Error::ConnectionError, "#{_request} : #{e.class} - #{e.message}"
1418
1451
  rescue Faraday::ClientError, Faraday::TimeoutError, Net::OpenTimeout => e
1419
1452
  # Request timed out mid-flight: the outcome is unknown (the server may
1420
1453
  # have received and applied the write but never answered), so only
@@ -1422,25 +1455,74 @@ module Parse
1422
1455
  #
1423
1456
  # Faraday 2.x raises `Faraday::TimeoutError` for a read timeout
1424
1457
  # (`Timeout::Error` / `Errno::ETIMEDOUT`); it subclasses `Faraday::Error`,
1425
- # not `ClientError`, so it must be listed explicitly to be caught. We
1426
- # deliberately do NOT catch `Faraday::ConnectionFailed` (connection
1427
- # refused/reset, plus the wrapped connect-timeout): refused is a
1428
- # non-transient "server down / misconfigured" failure, and auto-retrying
1429
- # it only adds backoff latency before the inevitable error. Broadening to
1430
- # reset connections safely (retry reset, fail fast on refused) is tracked
1431
- # as a follow-up.
1458
+ # not `ClientError`, so it must be listed explicitly to be caught.
1459
+ # `Faraday::ConnectionFailed` is handled in its own rescue above,
1460
+ # split into retry-reset / fail-fast-refused.
1432
1461
  if _retry_count > 0 && idempotent_retry?(method, body, headers)
1433
- warn "[Parse:Retry] Retries remaining #{_retry_count} : #{_request}"
1434
- _retry_count -= 1
1435
- backoff_delay = RETRY_DELAY * (_retry_max - _retry_count)
1436
- _retry_delay = backoff_delay * (0.75 + rand * 0.5)
1437
- sleep _retry_delay if _retry_delay > 0
1462
+ _retry_count = consume_retry_with_backoff(_retry_count, _retry_max, _request)
1438
1463
  retry
1439
1464
  end
1440
1465
  raise Parse::Error::ConnectionError, "#{_request} : #{e.class} - #{e.message}"
1441
1466
  end
1442
1467
  end
1443
1468
 
1469
+ # Consumes one attempt from the retry budget: logs the remaining count,
1470
+ # sleeps the linear backoff (RETRY_DELAY x attempt number, +/-25% jitter,
1471
+ # never zero), and returns the decremented budget. Shared by the
1472
+ # connection-reset and timeout rescue branches in {#request} so their
1473
+ # backoff behavior cannot drift apart; the `retry` keyword itself must
1474
+ # stay lexically inside each rescue clause, so it remains at the call
1475
+ # sites. The 429/503 branch keeps its own inline version because it also
1476
+ # honors a server-supplied Retry-After header.
1477
+ # @param retry_count [Integer] the remaining retry budget (must be > 0).
1478
+ # @param retry_max [Integer] the effective starting budget.
1479
+ # @param request [Parse::Request] the request being retried (for logging).
1480
+ # @return [Integer] the decremented retry budget.
1481
+ def consume_retry_with_backoff(retry_count, retry_max, request)
1482
+ warn "[Parse:Retry] Retries remaining #{retry_count} : #{request}"
1483
+ retry_count -= 1
1484
+ backoff_delay = RETRY_DELAY * (retry_max - retry_count)
1485
+ retry_delay = backoff_delay * (0.75 + rand * 0.5)
1486
+ sleep retry_delay if retry_delay > 0
1487
+ retry_count
1488
+ end
1489
+
1490
+ # The wrapped causes that mark a `Faraday::ConnectionFailed` as a RESET
1491
+ # connection (transient, retry-safe for idempotent requests) rather than a
1492
+ # REFUSED one (server down, fail fast). `EOFError` is what net/http raises
1493
+ # when the remote end closes a keep-alive socket cleanly between requests;
1494
+ # ECONNRESET/EPIPE/ECONNABORTED are the unclean variants.
1495
+ # @!visibility private
1496
+ CONNECTION_RESET_CAUSES = [
1497
+ Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED, EOFError,
1498
+ ].freeze
1499
+
1500
+ # Message fallback for adapters that raise `Faraday::ConnectionFailed`
1501
+ # with the cause flattened into the message instead of wrapped.
1502
+ # @!visibility private
1503
+ CONNECTION_RESET_MESSAGE = /connection reset|broken pipe|end of file reached/i
1504
+
1505
+ # Whether a `Faraday::ConnectionFailed` was caused by a reset/dropped
1506
+ # connection (retryable) as opposed to connection-refused or a DNS
1507
+ # failure (fail fast). Walks the wrapped exception and the `#cause`
1508
+ # chain looking for a reset-class error.
1509
+ # @param error [Exception] the rescued `Faraday::ConnectionFailed`.
1510
+ # @return [Boolean]
1511
+ def connection_reset_error?(error)
1512
+ inner = error.respond_to?(:wrapped_exception) ? error.wrapped_exception : nil
1513
+ inner ||= error.cause
1514
+ seen = 0
1515
+ while inner && seen < 8
1516
+ return true if CONNECTION_RESET_CAUSES.any? { |klass| inner.is_a?(klass) }
1517
+ inner = inner.cause
1518
+ seen += 1
1519
+ end
1520
+ CONNECTION_RESET_MESSAGE.match?(error.message.to_s)
1521
+ end
1522
+
1523
+ private :consume_retry_with_backoff, :connection_reset_error?
1524
+ private_constant :CONNECTION_RESET_CAUSES, :CONNECTION_RESET_MESSAGE
1525
+
1444
1526
  # Whether a request whose outcome is UNKNOWN (a 500/503 or a dropped
1445
1527
  # connection) is safe to transparently re-send.
1446
1528
  #
data/lib/parse/console.rb CHANGED
@@ -23,6 +23,7 @@
23
23
  # tests / fixtures.
24
24
 
25
25
  require "timeout"
26
+ require_relative "terminal_safe"
26
27
 
27
28
  module Parse
28
29
  module Console
@@ -65,7 +66,10 @@ module Parse
65
66
  events = Array(on || DEFAULT_WATCH_EVENTS).map(&:to_sym)
66
67
  printer = block_given? ? block : ->(ev, obj) {
67
68
  title = obj.respond_to?(:id) ? obj.id : obj.inspect
68
- puts "[#{Time.now.iso8601}] #{klass.parse_class}.#{ev} #{title}"
69
+ # The row is tenant data arriving over a live-query socket and this
70
+ # line goes straight to the operator's terminal, so escape it.
71
+ puts "[#{Time.now.iso8601}] #{klass.parse_class}.#{ev} " \
72
+ "#{Parse::TerminalSafe.sanitize_line(title)}"
69
73
  }
70
74
 
71
75
  delivered = 0
@@ -78,11 +82,13 @@ module Parse
78
82
  begin
79
83
  printer.call(ev, obj)
80
84
  rescue StandardError => e
81
- warn "[Parse.watch] handler raised #{e.class}: #{e.message}"
85
+ # The message can quote the row that triggered it.
86
+ warn "[Parse.watch] handler raised #{e.class}: " \
87
+ "#{Parse::TerminalSafe.sanitize_line(e.message)}"
82
88
  end
83
89
  end
84
90
  end
85
- sub.on(:error) { |err| warn "[Parse.watch] error: #{err}" }
91
+ sub.on(:error) { |err| warn "[Parse.watch] error: #{Parse::TerminalSafe.sanitize_line(err)}" }
86
92
 
87
93
  _block_until_interrupt
88
94
  delivered
data/lib/parse/query.rb CHANGED
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "client"
5
+ require_relative "terminal_safe"
5
6
  require_relative "pipeline_security"
6
7
  require_relative "query/operation"
7
8
  require_relative "query/constraints"
@@ -1782,7 +1783,7 @@ module Parse
1782
1783
  def fetch!(compiled_query)
1783
1784
  response = client.find_objects(@table, compiled_query.as_json, headers: _headers, **_opts)
1784
1785
  if response.error?
1785
- puts "[ParseQuery] #{response.error}"
1786
+ puts "[ParseQuery] #{Parse::TerminalSafe.sanitize_line(response.error)}"
1786
1787
  end
1787
1788
  response
1788
1789
  end
@@ -3596,12 +3597,12 @@ module Parse
3596
3597
  # non-master explain that worked on 8.x now returns a permission
3597
3598
  # error. Surface that as actionable guidance instead of a bare 403.
3598
3599
  if response.respond_to?(:permission_denied?) && response.permission_denied?
3599
- puts "[ParseQuery:Explain] #{response.error} — Parse Server 9.0+ defaults " \
3600
+ puts "[ParseQuery:Explain] #{Parse::TerminalSafe.sanitize_line(response.error)} — Parse Server 9.0+ defaults " \
3600
3601
  "`allowPublicExplain` to false; query explain now requires the master key " \
3601
3602
  "(use_master_key: true) or `allowPublicExplain: true` in the server's " \
3602
3603
  "databaseOptions."
3603
3604
  else
3604
- puts "[ParseQuery:Explain] #{response.error}"
3605
+ puts "[ParseQuery:Explain] #{Parse::TerminalSafe.sanitize_line(response.error)}"
3605
3606
  end
3606
3607
  return {}
3607
3608
  end
@@ -6,6 +6,6 @@ module Parse
6
6
  # The Parse Server SDK for Ruby
7
7
  module Stack
8
8
  # The current version.
9
- VERSION = "5.7.2"
9
+ VERSION = "5.7.4"
10
10
  end
11
11
  end
data/lib/parse/stack.rb CHANGED
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "stack/version"
5
+ require_relative "terminal_safe"
5
6
  require_relative "client"
6
7
  require_relative "query"
7
8
  require_relative "model/object"
@@ -0,0 +1,138 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ # Neutralizes terminal control sequences in untrusted text before it is
6
+ # written to a terminal, a log record, or an IRB `inspect` line.
7
+ #
8
+ # Parse Server returns whatever a tenant stored. Any string that originates
9
+ # in the database, in a server error message, or in an LLM answer is
10
+ # attacker-influenced, and a raw ESC byte in that string is not inert once it
11
+ # reaches a TTY. It can clear the screen, rewrite lines the operator has
12
+ # already read, retitle the window, or (via OSC 52) write the attacker's
13
+ # payload into the system clipboard so the operator's next paste runs it.
14
+ # Bidirectional overrides are the same class of problem: they reorder a line
15
+ # so what the operator reads is not what the bytes say.
16
+ #
17
+ # This module is the SDK's single answer to that. It escapes rather than
18
+ # deletes, so the offending bytes stay visible in the output (rendered as
19
+ # `\e`, `\u202E`, and friends) and an operator can still see that
20
+ # something tried.
21
+ #
22
+ # It sanitizes *rendering*, never storage. `result.text`, `object.title`, and
23
+ # the parsed response body keep their exact bytes. Only the human-readable
24
+ # form built for a terminal or a log line runs through here.
25
+ #
26
+ # @example Rendering an untrusted value
27
+ # puts Parse::TerminalSafe.sanitize(post.title)
28
+ #
29
+ # @example A single-line log record. Newlines are escaped too, so a stored
30
+ # value cannot forge a second log entry.
31
+ # logger.warn "#{status} - #{Parse::TerminalSafe.sanitize_line(error)}"
32
+ module TerminalSafe
33
+ extend self
34
+
35
+ # Codepoint ranges neutralized on every call:
36
+ #
37
+ # - 0x00-0x08, 0x0B-0x1F: C0 controls except TAB (0x09) and LF (0x0A).
38
+ # This is where ESC (0x1B, the CSI/OSC/DCS introducer), BEL (0x07, the
39
+ # OSC string terminator), CR (0x0D, overwrite-the-line), and BS (0x08,
40
+ # erase-the-previous-character) live.
41
+ # - 0x7F: DEL.
42
+ # - 0x80-0x9F: C1 controls, the 8-bit forms of the same introducers
43
+ # (0x9B is CSI, 0x9D is OSC, 0x90 is DCS).
44
+ # - 0x061C: Arabic letter mark, an implicit bidirectional control that
45
+ # reorders a line exactly like the explicit marks below.
46
+ # - 0x200B-0x200F: zero-width space, non-joiner, joiner, LRM, RLM.
47
+ # - 0x2028-0x2029: line and paragraph separator. Widely treated as a line
48
+ # break by terminals, editors, and log readers, so leaving them intact
49
+ # would reintroduce the forged-record problem that escaping LF solves.
50
+ # A legitimate line break is LF, which is preserved.
51
+ # - 0x202A-0x202E: bidirectional embedding and override.
52
+ # - 0x2060-0x2064: word joiner and the invisible math operators.
53
+ # - 0x2066-0x2069: the bidirectional isolates.
54
+ # - 0xFEFF: zero-width no-break space (a BOM appearing mid-string).
55
+ #
56
+ # TAB and LF are deliberately absent: both are ordinary formatting in
57
+ # multi-line output. {#sanitize_line} escapes LF as well.
58
+ UNSAFE_RANGES = [
59
+ 0x00..0x08, 0x0B..0x1F, 0x7F..0x9F,
60
+ 0x061C..0x061C,
61
+ 0x200B..0x200F, 0x2028..0x2029, 0x202A..0x202E,
62
+ 0x2060..0x2064, 0x2066..0x2069,
63
+ 0xFEFF..0xFEFF,
64
+ ].freeze
65
+
66
+ # Built from codepoints rather than written as literal escapes so the
67
+ # source of this file stays free of the very bytes it defends against.
68
+ def self.build_pattern(ranges)
69
+ body = ranges.map { |r| format("\\u{%04X}-\\u{%04X}", r.first, r.last) }.join
70
+ Regexp.new("[#{body}]")
71
+ end
72
+ private_class_method :build_pattern
73
+
74
+ UNSAFE_RE = build_pattern(UNSAFE_RANGES)
75
+
76
+ # The same set plus LF, for output that must occupy exactly one line.
77
+ UNSAFE_LINE_RE = build_pattern(UNSAFE_RANGES + [0x0A..0x0A])
78
+
79
+ # Readable escapes for the controls an operator is most likely to see.
80
+ # Everything else falls back to `\xNN` or `\uNNNN`.
81
+ NAMED_ESCAPES = {
82
+ "\0" => "\\0",
83
+ "\a" => "\\a",
84
+ "\b" => "\\b",
85
+ "\n" => "\\n",
86
+ "\v" => "\\v",
87
+ "\f" => "\\f",
88
+ "\r" => "\\r",
89
+ "\e" => "\\e",
90
+ }.freeze
91
+
92
+ # Escape terminal control sequences in `str`, preserving newlines and tabs.
93
+ #
94
+ # @param str [String, #to_s, nil] untrusted text.
95
+ # @return [String] the same text with control characters escaped. Non-UTF-8
96
+ # and invalid-encoding input is coerced to UTF-8 first, so this never
97
+ # raises on a binary response body.
98
+ def sanitize(str)
99
+ escape(str, UNSAFE_RE)
100
+ end
101
+
102
+ # Escape terminal control sequences and newlines, so untrusted text cannot
103
+ # forge additional lines. Use for anything written as a single log record
104
+ # or a single console line.
105
+ #
106
+ # @param str [String, #to_s, nil] untrusted text.
107
+ # @return [String]
108
+ def sanitize_line(str)
109
+ escape(str, UNSAFE_LINE_RE)
110
+ end
111
+
112
+ private
113
+
114
+ def escape(str, pattern)
115
+ s = coerce(str)
116
+ return s unless s.match?(pattern)
117
+ s.gsub(pattern) { |ch| escape_char(ch) }
118
+ end
119
+
120
+ # Force the input to valid UTF-8 without raising. A response body read off
121
+ # the wire can be ASCII-8BIT, and a truncated multi-byte sequence is not
122
+ # valid UTF-8. Either would make `match?` raise ArgumentError, which is
123
+ # exactly the wrong outcome for a defensive sanitizer.
124
+ def coerce(str)
125
+ s = str.is_a?(String) ? str : str.to_s
126
+ s = s.dup.force_encoding(Encoding::UTF_8) unless s.encoding == Encoding::UTF_8
127
+ s = s.scrub("�") unless s.valid_encoding?
128
+ s
129
+ end
130
+
131
+ def escape_char(ch)
132
+ named = NAMED_ESCAPES[ch]
133
+ return named if named
134
+ cp = ch.ord
135
+ cp <= 0xFF ? format("\\x%02X", cp) : format("\\u%04X", cp)
136
+ end
137
+ end
138
+ end
@@ -11,6 +11,7 @@ require "active_model/serializers/json"
11
11
  require "rack"
12
12
  require "ostruct"
13
13
  require_relative "client"
14
+ require_relative "terminal_safe"
14
15
  # Note: Do not require "stack" here - this file is loaded from stack.rb
15
16
  # and adding that require would create a circular dependency.
16
17
  require_relative "model/object"
@@ -326,7 +327,10 @@ module Parse
326
327
  # @param error [StandardError] the raised error.
327
328
  # @return [void]
328
329
  def report_handler_error(type, error)
329
- warn "[Parse::Webhooks] #{type} handler raised #{error.class}: #{error.message}; " \
330
+ # The handler's message is application-authored but routinely quotes the
331
+ # payload that triggered it, which is caller-controlled.
332
+ warn "[Parse::Webhooks] #{type} handler raised #{error.class}: " \
333
+ "#{Parse::TerminalSafe.sanitize_line(error.message)}; " \
330
334
  "continuing with the remaining handlers " \
331
335
  "(Parse::Webhooks.abort_after_callbacks_on_error is false)"
332
336
  return unless defined?(ActiveSupport::Notifications)
@@ -671,9 +675,9 @@ module Parse
671
675
  # record contents/tokens, and the rest of this file routes log output
672
676
  # through the same redactor.
673
677
  warn "[Parse::Webhooks] afterSave #{phase} callback raised for " \
674
- "#{obj.class}##{obj.id} -- the object is already persisted; " \
675
- "logging and continuing: #{e.class}: " \
676
- "#{Parse::Middleware::BodyBuilder.redact(e.message)}"
678
+ "#{obj.class}##{Parse::TerminalSafe.sanitize_line(obj.id)} -- the object is " \
679
+ "already persisted; logging and continuing: #{e.class}: " \
680
+ "#{Parse::TerminalSafe.sanitize_line(Parse::Middleware::BodyBuilder.redact(e.message))}"
677
681
  nil
678
682
  end
679
683
 
@@ -853,20 +857,29 @@ module Parse
853
857
  begin
854
858
  payload = Parse::Webhooks::Payload.new(body_str, webhook_class)
855
859
  rescue => e
856
- warn "Invalid webhook payload format: #{e}"
860
+ warn "Invalid webhook payload format: #{Parse::TerminalSafe.sanitize_line(e.to_s)}"
857
861
  response.write error("Invalid payload format. Should be valid JSON.")
858
862
  return response.finish
859
863
  end
860
864
 
861
865
  if self.logging.present?
866
+ # Everything interpolated below arrives in the webhook request body:
867
+ # the trigger/function names, the object id, and the whole payload are
868
+ # caller-controlled, and these lines go to the app server's console.
869
+ # Escape control sequences so a stored value cannot drive the terminal
870
+ # of whoever is tailing the log.
862
871
  if payload.trigger?
863
- puts "[Webhooks::Request] --> #{payload.trigger_name} #{payload.parse_class}:#{payload.parse_id}"
872
+ puts "[Webhooks::Request] --> #{Parse::TerminalSafe.sanitize_line(payload.trigger_name)} " \
873
+ "#{Parse::TerminalSafe.sanitize_line(payload.parse_class)}:" \
874
+ "#{Parse::TerminalSafe.sanitize_line(payload.parse_id)}"
864
875
  elsif payload.function?
865
- puts "[ParseWebhooks Request] --> Function #{payload.function_name}"
876
+ puts "[ParseWebhooks Request] --> Function #{Parse::TerminalSafe.sanitize_line(payload.function_name)}"
866
877
  end
867
878
  if self.logging == :debug
868
879
  puts "[Webhooks::Payload] ----------------------------"
869
- puts Parse::Middleware::BodyBuilder.redact(payload.as_json.to_json)
880
+ puts Parse::TerminalSafe.sanitize(
881
+ Parse::Middleware::BodyBuilder.redact(payload.as_json.to_json)
882
+ )
870
883
  puts "----------------------------------------------------\n"
871
884
  end
872
885
  end
@@ -891,14 +904,14 @@ module Parse
891
904
  else
892
905
  if self.logging.present?
893
906
  puts "[Webhooks] --> Could not find mapping route for " \
894
- "#{Parse::Middleware::BodyBuilder.redact(payload.to_json)}"
907
+ "#{Parse::TerminalSafe.sanitize_line(Parse::Middleware::BodyBuilder.redact(payload.to_json))}"
895
908
  end
896
909
  end
897
910
 
898
911
  result = true if result.nil?
899
912
  if self.logging.present?
900
913
  puts "[Webhooks::Response] ----------------------------"
901
- puts success(result)
914
+ puts Parse::TerminalSafe.sanitize(success(result))
902
915
  puts "----------------------------------------------------\n"
903
916
  end
904
917
  response.write success(result)
@@ -909,9 +922,13 @@ module Parse
909
922
  return response.finish
910
923
  rescue Parse::Webhooks::ResponseError, ActiveModel::ValidationError => e
911
924
  if payload.trigger?
912
- puts "[Webhooks::ResponseError] >> #{payload.trigger_name} #{payload.parse_class}:#{payload.parse_id}: #{e}"
925
+ puts "[Webhooks::ResponseError] >> #{Parse::TerminalSafe.sanitize_line(payload.trigger_name)} " \
926
+ "#{Parse::TerminalSafe.sanitize_line(payload.parse_class)}:" \
927
+ "#{Parse::TerminalSafe.sanitize_line(payload.parse_id)}: " \
928
+ "#{Parse::TerminalSafe.sanitize_line(e.to_s)}"
913
929
  elsif payload.function?
914
- puts "[Webhooks::ResponseError] >> #{payload.function_name}: #{e}"
930
+ puts "[Webhooks::ResponseError] >> #{Parse::TerminalSafe.sanitize_line(payload.function_name)}: " \
931
+ "#{Parse::TerminalSafe.sanitize_line(e.to_s)}"
915
932
  end
916
933
  response.write error(e.to_s)
917
934
  return response.finish
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parse-stack-next
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.7.2
4
+ version: 5.7.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Curtin
@@ -422,6 +422,7 @@ files:
422
422
  - lib/parse/stack/railtie.rb
423
423
  - lib/parse/stack/tasks.rb
424
424
  - lib/parse/stack/version.rb
425
+ - lib/parse/terminal_safe.rb
425
426
  - lib/parse/two_factor_auth.rb
426
427
  - lib/parse/two_factor_auth/user_extension.rb
427
428
  - lib/parse/vector_search.rb