Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,82 @@
## parse-stack-next Changelog

### 5.7.3

#### Stored values can no longer drive the operator's terminal

- **NEW**: `Parse::TerminalSafe` is a canonical sanitizer for untrusted text
that is about to be written to a terminal, a log record, or an IRB `inspect`
line. `Parse::TerminalSafe.sanitize(str)` escapes ESC, BEL, backspace,
carriage return, the remaining C0 controls, DEL, the C1 controls (the 8-bit
CSI/OSC/DCS introducers, which a sanitizer that only looks for `0x1B`
misses), the zero-width characters, and the Unicode bidirectional overrides
and isolates. Tabs and newlines are preserved.
`Parse::TerminalSafe.sanitize_line(str)` escapes newlines and the Unicode
line and paragraph separators as well, for text interpolated into a single
log record. Control characters are escaped rather than deleted, so an
operator can still see that something tried. Non-UTF-8 and invalid-encoding
input is coerced first, so the sanitizer never raises on a binary response
body.
- **FIXED**: Values read back from Parse Server reached the terminal with their
control bytes intact. A row whose field contained an OSC 52 sequence could
write an attacker-chosen payload into the operator's system clipboard, and
CSI and carriage-return sequences could clear the screen or overwrite lines
the operator had already read, so what was displayed was not what was stored.
Every such path now renders through `Parse::TerminalSafe`: the conversational
agent's answer and tool trace (`Parse::Agent::MCPClient::Result#to_s` and
`#inspect`, which run merely by evaluating `mcp.ask(...)` in IRB), the
request/response bodies and header values written by
`Parse::Middleware::Logging` and by the separate `Parse.logging = true`
printer in `Parse::Middleware::BodyBuilder`, the REST error text in logged
error summaries and in `Parse::Client`'s warning path, `Parse::Query`'s error
and explain warnings, the webhook request, payload, response, handler-error,
and afterSave-callback lines, and the event and handler-error lines emitted
by `Parse.watch`. Sanitization applies to rendering only: `result.text`,
`object.title`, and the parsed response body keep their exact bytes, so a
caller writing to a non-terminal surface is unaffected.
- **FIXED**: The LLM provider failure paths in `Parse::Agent::MCPClient`
interpolated the raw provider response body into the exception message, and a
malformed success body raised a `JSON::ParserError` quoting the offending
bytes verbatim. IRB prints both raw, so a hostile or compromised LLM endpoint
could still land control sequences on the terminal through the failure path.
Both are escaped now, and the quoted body is capped.
- **FIXED**: Untrusted text interpolated into a log record could contain a raw
newline and forge a second, attacker-authored log entry. Log records now use
the newline-escaping form, and the escape is applied before the body-length
cap so a truncated record stays on one line too.
- **CHANGED**: `rake mcp:chat` escapes the answer, the tool-call trace, the
`/history` and `/compact` output, and error messages before printing them.

#### `parse-console --url` no longer trusts the document it fetches

- **BREAKING**: `parse-console --url` copied every key in the fetched JSON
document into the process environment, letting whoever served or tampered
with that document set arbitrary environment variables for the console
process, including ones the console never reads but Ruby, OpenSSL, or a
later `require` does. Only `PARSE_SERVER_URL`,
`PARSE_SERVER_APPLICATION_ID`, `PARSE_APP_ID`, `PARSE_SERVER_REST_API_KEY`,
`PARSE_API_KEY`, `PARSE_SERVER_MASTER_KEY`, and `PARSE_MASTER_KEY` are
copied now, and each value must be a string. **Migration:** a remote config
that carried additional variables must set them in the shell instead.
- **FIXED**: `parse-console --url` parsed the fetched document with
`JSON.load`, which honors `json_class` additions and will instantiate
arbitrary already-loaded classes from the document. It uses `JSON.parse` now.
- **CHANGED**: `parse-console --url` refuses plaintext HTTP unless the host is
loopback. The document carries the master key, so over plaintext anyone on
the path reads it and can substitute a server URL of their choosing. The
check runs against `URI#hostname`, so an IPv6 loopback literal and an
uppercase host both resolve correctly.
- **FIXED**: `parse-console --url` fetches the document with a streaming
request under a 1 MiB cap, and revalidates the scheme and host on every
redirect hop (bounded at five). The previous open-uri call buffered the
entire response before any read limit applied, and followed redirects itself,
so a permitted loopback URL could bounce to arbitrary plaintext HTTP on the
public internet without the scheme check running again.
- **FIXED**: `parse-console` echoed the supplied URL before validating it, and
printed the (possibly remotely supplied) server URL and application ID
verbatim after connecting. All three are escaped now, as is the error output
from the fetch path, which can quote the fetched bytes.

### 5.7.2

#### `between` accepts Ruby Range values
Expand Down
14 changes: 7 additions & 7 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
parse-stack-next (5.7.2)
parse-stack-next (5.7.3)
activemodel (>= 6.1, < 9)
activesupport (>= 6.1, < 9)
connection_pool (>= 2.2, < 4)
Expand Down Expand Up @@ -49,7 +49,7 @@ GEM
reline (>= 0.3.8)
dotenv (3.2.0)
drb (2.2.3)
erb (6.0.6)
erb (6.0.7)
faraday (2.14.3)
faraday-net_http (>= 2.0, < 3.5)
json
Expand All @@ -60,13 +60,13 @@ GEM
faraday (~> 2.5)
net-http-persistent (>= 4.0.4, < 5)
fiber-storage (1.0.1)
graphql (2.6.7)
graphql (2.6.8)
base64
fiber-storage
logger
i18n (1.15.2)
concurrent-ruby (~> 1.0)
io-console (0.8.2)
io-console (0.9.2)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
Expand Down Expand Up @@ -106,7 +106,7 @@ GEM
reline (>= 0.6.0)
puma (8.0.2)
nio4r (~> 2.0)
rack (3.2.6)
rack (3.2.7)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
Expand All @@ -119,7 +119,7 @@ GEM
rackup (2.3.1)
rack (>= 3)
rake (13.4.2)
rbs (4.1.1)
rbs (4.1.3)
logger
prism (>= 1.6.0)
tsort
Expand All @@ -133,7 +133,7 @@ GEM
redis-client (= 0.30.1)
redis-client (0.30.1)
connection_pool
reline (0.6.3)
reline (0.7.0)
io-console (~> 0.5)
rotp (6.3.0)
rqrcode (3.2.0)
Expand Down
20 changes: 15 additions & 5 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,10 @@ namespace :mcp do
else
delta = client.usage.total_tokens - before
puts "[compacted; +#{delta} tokens spent on summary]"
puts " summary: #{summary[0, 200]}#{summary.length > 200 ? "…" : ""}"
# LLM-authored text conditioned on tenant rows: escape before it
# reaches the terminal.
truncated = "#{summary[0, 200]}#{summary.length > 200 ? "…" : ""}"
puts " summary: #{Parse::TerminalSafe.sanitize_line(truncated)}"
end
next
when "/tools"
Expand All @@ -682,7 +685,10 @@ namespace :mcp do
next
when "/history"
client.history.each_with_index do |m, i|
puts " #{i + 1}. [#{m[:role]}] #{m[:content].to_s[0, 120]}"
# Message content is a mix of LLM output and tool results, both of
# which carry stored values through verbatim.
content = Parse::TerminalSafe.sanitize_line(m[:content].to_s[0, 120])
puts " #{i + 1}. [#{m[:role]}] #{content}"
end
next
end
Expand All @@ -693,11 +699,15 @@ namespace :mcp do
puts "─── tool calls ───"
result.tool_calls.each_with_index do |tc, i|
args = tc[:arguments].is_a?(Hash) ? tc[:arguments].inspect : tc[:arguments].to_s
puts " #{i + 1}. #{tc[:name]}(#{args})"
puts " #{i + 1}. #{Parse::TerminalSafe.sanitize_line(tc[:name])}" \
"(#{Parse::TerminalSafe.sanitize_line(args)})"
end
end
puts
puts result.text.to_s.empty? ? "[empty response]" : result.text
# The answer is untrusted: the model was fed tenant rows and will
# repeat what they contain. Newlines are legitimate formatting in an
# answer, so only control sequences are escaped here.
puts result.text.to_s.empty? ? "[empty response]" : Parse::TerminalSafe.sanitize(result.text)
if trace && result.usage && result.usage.total_tokens.positive?
printf "[%d tokens / $%.6f this turn session: %d / $%.4f]\n",
result.usage.total_tokens, result.usage.cost_usd,
Expand All @@ -707,7 +717,7 @@ namespace :mcp do
puts "\n[interrupted]"
next
rescue => e
puts "[error] #{e.class}: #{e.message}"
puts "[error] #{e.class}: #{Parse::TerminalSafe.sanitize_line(e.message)}"
end
end

