From 138bc4c1a774b87c862132f2bcd4361c2a6543a9 Mon Sep 17 00:00:00 2001 From: Adrian Curtin <48138055+AdrianCurtin@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:18:13 -0400 Subject: [PATCH] Escape terminal control sequences in untrusted output Add Parse::TerminalSafe and route every path where server-stored values reach a terminal or log record through it. --- CHANGELOG.md | 77 ++++++++ Gemfile.lock | 14 +- Rakefile | 20 +- bin/parse-console | 124 ++++++++++-- examples/rag_chatbot.rb | 7 +- lib/parse/agent/mcp_client.rb | 58 ++++-- lib/parse/client.rb | 13 +- lib/parse/client/body_builder.rb | 17 +- lib/parse/client/logging.rb | 31 ++- lib/parse/console.rb | 12 +- lib/parse/query.rb | 7 +- lib/parse/stack.rb | 1 + lib/parse/stack/version.rb | 2 +- lib/parse/terminal_safe.rb | 138 ++++++++++++++ lib/parse/webhooks.rb | 41 ++-- test/lib/parse/terminal_safe_sinks_test.rb | 208 +++++++++++++++++++++ test/lib/parse/terminal_safe_test.rb | 170 +++++++++++++++++ 17 files changed, 864 insertions(+), 76 deletions(-) create mode 100644 lib/parse/terminal_safe.rb create mode 100644 test/lib/parse/terminal_safe_sinks_test.rb create mode 100644 test/lib/parse/terminal_safe_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index bc587ae..b4af595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 5ada037..8f52a78 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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 @@ -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) @@ -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) @@ -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 @@ -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) diff --git a/Rakefile b/Rakefile index f2335b4..f253476 100644 --- a/Rakefile +++ b/Rakefile @@ -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" @@ -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 @@ -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, @@ -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 diff --git a/bin/parse-console b/bin/parse-console index cd61e45..69019c8 100755 --- a/bin/parse-console +++ b/bin/parse-console @@ -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 = { @@ -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| @@ -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 @@ -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? diff --git a/examples/rag_chatbot.rb b/examples/rag_chatbot.rb index b1b1f04..6d50ede 100644 --- a/examples/rag_chatbot.rb +++ b/examples/rag_chatbot.rb @@ -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 diff --git a/lib/parse/agent/mcp_client.rb b/lib/parse/agent/mcp_client.rb index 597ca95..7514f87 100644 --- a/lib/parse/agent/mcp_client.rb +++ b/lib/parse/agent/mcp_client.rb @@ -6,6 +6,7 @@ require "json" require "securerandom" require_relative "mcp_dispatcher" +require_relative "../terminal_safe" module Parse class Agent @@ -65,17 +66,25 @@ def reply(question) end # Pretty-print for IRB: tool trace, answer, then per-call usage line. + # + # Every interpolated part is attacker-influenced. The answer is LLM + # output that was itself conditioned on tenant rows, and tool arguments + # can echo stored values. Merely evaluating `mcp.ask(...)` in IRB writes + # this string to the terminal, so control sequences are escaped here. + # `text` itself is untouched: callers that render into a non-terminal + # surface still get the exact bytes. def to_s parts = [] if tool_calls.any? parts << "─── tool calls (#{tool_calls.size}) ───" tool_calls.each_with_index do |tc, i| args_str = tc[:arguments].is_a?(Hash) ? tc[:arguments].inspect : tc[:arguments].to_s - parts << " #{i + 1}. #{tc[:name]}(#{args_str})" + parts << " #{i + 1}. #{Parse::TerminalSafe.sanitize_line(tc[:name])}" \ + "(#{Parse::TerminalSafe.sanitize_line(args_str)})" end end parts << "─── answer ───" - parts << text.to_s + parts << Parse::TerminalSafe.sanitize(text) parts << "─── usage ───" << " #{usage}" if usage && usage.total_tokens.positive? parts.join("\n") end @@ -311,6 +320,39 @@ def history private + # Maximum bytes of a provider response body quoted back in an exception. + LLM_ERROR_BODY_CAP = 2_000 + + # Check the HTTP status and parse the body, raising with a terminal-safe + # message on either failure. + # + # The provider's response body is untrusted output on both paths. An + # error body is echoed into the exception message, and a malformed + # success body produces a `JSON::ParserError` whose message quotes the + # offending bytes verbatim. Either exception is printed raw by IRB and by + # most logging setups, so an LLM endpoint (or a model repeating what a + # tenant row told it to say) could otherwise still land control sequences + # on the operator's terminal through the failure path. + # + # @param res [Net::HTTPResponse] + # @param label [String] provider name used in the message. + # @return [Hash] the parsed body. + def parse_llm_response!(res, label) + body = res.body.to_s + unless res.code.to_i.between?(200, 299) + quoted = Parse::TerminalSafe.sanitize_line(body[0, LLM_ERROR_BODY_CAP]) + raise "#{label} failed: HTTP #{res.code} #{quoted}" + end + + begin + JSON.parse(body) + rescue JSON::ParserError => e + raise JSON::ParserError, + "#{label} returned an unparseable body: " \ + "#{Parse::TerminalSafe.sanitize_line(e.message)[0, LLM_ERROR_BODY_CAP]}" + end + end + # Fetch the agent's MCP tool catalog and translate it into the LLM's # native function-calling schema. Cached per call (could be memoized # if tool lists grow large, but they're usually small). @@ -447,11 +489,7 @@ def openai_chat(messages:, tools:) res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", read_timeout: @timeout) { |h| h.request(req) } - unless res.code.to_i.between?(200, 299) - raise "LLM call failed: HTTP #{res.code} #{res.body}" - end - - parsed = JSON.parse(res.body) + parsed = parse_llm_response!(res, "LLM call") msg = parsed.dig("choices", 0, "message") || {} calls = Array(msg["tool_calls"]).map do |tc| args = tc.dig("function", "arguments") @@ -497,11 +535,7 @@ def anthropic_chat(messages:, tools:) res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", read_timeout: @timeout) { |h| h.request(req) } - unless res.code.to_i.between?(200, 299) - raise "Anthropic call failed: HTTP #{res.code} #{res.body}" - end - - parsed = JSON.parse(res.body) + parsed = parse_llm_response!(res, "Anthropic call") blocks = Array(parsed["content"]) text = blocks.select { |b| b["type"] == "text" }.map { |b| b["text"] }.join("\n") calls = blocks.select { |b| b["type"] == "tool_use" }.map do |b| diff --git a/lib/parse/client.rb b/lib/parse/client.rb index 0107d5e..4f39200 100644 --- a/lib/parse/client.rb +++ b/lib/parse/client.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "faraday" +require_relative "terminal_safe" # Attempt to load the persistent connection adapter for better performance. # Falls back gracefully to the default adapter if not available. @@ -528,11 +529,19 @@ def setup(opts = {}, &block) # @param name [String, nil] optional cloud-function or job name for context. # @return [nil] def _safe_warn(tag, response, name: nil) + # The server's error text and the request description both carry stored + # values through verbatim, and this lands in a log file or on a + # terminal. Escape control characters and newlines so a stored value + # can neither drive the terminal nor forge a second log record. err = Parse::Middleware::BodyBuilder.redact(response.error.to_s)[0, SAFE_WARN_MAX_ERROR_LENGTH] + err = Parse::TerminalSafe.sanitize_line(err) msg = if name - "[Parse:#{tag}] `#{name}` [#{response.code}] #{err} (HTTP #{response.http_status})" + "[Parse:#{tag}] `#{Parse::TerminalSafe.sanitize_line(name)}` " \ + "[#{response.code}] #{err} (HTTP #{response.http_status})" else - "[Parse:#{tag}] [E-#{response.code}] #{response.request} : #{err} (#{response.http_status})" + "[Parse:#{tag}] [E-#{response.code}] " \ + "#{Parse::TerminalSafe.sanitize_line(response.request)} : #{err} " \ + "(#{response.http_status})" end logger = Parse::Middleware::Logging.logger if logger diff --git a/lib/parse/client/body_builder.rb b/lib/parse/client/body_builder.rb index 9b895e0..f97742d 100644 --- a/lib/parse/client/body_builder.rb +++ b/lib/parse/client/body_builder.rb @@ -9,6 +9,7 @@ require "active_model/serializers/json" require "json" require "set" +require_relative "../terminal_safe" module Parse @@ -317,17 +318,23 @@ def call!(env) env[:body] = env[:body].to_json end + # `Parse.logging = true` routes here, so this legacy printer sees the + # same tenant-controlled bytes the Faraday logging middleware does and + # needs the same terminal-escape handling. if self.class.logging - puts "[Request #{env.method.upcase}] #{self.class.redact(env[:url].to_s)}" + puts "[Request #{env.method.upcase}] " \ + "#{Parse::TerminalSafe.sanitize_line(self.class.redact(env[:url].to_s))}" env[:request_headers].each do |k, v| if REDACTED_HEADERS.include?(k.to_s.downcase) - puts "[Header] #{k} : [FILTERED]" + puts "[Header] #{Parse::TerminalSafe.sanitize_line(k)} : [FILTERED]" else - puts "[Header] #{k} : #{v}" + puts "[Header] #{Parse::TerminalSafe.sanitize_line(k)} : " \ + "#{Parse::TerminalSafe.sanitize_line(v)}" end end - puts "[Request Body] #{self.class.redact(env[:body].to_s)}" + puts "[Request Body] " \ + "#{Parse::TerminalSafe.sanitize_line(self.class.redact(env[:body].to_s))}" end @app.call(env).on_complete do |response_env| # on a response, create a new Parse::Response and replace the :body @@ -335,7 +342,7 @@ def call!(env) # @todo CHECK FOR HTTP STATUS CODES if self.class.logging puts "[[Response #{response_env[:status]}]] ----------------------------------" - puts self.class.redact(response_env.body.to_s) + puts Parse::TerminalSafe.sanitize(self.class.redact(response_env.body.to_s)) puts "[[Response]] --------------------------------------\n" end diff --git a/lib/parse/client/logging.rb b/lib/parse/client/logging.rb index 04da144..05c493d 100644 --- a/lib/parse/client/logging.rb +++ b/lib/parse/client/logging.rb @@ -4,6 +4,7 @@ require "faraday" require "logger" require_relative "url_redaction" +require_relative "../terminal_safe" module Parse module Middleware @@ -167,7 +168,8 @@ def log_headers(headers, prefix) if Parse::Middleware::BodyBuilder::REDACTED_HEADERS.include?(key.to_s.downcase) logger.debug " [#{prefix} Header] #{key}: [FILTERED]" else - logger.debug " [#{prefix} Header] #{key}: #{value}" + logger.debug " [#{prefix} Header] #{Parse::TerminalSafe.sanitize_line(key)}: " \ + "#{Parse::TerminalSafe.sanitize_line(value)}" end end end @@ -196,6 +198,13 @@ def log_body(body, prefix) # so truncation can't split a token across the boundary and slip past. content = Parse::Middleware::BodyBuilder.redact(content) + # Request and response bodies carry tenant-stored values verbatim. A + # stored ESC sequence would execute against the operator's terminal the + # moment they tail the log, so escape control characters and newlines + # here. Done BEFORE the length cap so the record is one line whether or + # not it was truncated. + content = Parse::TerminalSafe.sanitize_line(content) + if content.length > max_length logger.debug " [#{prefix} Body] #{content[0...max_length]}... (truncated, #{content.length} total)" elsif content.length > 0 @@ -216,15 +225,21 @@ def response_body_content(response_env) end end + # The error text is whatever the server (or a stored value echoed back by + # the server) says, so it is untrusted. Escape terminal control sequences + # AND newlines: this is interpolated into a one-line log record, and a + # raw LF would let the text forge a second, attacker-authored entry. def error_summary(response_env) body = response_env[:body] - if body.is_a?(Parse::Response) && body.error? - "#{body.code}: #{body.error}" - elsif body.is_a?(Hash) - body["error"] || body[:error] || "Unknown error" - else - "HTTP #{response_env[:status]}" - end + summary = + if body.is_a?(Parse::Response) && body.error? + "#{body.code}: #{body.error}" + elsif body.is_a?(Hash) + body["error"] || body[:error] || "Unknown error" + else + "HTTP #{response_env[:status]}" + end + Parse::TerminalSafe.sanitize_line(summary) end def sanitize_url(url) diff --git a/lib/parse/console.rb b/lib/parse/console.rb index bbcc2d9..2e56a1d 100644 --- a/lib/parse/console.rb +++ b/lib/parse/console.rb @@ -23,6 +23,7 @@ # tests / fixtures. require "timeout" +require_relative "terminal_safe" module Parse module Console @@ -65,7 +66,10 @@ def watch(klass, where: {}, on: nil, fields: nil, session_token: nil, &block) events = Array(on || DEFAULT_WATCH_EVENTS).map(&:to_sym) printer = block_given? ? block : ->(ev, obj) { title = obj.respond_to?(:id) ? obj.id : obj.inspect - puts "[#{Time.now.iso8601}] #{klass.parse_class}.#{ev} #{title}" + # The row is tenant data arriving over a live-query socket and this + # line goes straight to the operator's terminal, so escape it. + puts "[#{Time.now.iso8601}] #{klass.parse_class}.#{ev} " \ + "#{Parse::TerminalSafe.sanitize_line(title)}" } delivered = 0 @@ -78,11 +82,13 @@ def watch(klass, where: {}, on: nil, fields: nil, session_token: nil, &block) begin printer.call(ev, obj) rescue StandardError => e - warn "[Parse.watch] handler raised #{e.class}: #{e.message}" + # The message can quote the row that triggered it. + warn "[Parse.watch] handler raised #{e.class}: " \ + "#{Parse::TerminalSafe.sanitize_line(e.message)}" end end end - sub.on(:error) { |err| warn "[Parse.watch] error: #{err}" } + sub.on(:error) { |err| warn "[Parse.watch] error: #{Parse::TerminalSafe.sanitize_line(err)}" } _block_until_interrupt delivered diff --git a/lib/parse/query.rb b/lib/parse/query.rb index b7e2eea..49b1aec 100644 --- a/lib/parse/query.rb +++ b/lib/parse/query.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require_relative "client" +require_relative "terminal_safe" require_relative "pipeline_security" require_relative "query/operation" require_relative "query/constraints" @@ -1782,7 +1783,7 @@ def recursively_drop_keys!(value, names) def fetch!(compiled_query) response = client.find_objects(@table, compiled_query.as_json, headers: _headers, **_opts) if response.error? - puts "[ParseQuery] #{response.error}" + puts "[ParseQuery] #{Parse::TerminalSafe.sanitize_line(response.error)}" end response end @@ -3596,12 +3597,12 @@ def explain # non-master explain that worked on 8.x now returns a permission # error. Surface that as actionable guidance instead of a bare 403. if response.respond_to?(:permission_denied?) && response.permission_denied? - puts "[ParseQuery:Explain] #{response.error} — Parse Server 9.0+ defaults " \ + puts "[ParseQuery:Explain] #{Parse::TerminalSafe.sanitize_line(response.error)} — Parse Server 9.0+ defaults " \ "`allowPublicExplain` to false; query explain now requires the master key " \ "(use_master_key: true) or `allowPublicExplain: true` in the server's " \ "databaseOptions." else - puts "[ParseQuery:Explain] #{response.error}" + puts "[ParseQuery:Explain] #{Parse::TerminalSafe.sanitize_line(response.error)}" end return {} end diff --git a/lib/parse/stack.rb b/lib/parse/stack.rb index c95e92b..3ef5e0e 100644 --- a/lib/parse/stack.rb +++ b/lib/parse/stack.rb @@ -2,6 +2,7 @@ # frozen_string_literal: true require_relative "stack/version" +require_relative "terminal_safe" require_relative "client" require_relative "query" require_relative "model/object" diff --git a/lib/parse/stack/version.rb b/lib/parse/stack/version.rb index 7dc9829..1ea649a 100644 --- a/lib/parse/stack/version.rb +++ b/lib/parse/stack/version.rb @@ -6,6 +6,6 @@ module Parse # The Parse Server SDK for Ruby module Stack # The current version. - VERSION = "5.7.2" + VERSION = "5.7.3" end end diff --git a/lib/parse/terminal_safe.rb b/lib/parse/terminal_safe.rb new file mode 100644 index 0000000..2f41a66 --- /dev/null +++ b/lib/parse/terminal_safe.rb @@ -0,0 +1,138 @@ +# encoding: UTF-8 +# frozen_string_literal: true + +module Parse + # Neutralizes terminal control sequences in untrusted text before it is + # written to a terminal, a log record, or an IRB `inspect` line. + # + # Parse Server returns whatever a tenant stored. Any string that originates + # in the database, in a server error message, or in an LLM answer is + # attacker-influenced, and a raw ESC byte in that string is not inert once it + # reaches a TTY. It can clear the screen, rewrite lines the operator has + # already read, retitle the window, or (via OSC 52) write the attacker's + # payload into the system clipboard so the operator's next paste runs it. + # Bidirectional overrides are the same class of problem: they reorder a line + # so what the operator reads is not what the bytes say. + # + # This module is the SDK's single answer to that. It escapes rather than + # deletes, so the offending bytes stay visible in the output (rendered as + # `\e`, `\u202E`, and friends) and an operator can still see that + # something tried. + # + # It sanitizes *rendering*, never storage. `result.text`, `object.title`, and + # the parsed response body keep their exact bytes. Only the human-readable + # form built for a terminal or a log line runs through here. + # + # @example Rendering an untrusted value + # puts Parse::TerminalSafe.sanitize(post.title) + # + # @example A single-line log record. Newlines are escaped too, so a stored + # value cannot forge a second log entry. + # logger.warn "#{status} - #{Parse::TerminalSafe.sanitize_line(error)}" + module TerminalSafe + extend self + + # Codepoint ranges neutralized on every call: + # + # - 0x00-0x08, 0x0B-0x1F: C0 controls except TAB (0x09) and LF (0x0A). + # This is where ESC (0x1B, the CSI/OSC/DCS introducer), BEL (0x07, the + # OSC string terminator), CR (0x0D, overwrite-the-line), and BS (0x08, + # erase-the-previous-character) live. + # - 0x7F: DEL. + # - 0x80-0x9F: C1 controls, the 8-bit forms of the same introducers + # (0x9B is CSI, 0x9D is OSC, 0x90 is DCS). + # - 0x061C: Arabic letter mark, an implicit bidirectional control that + # reorders a line exactly like the explicit marks below. + # - 0x200B-0x200F: zero-width space, non-joiner, joiner, LRM, RLM. + # - 0x2028-0x2029: line and paragraph separator. Widely treated as a line + # break by terminals, editors, and log readers, so leaving them intact + # would reintroduce the forged-record problem that escaping LF solves. + # A legitimate line break is LF, which is preserved. + # - 0x202A-0x202E: bidirectional embedding and override. + # - 0x2060-0x2064: word joiner and the invisible math operators. + # - 0x2066-0x2069: the bidirectional isolates. + # - 0xFEFF: zero-width no-break space (a BOM appearing mid-string). + # + # TAB and LF are deliberately absent: both are ordinary formatting in + # multi-line output. {#sanitize_line} escapes LF as well. + UNSAFE_RANGES = [ + 0x00..0x08, 0x0B..0x1F, 0x7F..0x9F, + 0x061C..0x061C, + 0x200B..0x200F, 0x2028..0x2029, 0x202A..0x202E, + 0x2060..0x2064, 0x2066..0x2069, + 0xFEFF..0xFEFF, + ].freeze + + # Built from codepoints rather than written as literal escapes so the + # source of this file stays free of the very bytes it defends against. + def self.build_pattern(ranges) + body = ranges.map { |r| format("\\u{%04X}-\\u{%04X}", r.first, r.last) }.join + Regexp.new("[#{body}]") + end + private_class_method :build_pattern + + UNSAFE_RE = build_pattern(UNSAFE_RANGES) + + # The same set plus LF, for output that must occupy exactly one line. + UNSAFE_LINE_RE = build_pattern(UNSAFE_RANGES + [0x0A..0x0A]) + + # Readable escapes for the controls an operator is most likely to see. + # Everything else falls back to `\xNN` or `\uNNNN`. + NAMED_ESCAPES = { + "\0" => "\\0", + "\a" => "\\a", + "\b" => "\\b", + "\n" => "\\n", + "\v" => "\\v", + "\f" => "\\f", + "\r" => "\\r", + "\e" => "\\e", + }.freeze + + # Escape terminal control sequences in `str`, preserving newlines and tabs. + # + # @param str [String, #to_s, nil] untrusted text. + # @return [String] the same text with control characters escaped. Non-UTF-8 + # and invalid-encoding input is coerced to UTF-8 first, so this never + # raises on a binary response body. + def sanitize(str) + escape(str, UNSAFE_RE) + end + + # Escape terminal control sequences and newlines, so untrusted text cannot + # forge additional lines. Use for anything written as a single log record + # or a single console line. + # + # @param str [String, #to_s, nil] untrusted text. + # @return [String] + def sanitize_line(str) + escape(str, UNSAFE_LINE_RE) + end + + private + + def escape(str, pattern) + s = coerce(str) + return s unless s.match?(pattern) + s.gsub(pattern) { |ch| escape_char(ch) } + end + + # Force the input to valid UTF-8 without raising. A response body read off + # the wire can be ASCII-8BIT, and a truncated multi-byte sequence is not + # valid UTF-8. Either would make `match?` raise ArgumentError, which is + # exactly the wrong outcome for a defensive sanitizer. + def coerce(str) + s = str.is_a?(String) ? str : str.to_s + s = s.dup.force_encoding(Encoding::UTF_8) unless s.encoding == Encoding::UTF_8 + s = s.scrub("�") unless s.valid_encoding? + s + end + + def escape_char(ch) + named = NAMED_ESCAPES[ch] + return named if named + cp = ch.ord + cp <= 0xFF ? format("\\x%02X", cp) : format("\\u%04X", cp) + end + end +end diff --git a/lib/parse/webhooks.rb b/lib/parse/webhooks.rb index 01cdb86..47c1b81 100644 --- a/lib/parse/webhooks.rb +++ b/lib/parse/webhooks.rb @@ -11,6 +11,7 @@ require "rack" require "ostruct" require_relative "client" +require_relative "terminal_safe" # Note: Do not require "stack" here - this file is loaded from stack.rb # and adding that require would create a circular dependency. require_relative "model/object" @@ -326,7 +327,10 @@ def dispatch_composed(payload, registry, type) # @param error [StandardError] the raised error. # @return [void] def report_handler_error(type, error) - warn "[Parse::Webhooks] #{type} handler raised #{error.class}: #{error.message}; " \ + # The handler's message is application-authored but routinely quotes the + # payload that triggered it, which is caller-controlled. + warn "[Parse::Webhooks] #{type} handler raised #{error.class}: " \ + "#{Parse::TerminalSafe.sanitize_line(error.message)}; " \ "continuing with the remaining handlers " \ "(Parse::Webhooks.abort_after_callbacks_on_error is false)" return unless defined?(ActiveSupport::Notifications) @@ -671,9 +675,9 @@ def run_after_save_phase(obj, phase) # record contents/tokens, and the rest of this file routes log output # through the same redactor. warn "[Parse::Webhooks] afterSave #{phase} callback raised for " \ - "#{obj.class}##{obj.id} -- the object is already persisted; " \ - "logging and continuing: #{e.class}: " \ - "#{Parse::Middleware::BodyBuilder.redact(e.message)}" + "#{obj.class}##{Parse::TerminalSafe.sanitize_line(obj.id)} -- the object is " \ + "already persisted; logging and continuing: #{e.class}: " \ + "#{Parse::TerminalSafe.sanitize_line(Parse::Middleware::BodyBuilder.redact(e.message))}" nil end @@ -853,20 +857,29 @@ def call!(env) begin payload = Parse::Webhooks::Payload.new(body_str, webhook_class) rescue => e - warn "Invalid webhook payload format: #{e}" + warn "Invalid webhook payload format: #{Parse::TerminalSafe.sanitize_line(e.to_s)}" response.write error("Invalid payload format. Should be valid JSON.") return response.finish end if self.logging.present? + # Everything interpolated below arrives in the webhook request body: + # the trigger/function names, the object id, and the whole payload are + # caller-controlled, and these lines go to the app server's console. + # Escape control sequences so a stored value cannot drive the terminal + # of whoever is tailing the log. if payload.trigger? - puts "[Webhooks::Request] --> #{payload.trigger_name} #{payload.parse_class}:#{payload.parse_id}" + puts "[Webhooks::Request] --> #{Parse::TerminalSafe.sanitize_line(payload.trigger_name)} " \ + "#{Parse::TerminalSafe.sanitize_line(payload.parse_class)}:" \ + "#{Parse::TerminalSafe.sanitize_line(payload.parse_id)}" elsif payload.function? - puts "[ParseWebhooks Request] --> Function #{payload.function_name}" + puts "[ParseWebhooks Request] --> Function #{Parse::TerminalSafe.sanitize_line(payload.function_name)}" end if self.logging == :debug puts "[Webhooks::Payload] ----------------------------" - puts Parse::Middleware::BodyBuilder.redact(payload.as_json.to_json) + puts Parse::TerminalSafe.sanitize( + Parse::Middleware::BodyBuilder.redact(payload.as_json.to_json) + ) puts "----------------------------------------------------\n" end end @@ -891,14 +904,14 @@ def call!(env) else if self.logging.present? puts "[Webhooks] --> Could not find mapping route for " \ - "#{Parse::Middleware::BodyBuilder.redact(payload.to_json)}" + "#{Parse::TerminalSafe.sanitize_line(Parse::Middleware::BodyBuilder.redact(payload.to_json))}" end end result = true if result.nil? if self.logging.present? puts "[Webhooks::Response] ----------------------------" - puts success(result) + puts Parse::TerminalSafe.sanitize(success(result)) puts "----------------------------------------------------\n" end response.write success(result) @@ -909,9 +922,13 @@ def call!(env) return response.finish rescue Parse::Webhooks::ResponseError, ActiveModel::ValidationError => e if payload.trigger? - puts "[Webhooks::ResponseError] >> #{payload.trigger_name} #{payload.parse_class}:#{payload.parse_id}: #{e}" + puts "[Webhooks::ResponseError] >> #{Parse::TerminalSafe.sanitize_line(payload.trigger_name)} " \ + "#{Parse::TerminalSafe.sanitize_line(payload.parse_class)}:" \ + "#{Parse::TerminalSafe.sanitize_line(payload.parse_id)}: " \ + "#{Parse::TerminalSafe.sanitize_line(e.to_s)}" elsif payload.function? - puts "[Webhooks::ResponseError] >> #{payload.function_name}: #{e}" + puts "[Webhooks::ResponseError] >> #{Parse::TerminalSafe.sanitize_line(payload.function_name)}: " \ + "#{Parse::TerminalSafe.sanitize_line(e.to_s)}" end response.write error(e.to_s) return response.finish diff --git a/test/lib/parse/terminal_safe_sinks_test.rb b/test/lib/parse/terminal_safe_sinks_test.rb new file mode 100644 index 0000000..f0bcba7 --- /dev/null +++ b/test/lib/parse/terminal_safe_sinks_test.rb @@ -0,0 +1,208 @@ +require_relative "../../test_helper" +require "parse-stack" +require "parse/agent" +require "parse/agent/mcp_client" +require "stringio" + +# The sanitizer is only worth anything if the sinks actually call it. These +# cover the paths where untrusted bytes reach a terminal or a log file. +class TestTerminalSafeSinks < Minitest::Test + ESC = 0x1B.chr + BEL = 0x07.chr + + # --- Parse::Agent::MCPClient::Result ------------------------------------- + # Evaluating `mcp.ask(...)` in IRB prints this struct, so `to_s`/`inspect` + # are a direct write of LLM output (conditioned on tenant rows) to the TTY. + + def test_result_to_s_escapes_the_answer + result = Parse::Agent::MCPClient::Result.new( + text: "All clear#{ESC}]52;c;cGF5bG9hZA==#{BEL}", + tool_calls: [], + transcript: [], + ) + + refute_includes result.to_s, ESC + refute_includes result.to_s, BEL + assert_includes result.to_s, "\\e]52;c;" + end + + def test_result_inspect_escapes_the_answer + result = Parse::Agent::MCPClient::Result.new( + text: "hi#{ESC}[2J", + tool_calls: [], + transcript: [], + ) + + refute_includes result.inspect, ESC + end + + def test_result_to_s_escapes_tool_call_names_and_arguments + result = Parse::Agent::MCPClient::Result.new( + text: "done", + tool_calls: [{ name: "query_class", arguments: { where: "title#{ESC}[31m" } }], + transcript: [], + ) + + refute_includes result.to_s, ESC + assert_includes result.to_s, "\\e[31m" + end + + def test_result_text_itself_is_left_untouched + # Sanitization is a rendering concern. A caller writing to a non-terminal + # surface still gets the exact bytes the model returned. + raw = "answer#{ESC}[0m" + result = Parse::Agent::MCPClient::Result.new(text: raw, tool_calls: [], transcript: []) + + assert_equal raw, result.text + end + + def test_result_answer_keeps_its_line_breaks + result = Parse::Agent::MCPClient::Result.new( + text: "line one\nline two", + tool_calls: [], + transcript: [], + ) + + assert_includes result.to_s, "line one\nline two" + end + + # --- LLM provider failure paths ------------------------------------------ + # An error body is echoed into the exception message, and a malformed success + # body produces a JSON::ParserError quoting the offending bytes. IRB prints + # both raw, so the failure path is a terminal sink too. + + def test_llm_http_error_body_is_escaped_in_the_exception + res = Struct.new(:code, :body).new("500", "upstream said#{ESC}]52;c;cHduCg==#{BEL}") + client = Parse::Agent::MCPClient.allocate + + err = assert_raises(RuntimeError) { client.send(:parse_llm_response!, res, "LLM call") } + + refute_includes err.message, ESC + refute_includes err.message, BEL + assert_includes err.message, "HTTP 500" + assert_includes err.message, "\\e]52;c;" + end + + def test_llm_error_body_cannot_forge_a_log_line + res = Struct.new(:code, :body).new("500", "boom\nINFO everything is fine") + client = Parse::Agent::MCPClient.allocate + + err = assert_raises(RuntimeError) { client.send(:parse_llm_response!, res, "LLM call") } + + refute_includes err.message, "\n" + end + + def test_unparseable_llm_body_raises_an_escaped_parser_error + res = Struct.new(:code, :body).new("200", "not json#{ESC}[2J") + client = Parse::Agent::MCPClient.allocate + + err = assert_raises(JSON::ParserError) { client.send(:parse_llm_response!, res, "LLM call") } + + refute_includes err.message, ESC + assert_includes err.message, "unparseable" + end + + def test_successful_llm_body_parses_normally + res = Struct.new(:code, :body).new("200", '{"choices":[]}') + client = Parse::Agent::MCPClient.allocate + + assert_equal({ "choices" => [] }, client.send(:parse_llm_response!, res, "LLM call")) + end + + # --- Parse::Middleware::Logging ------------------------------------------ + + def test_error_summary_escapes_control_characters_and_newlines + middleware = Parse::Middleware::Logging.new(->(env) { env }) + env = { status: 400, body: { "error" => "bad#{ESC}[2J\nINFO all clear" } } + + summary = middleware.send(:error_summary, env) + + refute_includes summary, ESC + refute_includes summary, "\n" + assert_includes summary, "\\e[2J" + assert_includes summary, "\\n" + end + + # --- The legacy `Parse.logging = true` printer ---------------------------- + # This is a separate code path from the Faraday logging middleware and prints + # straight to stdout, so it needs the same handling. + + def test_legacy_logging_printer_escapes_request_and_response + previous = Parse::Middleware::BodyBuilder.logging + begin + Parse::Middleware::BodyBuilder.logging = true + env = Faraday::Env.new + env.method = :post + env.url = URI("https://example.com/parse/classes/Post") + env.request_headers = { "X-Custom" => "v#{ESC}[2J" } + env.body = %({"title":"pwn#{ESC}]52;c;cHduCg==#{BEL}"}) + + inner = lambda do |e| + e.status = 200 + e.body = %({"results":[{"title":"pwn#{ESC}[2J"}]}) + e.response_headers = {} + Faraday::Response.new(e).tap { |r| r.finish(e) unless r.finished? } + end + + out, _err = capture_io do + Parse::Middleware::BodyBuilder.new(inner).call(env) + end + + refute_includes out, ESC + refute_includes out, BEL + assert_includes out, "\\e[2J" + ensure + Parse::Middleware::BodyBuilder.logging = previous + end + end + + # --- Parse::Client#_safe_warn -------------------------------------------- + + def test_safe_warn_escapes_the_server_error_text + io = StringIO.new + previous_logger = Parse::Middleware::Logging.current_logger + begin + logger = Logger.new(io) + logger.formatter = ->(_sev, _time, _prog, msg) { "#{msg}\n" } + Parse::Middleware::Logging.logger = logger + + response = Parse::Response.new + response.code = 141 + response.error = "handler failed#{ESC}[2J\nINFO all clear" + response.http_status = 400 + response.request = "POST /functions/doThing" + + Parse::Client._safe_warn("ScriptError", response) + + output = io.string + refute_includes output, ESC + assert_includes output, "\\e[2J" + assert_equal 1, output.lines.size + ensure + Parse::Middleware::Logging.logger = previous_logger + end + end + + def test_logged_body_is_escaped_and_stays_on_one_line + io = StringIO.new + previous_logger = Parse::Middleware::Logging.current_logger + previous_level = Parse::Middleware::Logging.current_log_level + begin + logger = Logger.new(io) + logger.formatter = ->(_sev, _time, _prog, msg) { "#{msg}\n" } + Parse::Middleware::Logging.logger = logger + Parse::Middleware::Logging.log_level = :debug + + middleware = Parse::Middleware::Logging.new(->(env) { env }) + middleware.send(:log_body, %({"title":"pwn#{ESC}[2J\nforged"}), "Response") + + output = io.string + refute_includes output, ESC + assert_includes output, "\\e[2J" + assert_equal 1, output.lines.size + ensure + Parse::Middleware::Logging.logger = previous_logger + Parse::Middleware::Logging.log_level = previous_level + end + end +end diff --git a/test/lib/parse/terminal_safe_test.rb b/test/lib/parse/terminal_safe_test.rb new file mode 100644 index 0000000..7bee818 --- /dev/null +++ b/test/lib/parse/terminal_safe_test.rb @@ -0,0 +1,170 @@ +require_relative "../../test_helper" +require "parse/terminal_safe" + +# Regression coverage for the terminal-escape sanitizer. +# +# The payloads below are written from codepoints rather than as literal escapes +# so this file itself never carries the control bytes it asserts about. +class TestTerminalSafe < Minitest::Test + T = Parse::TerminalSafe + + ESC = 0x1B.chr + BEL = 0x07.chr + CR = 0x0D.chr + BS = 0x08.chr + DEL = 0x7F.chr + + def test_osc_52_clipboard_write_is_neutralized + # OSC 52 asks the terminal to place the payload on the system clipboard, + # which is the step that turns "display" into "the operator pastes and runs". + payload = "invoice #{ESC}]52;c;cm0gLXJmIH4=#{BEL} paid" + out = T.sanitize(payload) + + refute_includes out, ESC + refute_includes out, BEL + assert_includes out, "\\e]52;c;" + assert_includes out, "\\a" + end + + def test_screen_clearing_csi_is_neutralized + out = T.sanitize("#{ESC}[2J#{ESC}[H you saw nothing") + + refute_includes out, ESC + assert_equal "\\e[2J\\e[H you saw nothing", out + end + + def test_c1_eight_bit_introducers_are_neutralized + # 0x9B is the 8-bit CSI, 0x9D the 8-bit OSC. A sanitizer that only looks + # for 0x1B misses both. + csi = [0x9B].pack("U") + osc = [0x9D].pack("U") + out = T.sanitize("#{csi}2J#{osc}0;title#{BEL}") + + refute_includes out, csi + refute_includes out, osc + assert_includes out, "\\x9B" + assert_includes out, "\\x9D" + end + + def test_carriage_return_overwrite_is_neutralized + out = T.sanitize("transfer $1.00#{CR}transfer $9,000.00") + + refute_includes out, CR + assert_includes out, "\\r" + end + + def test_backspace_and_del_are_neutralized + out = T.sanitize("safe#{BS}#{BS}#{DEL}evil") + + refute_includes out, BS + refute_includes out, DEL + assert_equal "safe\\b\\b\\x7Fevil", out + end + + def test_bidi_override_is_neutralized + rlo = [0x202E].pack("U") + pdf = [0x202C].pack("U") + out = T.sanitize("report#{rlo}gnp.exe#{pdf}") + + refute_includes out, rlo + refute_includes out, pdf + assert_includes out, "\\u202E" + assert_includes out, "\\u202C" + end + + def test_zero_width_and_bom_are_neutralized + out = T.sanitize([0x200B, 0x2060, 0xFEFF].pack("U*")) + + assert_equal "\\u200B\\u2060\\uFEFF", out + end + + def test_arabic_letter_mark_is_neutralized + # U+061C is an implicit bidi control and reorders a line exactly like the + # explicit overrides, but sits outside the U+202x block. + alm = [0x061C].pack("U") + out = T.sanitize("total#{alm}reversed") + + refute_includes out, alm + assert_includes out, "\\u061C" + end + + def test_unicode_line_separators_are_neutralized + # U+2028/U+2029 are treated as line breaks by terminals, editors, and log + # readers, so leaving them intact would reintroduce forged records. + out = T.sanitize_line("not found#{[0x2028].pack("U")}INFO all clear") + + refute_includes out, [0x2028].pack("U") + assert_includes out, "\\u2028" + end + + def test_unicode_paragraph_separator_is_neutralized_in_both_modes + ps = [0x2029].pack("U") + + refute_includes T.sanitize("a#{ps}b"), ps + refute_includes T.sanitize_line("a#{ps}b"), ps + end + + def test_source_file_contains_no_literal_control_characters + # The sanitizer's own source must not carry the bytes it defends against, + # or reading the file in a terminal is itself the attack. + source = File.read(File.expand_path("../../../lib/parse/terminal_safe.rb", __dir__)) + stripped = source.delete("\n\t") + + assert_equal stripped, T.sanitize(stripped) + end + + def test_newlines_and_tabs_survive_sanitize + assert_equal "a\nb\tc", T.sanitize("a\nb\tc") + end + + def test_sanitize_line_escapes_newlines_to_stop_log_forging + forged = "not found\n2026-08-14 INFO all clear, nothing to see" + out = T.sanitize_line(forged) + + refute_includes out, "\n" + assert_includes out, "\\n" + end + + def test_sanitize_line_still_allows_tabs + assert_equal "a\tb", T.sanitize_line("a\tb") + end + + def test_plain_text_is_returned_unchanged + assert_equal "Hello, world! 100% ", T.sanitize("Hello, world! 100% ") + end + + def test_unicode_text_is_preserved + text = "Grüße, 日本語, emoji 🎉" + assert_equal text, T.sanitize(text) + end + + def test_is_idempotent + once = T.sanitize("x#{ESC}[31my") + assert_equal once, T.sanitize(once) + end + + def test_nil_and_non_strings_are_coerced + assert_equal "", T.sanitize(nil) + assert_equal "42", T.sanitize(42) + assert_equal "[1, 2]", T.sanitize([1, 2]) + end + + def test_binary_and_invalid_encoding_do_not_raise + # A truncated multi-byte sequence off the wire must not turn the defensive + # sanitizer itself into the failure. + invalid = "abc\xC3".dup.force_encoding(Encoding::ASCII_8BIT) + out = T.sanitize(invalid) + + assert_kind_of String, out + assert out.valid_encoding? + assert_includes out, "abc" + end + + def test_binary_string_with_escape_byte_is_neutralized + invalid = "a#{ESC}[2Jb\xFF".dup.force_encoding(Encoding::ASCII_8BIT) + out = T.sanitize(invalid) + + refute_includes out, ESC + assert_includes out, "\\e[2J" + end +end