parse-stack-next 5.7.1 → 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.
@@ -0,0 +1,138 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ # Neutralizes terminal control sequences in untrusted text before it is
6
+ # written to a terminal, a log record, or an IRB `inspect` line.
7
+ #
8
+ # Parse Server returns whatever a tenant stored. Any string that originates
9
+ # in the database, in a server error message, or in an LLM answer is
10
+ # attacker-influenced, and a raw ESC byte in that string is not inert once it
11
+ # reaches a TTY. It can clear the screen, rewrite lines the operator has
12
+ # already read, retitle the window, or (via OSC 52) write the attacker's
13
+ # payload into the system clipboard so the operator's next paste runs it.
14
+ # Bidirectional overrides are the same class of problem: they reorder a line
15
+ # so what the operator reads is not what the bytes say.
16
+ #
17
+ # This module is the SDK's single answer to that. It escapes rather than
18
+ # deletes, so the offending bytes stay visible in the output (rendered as
19
+ # `\e`, `\u202E`, and friends) and an operator can still see that
20
+ # something tried.
21
+ #
22
+ # It sanitizes *rendering*, never storage. `result.text`, `object.title`, and
23
+ # the parsed response body keep their exact bytes. Only the human-readable
24
+ # form built for a terminal or a log line runs through here.
25
+ #
26
+ # @example Rendering an untrusted value
27
+ # puts Parse::TerminalSafe.sanitize(post.title)
28
+ #
29
+ # @example A single-line log record. Newlines are escaped too, so a stored
30
+ # value cannot forge a second log entry.
31
+ # logger.warn "#{status} - #{Parse::TerminalSafe.sanitize_line(error)}"
32
+ module TerminalSafe
33
+ extend self
34
+
35
+ # Codepoint ranges neutralized on every call:
36
+ #
37
+ # - 0x00-0x08, 0x0B-0x1F: C0 controls except TAB (0x09) and LF (0x0A).
38
+ # This is where ESC (0x1B, the CSI/OSC/DCS introducer), BEL (0x07, the
39
+ # OSC string terminator), CR (0x0D, overwrite-the-line), and BS (0x08,
40
+ # erase-the-previous-character) live.
41
+ # - 0x7F: DEL.
42
+ # - 0x80-0x9F: C1 controls, the 8-bit forms of the same introducers
43
+ # (0x9B is CSI, 0x9D is OSC, 0x90 is DCS).
44
+ # - 0x061C: Arabic letter mark, an implicit bidirectional control that
45
+ # reorders a line exactly like the explicit marks below.
46
+ # - 0x200B-0x200F: zero-width space, non-joiner, joiner, LRM, RLM.
47
+ # - 0x2028-0x2029: line and paragraph separator. Widely treated as a line
48
+ # break by terminals, editors, and log readers, so leaving them intact
49
+ # would reintroduce the forged-record problem that escaping LF solves.
50
+ # A legitimate line break is LF, which is preserved.
51
+ # - 0x202A-0x202E: bidirectional embedding and override.
52
+ # - 0x2060-0x2064: word joiner and the invisible math operators.
53
+ # - 0x2066-0x2069: the bidirectional isolates.
54
+ # - 0xFEFF: zero-width no-break space (a BOM appearing mid-string).
55
+ #
56
+ # TAB and LF are deliberately absent: both are ordinary formatting in
57
+ # multi-line output. {#sanitize_line} escapes LF as well.
58
+ UNSAFE_RANGES = [
59
+ 0x00..0x08, 0x0B..0x1F, 0x7F..0x9F,
60
+ 0x061C..0x061C,
61
+ 0x200B..0x200F, 0x2028..0x2029, 0x202A..0x202E,
62
+ 0x2060..0x2064, 0x2066..0x2069,
63
+ 0xFEFF..0xFEFF,
64
+ ].freeze
65
+
66
+ # Built from codepoints rather than written as literal escapes so the
67
+ # source of this file stays free of the very bytes it defends against.
68
+ def self.build_pattern(ranges)
69
+ body = ranges.map { |r| format("\\u{%04X}-\\u{%04X}", r.first, r.last) }.join
70
+ Regexp.new("[#{body}]")
71
+ end
72
+ private_class_method :build_pattern
73
+
74
+ UNSAFE_RE = build_pattern(UNSAFE_RANGES)
75
+
76
+ # The same set plus LF, for output that must occupy exactly one line.
77
+ UNSAFE_LINE_RE = build_pattern(UNSAFE_RANGES + [0x0A..0x0A])
78
+
79
+ # Readable escapes for the controls an operator is most likely to see.
80
+ # Everything else falls back to `\xNN` or `\uNNNN`.
81
+ NAMED_ESCAPES = {
82
+ "\0" => "\\0",
83
+ "\a" => "\\a",
84
+ "\b" => "\\b",
85
+ "\n" => "\\n",
86
+ "\v" => "\\v",
87
+ "\f" => "\\f",
88
+ "\r" => "\\r",
89
+ "\e" => "\\e",
90
+ }.freeze
91
+
92
+ # Escape terminal control sequences in `str`, preserving newlines and tabs.
93
+ #
94
+ # @param str [String, #to_s, nil] untrusted text.
95
+ # @return [String] the same text with control characters escaped. Non-UTF-8
96
+ # and invalid-encoding input is coerced to UTF-8 first, so this never
97
+ # raises on a binary response body.
98
+ def sanitize(str)
99
+ escape(str, UNSAFE_RE)
100
+ end
101
+
102
+ # Escape terminal control sequences and newlines, so untrusted text cannot
103
+ # forge additional lines. Use for anything written as a single log record
104
+ # or a single console line.
105
+ #
106
+ # @param str [String, #to_s, nil] untrusted text.
107
+ # @return [String]
108
+ def sanitize_line(str)
109
+ escape(str, UNSAFE_LINE_RE)
110
+ end
111
+
112
+ private
113
+
114
+ def escape(str, pattern)
115
+ s = coerce(str)
116
+ return s unless s.match?(pattern)
117
+ s.gsub(pattern) { |ch| escape_char(ch) }
118
+ end
119
+
120
+ # Force the input to valid UTF-8 without raising. A response body read off
121
+ # the wire can be ASCII-8BIT, and a truncated multi-byte sequence is not
122
+ # valid UTF-8. Either would make `match?` raise ArgumentError, which is
123
+ # exactly the wrong outcome for a defensive sanitizer.
124
+ def coerce(str)
125
+ s = str.is_a?(String) ? str : str.to_s
126
+ s = s.dup.force_encoding(Encoding::UTF_8) unless s.encoding == Encoding::UTF_8
127
+ s = s.scrub("�") unless s.valid_encoding?
128
+ s
129
+ end
130
+
131
+ def escape_char(ch)
132
+ named = NAMED_ESCAPES[ch]
133
+ return named if named
134
+ cp = ch.ord
135
+ cp <= 0xFF ? format("\\x%02X", cp) : format("\\u%04X", cp)
136
+ end
137
+ end
138
+ end
@@ -11,6 +11,7 @@ require "active_model/serializers/json"
11
11
  require "rack"