Expand Down
124 changes: 108 additions & 16 deletions bin/parse-console
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

require 'optparse'
require 'json'
require 'open-uri'
require 'net/http'
require 'uri'
require 'active_support'
require 'active_support/core_ext'
require 'parse/terminal_safe'

DEFAULT_CONFIG_FILE = 'config.json'
DEFAULT_CONFIG_CONTENTS = {
Expand All @@ -18,6 +20,88 @@ DEFAULT_CONFIG_CONTENTS = {
}]
}.freeze

# Only these keys are copied out of a remote config document and into the
# process environment. The loader used to copy every key it was handed, which
# let whoever served (or tampered with) the document set arbitrary env vars for
# the console process, including ones the console never reads but Ruby,
# OpenSSL, or a later `require` does.
REMOTE_CONFIG_ENV_ALLOWLIST = %w[
PARSE_SERVER_URL
PARSE_SERVER_APPLICATION_ID PARSE_APP_ID
PARSE_SERVER_REST_API_KEY PARSE_API_KEY
PARSE_SERVER_MASTER_KEY PARSE_MASTER_KEY
].freeze

# A remote config carries the master key. Over plaintext HTTP anyone on the path
# reads it and can substitute a server URL of their choosing, so require TLS
# except when pointing at the loopback interface.
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze

REMOTE_CONFIG_MAX_BYTES = 1_048_576
REMOTE_CONFIG_MAX_REDIRECTS = 5

# SEC-20: never hand a user-supplied string to bare Kernel#open, where
# `open("|command")` executes a subprocess. Parse an explicit URI and require an
# HTTP(S) scheme instead.
def validate_config_uri!(uri)
unless uri.is_a?(URI::HTTP) # URI::HTTPS < URI::HTTP, so this admits both
raise "Refusing to load config from a non-HTTP(S) URL: #{uri.to_s.inspect}"
end
# `hostname` (not `host`) so an IPv6 literal arrives as "::1" rather than
# "[::1]"; downcased so "LOCALHOST" is recognized too.
unless uri.is_a?(URI::HTTPS) || LOOPBACK_HOSTS.include?(uri.hostname.to_s.downcase)
raise "Refusing to fetch credentials over plaintext HTTP: #{uri.to_s.inspect}. " \
"Use https, or a loopback host for local testing."
end
uri
end

# Fetch a remote config document, streaming it under a hard byte cap and
# revalidating every redirect hop.
#
# Both properties are the reason this is hand-rolled rather than an open-uri
# one-liner. open-uri buffers the whole response before yielding the IO, so a
# read cap on the returned handle limits only what is read back out of a body
# that was already downloaded in full; and it follows redirects itself, so a
# permitted `http://localhost/...` could bounce to arbitrary plaintext HTTP on
# the public internet without the scheme check ever running again.
def fetch_remote_config_body(url)
uri = validate_config_uri!(URI.parse(url))
redirects = 0

loop do
body = nil
Net::HTTP.start(uri.hostname, uri.port,
use_ssl: uri.is_a?(URI::HTTPS),
open_timeout: 10, read_timeout: 30) do |http|
http.request(Net::HTTP::Get.new(uri)) do |res|
case res
when Net::HTTPRedirection
location = res['location'].to_s
raise "Redirect with no Location header." if location.empty?
redirects += 1
if redirects > REMOTE_CONFIG_MAX_REDIRECTS
raise "Too many redirects (limit #{REMOTE_CONFIG_MAX_REDIRECTS})."
end
uri = validate_config_uri!(URI.join(uri.to_s, location))
when Net::HTTPSuccess
buffer = +''
res.read_body do |chunk|
buffer << chunk
if buffer.bytesize > REMOTE_CONFIG_MAX_BYTES
raise "Config exceeds #{REMOTE_CONFIG_MAX_BYTES} bytes; refusing to buffer more."
end
end
body = buffer
else
raise "Config fetch failed: HTTP #{res.code}."
end
end
end
return body if body
end
end

