parse-stack-next 5.7.2 → 5.7.3

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: e4017efda309de14faf52b86e26274cc097ee029d282a975f0144e324ac68250
4
+ data.tar.gz: e46cbf422955acc46ef7ceff8348a564712a9109051511dfec1bd2dce96356f0
5
5
  SHA512:
6
- metadata.gz: 1ffa5266044c369c673c8c5e4e57d4b37d882674eae6d8f7c79f3b1a8318a75dbd76d97036337d8a5416cfdcc022c1db3353e20de53857eaf8e37ac29913dfa7
7
- data.tar.gz: e897bb63c7030751cbb2a9720e6fde91e23f6c118a6656e9063a84df4ec7305db1e3f8dd141e600f2e9765fb3660fdd26b0c36c2c321184c9743c6be976498c9
6
+ metadata.gz: 1cee46e278c9ad77fab0d4f2869636c623b43e9e1b7d2ea0ea77f92be9ad8f6631bb6e5f92421997f92d42ba73470db659486595fe0993a50de93140eb6051fb
7
+ data.tar.gz: 36322b4f93132dd277476d999fc71511324d5a726979896ae08e4f6aea31d3aa4454fd41910a1c7758d229c9ff1000cb43de12ffaf8f795403e1ab7cf96f71ef
data/CHANGELOG.md CHANGED
@@ -1,5 +1,82 @@
1
1
  ## parse-stack-next Changelog
2
2
 
3
+ ### 5.7.3
4
+
5
+ #### Stored values can no longer drive the operator's terminal
6
+
7
+ - **NEW**: `Parse::TerminalSafe` is a canonical sanitizer for untrusted text
8
+ that is about to be written to a terminal, a log record, or an IRB `inspect`
9
+ line. `Parse::TerminalSafe.sanitize(str)` escapes ESC, BEL, backspace,
10
+ carriage return, the remaining C0 controls, DEL, the C1 controls (the 8-bit
11
+ CSI/OSC/DCS introducers, which a sanitizer that only looks for `0x1B`
12
+ misses), the zero-width characters, and the Unicode bidirectional overrides
13
+ and isolates. Tabs and newlines are preserved.
14
+ `Parse::TerminalSafe.sanitize_line(str)` escapes newlines and the Unicode
15
+ line and paragraph separators as well, for text interpolated into a single
16
+ log record. Control characters are escaped rather than deleted, so an
17
+ operator can still see that something tried. Non-UTF-8 and invalid-encoding
18
+ input is coerced first, so the sanitizer never raises on a binary response
19
+ body.
20
+ - **FIXED**: Values read back from Parse Server reached the terminal with their
21
+ control bytes intact. A row whose field contained an OSC 52 sequence could
22
+ write an attacker-chosen payload into the operator's system clipboard, and
23
+ CSI and carriage-return sequences could clear the screen or overwrite lines
24
+ the operator had already read, so what was displayed was not what was stored.
25
+ Every such path now renders through `Parse::TerminalSafe`: the conversational
26
+ agent's answer and tool trace (`Parse::Agent::MCPClient::Result#to_s` and
27
+ `#inspect`, which run merely by evaluating `mcp.ask(...)` in IRB), the
28
+ request/response bodies and header values written by
29
+ `Parse::Middleware::Logging` and by the separate `Parse.logging = true`
30
+ printer in `Parse::Middleware::BodyBuilder`, the REST error text in logged
31
+ error summaries and in `Parse::Client`'s warning path, `Parse::Query`'s error
32
+ and explain warnings, the webhook request, payload, response, handler-error,
33
+ and afterSave-callback lines, and the event and handler-error lines emitted
34
+ by `Parse.watch`. Sanitization applies to rendering only: `result.text`,
35
+ `object.title`, and the parsed response body keep their exact bytes, so a
36
+ caller writing to a non-terminal surface is unaffected.
37
+ - **FIXED**: The LLM provider failure paths in `Parse::Agent::MCPClient`
38
+ interpolated the raw provider response body into the exception message, and a
39
+ malformed success body raised a `JSON::ParserError` quoting the offending
40
+ bytes verbatim. IRB prints both raw, so a hostile or compromised LLM endpoint
41
+ could still land control sequences on the terminal through the failure path.
42
+ Both are escaped now, and the quoted body is capped.
43
+ - **FIXED**: Untrusted text interpolated into a log record could contain a raw
44
+ newline and forge a second, attacker-authored log entry. Log records now use
45
+ the newline-escaping form, and the escape is applied before the body-length
46
+ cap so a truncated record stays on one line too.
47
+ - **CHANGED**: `rake mcp:chat` escapes the answer, the tool-call trace, the
48
+ `/history` and `/compact` output, and error messages before printing them.
49
+
50
+ #### `parse-console --url` no longer trusts the document it fetches
51
+
52
+ - **BREAKING**: `parse-console --url` copied every key in the fetched JSON
53
+ document into the process environment, letting whoever served or tampered
54
+ with that document set arbitrary environment variables for the console
55
+ process, including ones the console never reads but Ruby, OpenSSL, or a
56
+ later `require` does. Only `PARSE_SERVER_URL`,
57
+ `PARSE_SERVER_APPLICATION_ID`, `PARSE_APP_ID`, `PARSE_SERVER_REST_API_KEY`,
58
+ `PARSE_API_KEY`, `PARSE_SERVER_MASTER_KEY`, and `PARSE_MASTER_KEY` are
59
+ copied now, and each value must be a string. **Migration:** a remote config
60
+ that carried additional variables must set them in the shell instead.
61
+ - **FIXED**: `parse-console --url` parsed the fetched document with
62
+ `JSON.load`, which honors `json_class` additions and will instantiate
63
+ arbitrary already-loaded classes from the document. It uses `JSON.parse` now.
64
+ - **CHANGED**: `parse-console --url` refuses plaintext HTTP unless the host is
65
+ loopback. The document carries the master key, so over plaintext anyone on
66
+ the path reads it and can substitute a server URL of their choosing. The
67
+ check runs against `URI#hostname`, so an IPv6 loopback literal and an
68
+ uppercase host both resolve correctly.
69
+ - **FIXED**: `parse-console --url` fetches the document with a streaming
70
+ request under a 1 MiB cap, and revalidates the scheme and host on every
71
+ redirect hop (bounded at five). The previous open-uri call buffered the
72
+ entire response before any read limit applied, and followed redirects itself,
73
+ so a permitted loopback URL could bounce to arbitrary plaintext HTTP on the
74
+ public internet without the scheme check running again.
75
+ - **FIXED**: `parse-console` echoed the supplied URL before validating it, and
76
+ printed the (possibly remotely supplied) server URL and application ID
77
+ verbatim after connecting. All three are escaped now, as is the error output
78
+ from the fetch path, which can quote the fetched bytes.
79
+
3
80
  ### 5.7.2
4
81
 
5
82
  #### `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
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.3"
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.3
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