12
12
  require "ostruct"
13
13
  require_relative "client"
14
+ require_relative "terminal_safe"
14
15
  # Note: Do not require "stack" here - this file is loaded from stack.rb
15
16
  # and adding that require would create a circular dependency.
16
17
  require_relative "model/object"
@@ -326,7 +327,10 @@ module Parse
326
327
  # @param error [StandardError] the raised error.
327
328
  # @return [void]
328
329
  def report_handler_error(type, error)
329
- warn "[Parse::Webhooks] #{type} handler raised #{error.class}: #{error.message}; " \
330
+ # The handler's message is application-authored but routinely quotes the
331
+ # payload that triggered it, which is caller-controlled.
332
+ warn "[Parse::Webhooks] #{type} handler raised #{error.class}: " \
333
+ "#{Parse::TerminalSafe.sanitize_line(error.message)}; " \
330
334
  "continuing with the remaining handlers " \
331
335
  "(Parse::Webhooks.abort_after_callbacks_on_error is false)"
332
336
  return unless defined?(ActiveSupport::Notifications)
@@ -444,7 +448,6 @@ module Parse
444
448
  payload.instance_variable_set(:@ruby_initiated, ruby_initiated)
445
449
  trusted_ruby_initiated = ruby_initiated && (payload.master? == true)
446
450
  else
447
- ruby_initiated = false
448
451
  trusted_ruby_initiated = false