opts = { verbose: false, pry: false }
opt_parser = OptionParser.new do |o|

Expand Down Expand Up @@ -73,26 +157,33 @@ opt_parser = OptionParser.new do |o|
end

end
o.on('--url URL', 'Load the env config from a url.') do |url|
o.on('--url URL', 'Load the env config from an https url.') do |url|
begin
puts "Loading config: #{url}"
# SEC-20: do NOT pass a user-supplied string to bare Kernel#open —
# `open("|command")` executes a subprocess. Parse an explicit URI and
# require an HTTP(S) scheme, then use open-uri's URI::HTTP#open (a real
# network fetch), never the Kernel form.
uri = URI.parse(url)
unless uri.is_a?(URI::HTTP) # URI::HTTPS < URI::HTTP, so this admits both
raise "Refusing to load config from a non-HTTP(S) URL: #{url.inspect}"
end
json = JSON.load(uri.open)
# Echo the URL only in escaped form. It is operator-supplied but not yet
# validated at this point, and a pasted URL is exactly the kind of string
# that carries a control sequence.
puts "Loading config: #{Parse::TerminalSafe.sanitize_line(url)}"
# JSON.parse, never JSON.load: `load` honors `json_class` additions and
# will instantiate arbitrary loaded classes from the document.
json = JSON.parse(fetch_remote_config_body(url))
raise "Contents not a JSON hash." unless json.is_a?(Hash)
json.each { |k,v| ENV[k.upcase] = v }
json.each do |k, v|
key = k.to_s.upcase
next unless REMOTE_CONFIG_ENV_ALLOWLIST.include?(key)
unless v.is_a?(String)
raise "Config key #{key} must be a string, got #{v.class}."
end
ENV[key] = v
end
opts[:server_url] ||= ENV['PARSE_SERVER_URL']
opts[:app_id] ||= ENV['PARSE_SERVER_APPLICATION_ID'] || ENV['PARSE_APP_ID']
opts[:api_key] ||= ENV['PARSE_SERVER_REST_API_KEY'] || ENV['PARSE_API_KEY']
opts[:master_key] ||= ENV['PARSE_SERVER_MASTER_KEY'] || ENV['PARSE_MASTER_KEY']
rescue Exception => e
$stderr.puts "Error: Invalid JSON format for #{url} (#{e})"
# The message can quote the fetched document, so escape it: this is the
# one place where remote bytes reach the operator's terminal.
$stderr.puts "Error: Invalid JSON format for #{Parse::TerminalSafe.sanitize_line(url)} " \
"(#{Parse::TerminalSafe.sanitize_line(e.message)})"
exit 1
end
end
Expand Down Expand Up @@ -120,8 +211,9 @@ Parse.setup server_url: opts[:server_url],
api_key: opts[:api_key],
master_key: opts[:master_key]
Parse.logging = true if opts[:verbose]
puts "Server : #{Parse.client.server_url}"
puts "App Id : #{Parse.client.app_id}"
# Both of these can have come from a remote config document, so escape them.
puts "Server : #{Parse::TerminalSafe.sanitize_line(Parse.client.server_url)}"
puts "App Id : #{Parse::TerminalSafe.sanitize_line(Parse.client.app_id)}"
puts "Master : #{Parse.client.master_key.present?}"

if Parse.client.master_key.present?
Expand Down
7 changes: 5 additions & 2 deletions examples/rag_chatbot.rb
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,12 @@ def chat_loop(backend: :anthropic)
chunks = retrieve(agent, question)
answer = ChatAnswerer.public_send(backend, question, chunks)

puts "\n#{answer}\n"
# The answer is model output grounded in retrieved rows, and the object ids
# come from the database. Both are untrusted for terminal purposes: escape
# control sequences before writing them to a TTY.
puts "\n#{Parse::TerminalSafe.sanitize(answer)}\n"
sources = chunks.map { |c| c.dig(:metadata, :object_id) }.uniq.join(", ")
puts " (sources: #{sources})\n\n"
puts " (sources: #{Parse::TerminalSafe.sanitize_line(sources)})\n\n"
end
end

Expand Down
Loading