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 +4 -4
- data/CHANGELOG.md +77 -0
- data/bin/parse-console +108 -16
- data/examples/rag_chatbot.rb +5 -2
- data/lib/parse/agent/mcp_client.rb +46 -12
- data/lib/parse/client/body_builder.rb +12 -5
- data/lib/parse/client/logging.rb +23 -8
- data/lib/parse/client.rb +11 -2
- data/lib/parse/console.rb +9 -3
- data/lib/parse/query.rb +4 -3
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +1 -0
- data/lib/parse/terminal_safe.rb +138 -0
- data/lib/parse/webhooks.rb +29 -12
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e4017efda309de14faf52b86e26274cc097ee029d282a975f0144e324ac68250
|
|
4
|
+
data.tar.gz: e46cbf422955acc46ef7ceff8348a564712a9109051511dfec1bd2dce96356f0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 '
|
|
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
|
|
160
|
+
o.on('--url URL', 'Load the env config from an https url.') do |url|
|
|
77
161
|
begin
|
|
78
|
-
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
puts "
|
|
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?
|
data/examples/rag_chatbot.rb
CHANGED
|
@@ -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
|
-
|
|
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]}
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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}]
|
|
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} :
|
|
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]
|
|
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
|
|
data/lib/parse/client/logging.rb
CHANGED
|
@@ -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}:
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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}`
|
|
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}]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/parse/stack/version.rb
CHANGED
data/lib/parse/stack.rb
CHANGED
|
@@ -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
|
data/lib/parse/webhooks.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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}
|
|
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::
|
|
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}
|
|
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}:
|
|
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.
|
|
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
|