449
452
  end
450
453
 
@@ -672,9 +675,9 @@ module Parse
672
675
  # record contents/tokens, and the rest of this file routes log output
673
676
  # through the same redactor.
674
677
  warn "[Parse::Webhooks] afterSave #{phase} callback raised for " \
675
- "#{obj.class}##{obj.id} -- the object is already persisted; " \
676
- "logging and continuing: #{e.class}: " \
677
- "#{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))}"
678
681
  nil
679
682
  end
680
683
 
@@ -854,20 +857,29 @@ module Parse
854
857
  begin
855
858
  payload = Parse::Webhooks::Payload.new(body_str, webhook_class)
856
859
  rescue => e
857
- warn "Invalid webhook payload format: #{e}"
860
+ warn "Invalid webhook payload format: #{Parse::TerminalSafe.sanitize_line(e.to_s)}"
858
861
  response.write error("Invalid payload format. Should be valid JSON.")
859
862
  return response.finish
860
863
  end
861
864
 
862
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.
863
871
  if payload.trigger?
864
- puts "[Webhooks::Request] --> #{payload.trigger_name} #{payload.parse_class}:#{payload.parse_id}"
872
+ puts "[Webhooks::Request] --> #{Parse::TerminalSafe.sanitize_line(payload.trigger_name)} " \
873
+ "#{Parse::TerminalSafe.sanitize_line(payload.parse_class)}:" \
874
+ "#{Parse::TerminalSafe.sanitize_line(payload.parse_id)}"
865
875
  elsif payload.function?
866
- puts "[ParseWebhooks Request] --> Function #{payload.function_name}"
876
+ puts "[ParseWebhooks Request] --> Function #{Parse::TerminalSafe.sanitize_line(payload.function_name)}"
867
877
  end
868
878
  if self.logging == :debug
869
879
  puts "[Webhooks::Payload] ----------------------------"
870
- puts Parse::Middleware::BodyBuilder.redact(payload.as_json.to_json)
880
+ puts Parse::TerminalSafe.sanitize(
881
+ Parse::Middleware::BodyBuilder.redact(payload.as_json.to_json)
882
+ )
871
883
  puts "----------------------------------------------------\n"
872
884
  end
873
885
  end
@@ -892,14 +904,14 @@ module Parse
892
904
  else
893
905
  if self.logging.present?
894
906
  puts "[Webhooks] --> Could not find mapping route for " \
895
- "#{Parse::Middleware::BodyBuilder.redact(payload.to_json)}"
907
+ "#{Parse::TerminalSafe.sanitize_line(Parse::Middleware::BodyBuilder.redact(payload.to_json))}"
896
908
  end
897
909
  end
898
910
 
899
911
  result = true if result.nil?
900
912
  if self.logging.present?
901
913
  puts "[Webhooks::Response] ----------------------------"
902
- puts success(result)
914
+ puts Parse::TerminalSafe.sanitize(success(result))
903
915
  puts "----------------------------------------------------\n"
904
916
  end
905
917
  response.write success(result)
@@ -910,9 +922,13 @@ module Parse
910
922
  return response.finish
911
923
  rescue Parse::Webhooks::ResponseError, ActiveModel::ValidationError => e
912
924
  if payload.trigger?
913
- puts "[Webhooks::ResponseError] >> #{payload.trigger_name} #{payload.parse_class}:#{payload.parse_id}: #{e}"
925
+ puts "[Webhooks::ResponseError] >> #{Parse::TerminalSafe.sanitize_line(payload.trigger_name)} " \
926
+ "#{Parse::TerminalSafe.sanitize_line(payload.parse_class)}:" \
927
+ "#{Parse::TerminalSafe.sanitize_line(payload.parse_id)}: " \
928
+ "#{Parse::TerminalSafe.sanitize_line(e.to_s)}"
914
929
  elsif payload.function?
915
- puts "[Webhooks::ResponseError] >> #{payload.function_name}: #{e}"
930
+ puts "[Webhooks::ResponseError] >> #{Parse::TerminalSafe.sanitize_line(payload.function_name)}: " \
931
+ "#{Parse::TerminalSafe.sanitize_line(e.to_s)}"
916
932
  end
917
933
  response.write error(e.to_s)
918
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.1
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