Skip to content

v5.7.3 - Terminal Escape Sanitization, Console Hardening - #41

Merged
AdrianCurtin merged 1 commit into
mainfrom
v0573
Aug 14, 2026
Merged

v5.7.3 - Terminal Escape Sanitization, Console Hardening#41
AdrianCurtin merged 1 commit into
mainfrom
v0573

Conversation

@AdrianCurtin

@AdrianCurtin AdrianCurtin commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Stored values can no longer drive the operator's terminal

Parse Server returns whatever a tenant stored, and until now those bytes reached the operator's terminal intact. A row whose field carried an OSC 52 sequence could write an attacker-chosen payload into the system clipboard, and CSI or carriage-return sequences could clear the screen or overwrite lines already on it, so what was displayed was not what was stored. This release adds one canonical sanitizer and routes every terminal and log sink through it. It also stops parse-console --url from trusting the document it fetches.

Changes

A single sanitizer for untrusted output

  • NEW: Parse::TerminalSafe neutralizes terminal control sequences in text that is about to reach 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 filter looking only for 0x1B misses), the zero-width characters, and the Unicode bidirectional marks, 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.
  • NEW: Control characters are escaped rather than deleted, so the attempt stays visible in the output as \e, , and friends instead of vanishing. Non-UTF-8 and invalid-encoding input is coerced first, so the sanitizer never raises on a binary response body, and the pattern is built from codepoint ranges so the file itself carries none of the bytes it defends against.

Every terminal and log sink now renders through it

  • FIXED: Values read back from Parse Server reached the terminal with their control bytes intact. The affected paths were 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 and 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.
  • FIXED: Untrusted text interpolated into a log record could contain a raw newline and forge a second, attacker-authored entry. Log records use the newline-escaping form, applied before the body-length cap so a truncated record stays on one line too.
  • 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 endpoint could still reach the terminal through the failure path. Both are escaped now, and the quoted body is capped.
  • 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: The loader 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 should set them in the shell instead.
  • FIXED: The document was parsed with JSON.load, which honors json_class additions and will instantiate arbitrary already-loaded classes from the document. It uses JSON.parse now.
  • FIXED: The fetch is a streaming request under a 1 MiB cap that 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 ever running again.
  • CHANGED: Plaintext HTTP is refused 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: The supplied URL was echoed before validation, and the (possibly remotely supplied) server URL and application ID were printed verbatim after connecting. All three are escaped, as is the error output from the fetch path, which can quote the fetched bytes.

Behavior Notes

Sanitization applies to rendering, never to storage. result.text, object.title, and the parsed response body keep their exact bytes, so application code writing to a non-terminal surface (an HTTP response, a database, a file) is unaffected. Only the human-readable form built for a terminal or a log line is escaped.

This addresses terminal output. It is not a Ruby code-execution issue: IRB does not evaluate program output as input, and stored values do not obtain an IRB binding. Turning escaped output into command execution additionally requires a terminal emulator with dangerous features or remote control enabled, or an operator pasting a poisoned clipboard.

Two related items remain the deploying application's responsibility, since they live outside the SDK: keeping internet-facing agents in a separate process and OS account from administrative consoles, and auditing application-defined agent_methods and custom tools for shell, eval, and deserialization sinks. The SDK-side guard for the latter is unchanged in Parse::Agent::Tools, which validates identifiers and blocks eval, system, exec, spawn, send, and binding.

Code Example

title = post.title            # "Q3 report\e]52;c;cm0gLXJmIH4=\a"

puts title                    # before: the terminal takes the clipboard write
puts Parse::TerminalSafe.sanitize(title)
# => Q3 report\e]52;c;cm0gLXJmIH4=\a   (visible, inert)

# Log records escape newlines too, so a stored value cannot forge an entry.
Parse::TerminalSafe.sanitize_line("not found\n2026-08-14 INFO all clear")
# => "not found\\n2026-08-14 INFO all clear"

post.title                    # unchanged: sanitization is a rendering concern

Commit: 16d312a
Author: Adrian Curtin
Date: August 14, 2026

@AdrianCurtin AdrianCurtin changed the title Escape terminal control sequences in untrusted output v5.7.3 - Terminal Escape Sanitization, Console Hardening Aug 14, 2026
Add Parse::TerminalSafe and route every path where server-stored values reach a terminal or log record through it.
@AdrianCurtin
AdrianCurtin merged commit acc755d into main Aug 14, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant