coolhand 0.10.0 → 0.10.1

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.
@@ -20,6 +20,7 @@ module Coolhand
20
20
 
21
21
  def valid?
22
22
  @errors.clear
23
+ @claimed_id = nil
23
24
  @payload = request.raw_post || request.body.read
24
25
 
25
26
  return false unless payload_valid?
@@ -28,7 +29,9 @@ module Coolhand
28
29
  secret_bytes = extract_secret_bytes
29
30
  webhook_signature, webhook_timestamp, webhook_id = extract_webhook_headers
30
31
 
31
- return validate_headers_in_non_production_env unless webhook_signature && webhook_timestamp && webhook_id
32
+ unless webhook_signature && webhook_timestamp && webhook_id
33
+ return reject_unsigned("Missing OpenAI webhook signature, timestamp, or id headers")
34
+ end
32
35
 
33
36
  verify_signature(webhook_signature, webhook_timestamp, webhook_id, secret_bytes)
34
37
  end
@@ -37,12 +40,29 @@ module Coolhand
37
40
  @errors.join(", ")
38
41
  end
39
42
 
43
+ # Gives back the webhook-id claim made by the last successful `valid?` so a same-id retry
44
+ # (e.g. after processing failed) isn't rejected as a replay. A no-op when nothing was claimed
45
+ # or when the store has no `release!` (custom stores may implement only `claim!`).
46
+ def release_claim!
47
+ claimed_id = @claimed_id
48
+ return unless claimed_id
49
+
50
+ @claimed_id = nil
51
+ id_store.release!(claimed_id) if id_store.respond_to?(:release!)
52
+ rescue StandardError => e
53
+ # Best effort: a failing store must not stop the caller from rejecting the request.
54
+ Rails.logger.error("OpenAI webhook id release failed: #{e.message}")
55
+ end
56
+
40
57
  private
41
58
 
42
59
  attr_reader :webhook_secret
43
60
 
61
+ # Only a nil payload counts as missing. An empty string is a real (empty) body, so it continues
62
+ # to the signature check, where it is accepted only if it was actually signed.
44
63
  def payload_valid?
45
64
  return true if @payload
65
+ return reject_unsigned("Empty webhook payload") if Coolhand.required_field?(webhook_secret)
46
66
 
47
67
  enforce_or_allow("Empty webhook payload",
48
68
  "Empty webhook payload - allowing in #{Rails.env} environment")
@@ -57,10 +77,8 @@ module Coolhand
57
77
  # check is skipped with a warning.
58
78
  def enforce_or_allow(problem, allow_warning)
59
79
  if should_enforce_strict_validation?
60
- @errors << "#{problem} - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
61
- "not in development/test allowlist)"
62
- Rails.logger.error(@errors.last)
63
- false
80
+ reject("#{problem} - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
81
+ "not in development/test allowlist)")
64
82
  else
65
83
  Rails.logger.warn(allow_warning)
66
84
  true
@@ -83,16 +101,15 @@ module Coolhand
83
101
  [webhook_signature, webhook_timestamp, webhook_id]
84
102
  end
85
103
 
86
- def validate_headers_in_non_production_env
87
- if should_enforce_strict_validation?
88
- @errors << "Missing OpenAI webhook signature, timestamp, or id headers - " \
89
- "rejecting webhook (Rails.env=#{Rails.env.inspect} not in development/test allowlist)"
90
- Rails.logger.error(@errors.last)
91
- false
92
- else
93
- Rails.logger.warn("Missing OpenAI webhook headers - skipping verification in #{Rails.env}")
94
- true
95
- end
104
+ # A secret is configured, so anything that can't be verified is rejected in every environment.
105
+ def reject_unsigned(problem)
106
+ reject("#{problem} - rejecting webhook")
107
+ end
108
+
109
+ def reject(message)
110
+ @errors << message
111
+ Rails.logger.error(message)
112
+ false
96
113
  end
97
114
 
98
115
  def verify_signature(webhook_signature, webhook_timestamp, webhook_id, secret_bytes)
@@ -102,11 +119,7 @@ module Coolhand
102
119
  # An empty key (a bare "whsec_" secret) makes the HMAC forgeable by anyone, so never accept it.
103
120
  signature_valid = !secret_bytes.empty? && signature_matches?(webhook_signature, expected_signature)
104
121
 
105
- unless signature_valid
106
- @errors << "OpenAI webhook signature verification failed"
107
- Rails.logger.error(@errors.last)
108
- return false
109
- end
122
+ return reject("OpenAI webhook signature verification failed") unless signature_valid
110
123
 
111
124
  return false unless timestamp_fresh?(webhook_timestamp)
112
125
  return false unless webhook_id_unused?(webhook_id)
@@ -134,18 +147,20 @@ module Coolhand
134
147
  age = (Time.now.to_i - webhook_timestamp.to_i).abs
135
148
  return true if age <= tolerance
136
149
 
137
- @errors << "OpenAI webhook timestamp outside replay-protection tolerance window"
138
- Rails.logger.error(@errors.last)
139
- false
150
+ reject("OpenAI webhook timestamp outside replay-protection tolerance window")
140
151
  end
141
152
 
153
+ # The timestamp check accepts anything within +/- tolerance of now, so a request stamped
154
+ # `now + tolerance` stays fresh until `now + 2 * tolerance`. Remember the id for twice the
155
+ # tolerance so it can't be replayed after its claim expires but while it is still fresh.
142
156
  def webhook_id_unused?(webhook_id)
143
- tolerance = Coolhand.configuration.webhook_replay_tolerance_seconds
144
- return true if id_store.claim!(webhook_id, tolerance)
157
+ ttl = Coolhand.configuration.webhook_replay_tolerance_seconds * 2
158
+ if id_store.claim!(webhook_id, ttl)
159
+ @claimed_id = webhook_id
160
+ return true
161
+ end
145
162
 
146
- @errors << "OpenAI webhook id already processed (replay protection)"
147
- Rails.logger.error(@errors.last)
148
- false
163
+ reject("OpenAI webhook id already processed (replay protection)")
149
164
  end
150
165
 
151
166
  def id_store
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ module PathNormalizer
5
+ UNRESERVED_CHAR = /\A[A-Za-z0-9\-._~]\z/
6
+ private_constant :UNRESERVED_CHAR
7
+
8
+ module_function
9
+
10
+ # Path used for matching only (the URL that is sent and logged is never touched), so
11
+ # sloppy joins like "/openai//files", "/openai/./files", "/openai/%66iles" or
12
+ # "/v2/chat/../../v1/rerank" can't slip past an exclude pattern or a path anchor.
13
+ # Decodes unreserved-character escapes (%2F and other reserved escapes stay encoded, and
14
+ # decoding is one pass, so "%252e" stays literal), collapses repeated "/", and resolves
15
+ # "." and ".." segments without ever climbing above the root. Case is left as is. A path
16
+ # that can't be normalized falls back to the raw one rather than raising, and an empty
17
+ # path comes back as "/".
18
+ def normalize(path)
19
+ decoded = path.gsub(/%\h\h/) do |escape|
20
+ char = escape[1, 2].hex.chr
21
+ UNRESERVED_CHAR.match?(char) ? char : escape
22
+ end
23
+ segments = decoded.split("/").each_with_object([]) do |segment, kept|
24
+ case segment
25
+ when "", "." then next
26
+ when ".." then kept.pop
27
+ else kept << segment
28
+ end
29
+ end
30
+ directory = !segments.empty? && decoded.end_with?("/", "/.", "/..")
31
+ "/#{segments.join('/')}#{'/' if directory}"
32
+ rescue StandardError
33
+ path
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Redacts credentials in raw String bodies, which BodyRedactor's tree walk never sees: a streamed
5
+ # (server-sent event) response arrives as one String of `data: {...}` lines, and a webhook body
6
+ # may be raw JSON text. Each JSON object/array payload is parsed, run through BodyRedactor and
7
+ # re-serialized; text that is not JSON (plain text, `[DONE]`, `event:` and comment lines, a
8
+ # payload that fails to parse) is left byte for byte. A JSON payload is always re-serialized, even
9
+ # when nothing in it needed redacting: JSON.parse keeps only the last of a repeated key, so
10
+ # "nothing changed" cannot be trusted to mean "no credential in the original text".
11
+ module StringBodyRedactor
12
+ module_function
13
+
14
+ NESTING_MARKER = { "_coolhand_capture_skipped" => "nesting_too_deep" }.freeze
15
+ FAILED_MARKER = { "_coolhand_capture_skipped" => "redaction_failed" }.freeze
16
+
17
+ # A `data:` field at the start of a line (LF, CRLF or bare CR), or after a byte order mark,
18
+ # which the SSE spec ignores. Whitespace after the colon is captured apart from the payload so
19
+ # a rewritten line keeps its own.
20
+ DATA_LINE = /(?<![^\r\n\uFEFF])data:([ \t]*)([^\r\n]*)/
21
+ JSON_CONTAINER = /\A\uFEFF?\s*[\[{]/
22
+
23
+ def redact(body)
24
+ text = utf8_text(body)
25
+ json = redacted_json(text) if text.match?(JSON_CONTAINER)
26
+ json || redact_stream(body, text)
27
+ rescue StandardError => e
28
+ # Fail closed: never log a body that could not be checked. The message is left out because
29
+ # a parser error can quote the payload.
30
+ Coolhand.log "❌ Error redacting string body: #{e.class}"
31
+ FAILED_MARKER
32
+ end
33
+
34
+ # Captured bodies are UTF-8, but a binary-tagged, other-encoded or invalid-UTF-8 String (a
35
+ # webhook body can be any of them) is not worth a failure: inspect a UTF-8 working copy, with
36
+ # invalid bytes replaced, so every line can still be checked.
37
+ def utf8_text(body)
38
+ text = case body.encoding
39
+ when Encoding::UTF_8 then body
40
+ when Encoding::BINARY then body.dup.force_encoding(Encoding::UTF_8)
41
+ else body.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
42
+ end
43
+ text.valid_encoding? ? text : text.scrub
44
+ end
45
+
46
+ # One pass with gsub (linear in the body); the original String is returned when no line differs
47
+ # once re-serialized (providers emit compact JSON, so a clean stream usually round-trips as is).
48
+ def redact_stream(body, text)
49
+ changed = false
50
+ redacted = text.gsub(DATA_LINE) do |line|
51
+ match = Regexp.last_match
52
+ payload = match[2].match?(JSON_CONTAINER) ? redacted_json(match[2]) : nil
53
+ next line if payload.nil? || payload == match[2]
54
+
55
+ changed = true
56
+ "data:#{match[1]}#{payload}"
57
+ end
58
+ changed ? redacted : body
59
+ end
60
+
61
+ # The redacted, re-serialized JSON; nil when the text should stay as is (not a JSON
62
+ # object/array, or unparsable). Anything else that goes wrong yields a `redaction_failed`
63
+ # marker rather than the unchecked text.
64
+ def redacted_json(text)
65
+ # NaN/Infinity are not JSON, but some servers emit them; a payload that failed to parse
66
+ # would be logged unredacted next to its credential-named fields.
67
+ parsed = JSON.parse(text.delete_prefix("\uFEFF"), allow_nan: true)
68
+ return unless parsed.is_a?(Hash) || parsed.is_a?(Array)
69
+
70
+ redacted = BodyRedactor.redact(parsed)
71
+ JSON.generate(redacted, allow_nan: true)
72
+ rescue JSON::NestingError
73
+ JSON.generate(NESTING_MARKER)
74
+ rescue JSON::ParserError
75
+ nil
76
+ rescue StandardError => e
77
+ # e.g. a string with a lone surrogate that parses but cannot be serialized: fail closed for
78
+ # this payload alone so the rest of the body is still redacted.
79
+ Coolhand.log "❌ Error redacting JSON payload: #{e.class}"
80
+ JSON.generate(FAILED_MARKER)
81
+ end
82
+ end
83
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coolhand
4
- VERSION = "0.10.0"
4
+ VERSION = "0.10.1"
5
5
  end
@@ -111,9 +111,9 @@ module Coolhand
111
111
  source_api: SOURCE_API,
112
112
  model: model,
113
113
  request_headers: {},
114
- request_body: batch_item["request"],
114
+ request_body: BatchBodyCap.cap(batch_item["request"]),
115
115
  response_headers: {},
116
- response_body: batch_item["response"],
116
+ response_body: BatchBodyCap.cap(batch_item["response"]),
117
117
  status_code: 200,
118
118
  start_time: start_time,
119
119
  end_time: end_time,
@@ -5,6 +5,27 @@ require_relative "open_ai/batch_result_processor"
5
5
 
6
6
  module Coolhand
7
7
  module WebhookInterceptor
8
+ # Helpers that must never be routable as controller actions.
9
+ HELPER_METHODS = %w[intercept_batch_request webhook_secret process_event].freeze
10
+ private_constant :HELPER_METHODS
11
+
12
+ # `private` below only covers the definitions in this module. A host that
13
+ # re-defines `webhook_secret` or `process_event` as public would make them
14
+ # public again, and Rails would then count them as action methods. Filtering
15
+ # them out of `action_methods` keeps them unroutable either way, without this
16
+ # gem depending on Rails (`super` is Rails' own `action_methods`).
17
+ module ClassMethods
18
+ def action_methods
19
+ super - HELPER_METHODS
20
+ end
21
+ end
22
+
23
+ def self.included(base)
24
+ base.extend(ClassMethods)
25
+ end
26
+
27
+ private
28
+
8
29
  def intercept_batch_request
9
30
  Rails.logger.info("[Interceptor] #{controller_name}##{action_name}")
10
31
 
@@ -27,6 +48,8 @@ module Coolhand
27
48
  # let the controller action run for a request whose webhook
28
49
  # signature was never confirmed valid.
29
50
  Rails.logger.error("[Interceptor] Failed to intercept batch request: #{e.message}")
51
+ # Give the webhook-id back so the sender's retry isn't rejected as a replay.
52
+ @validator&.release_claim!
30
53
  head :unauthorized
31
54
  false
32
55
  end
data/lib/coolhand.rb CHANGED
@@ -14,15 +14,18 @@ require_relative "coolhand/declared_context"
14
14
  require_relative "coolhand/declarable"
15
15
  require_relative "coolhand/rails_auto_declare"
16
16
  require_relative "coolhand/log_queue"
17
+ require_relative "coolhand/body_redactor"
18
+ require_relative "coolhand/string_body_redactor"
17
19
  require_relative "coolhand/base_interceptor"
18
20
  require_relative "coolhand/net_http_interceptor"
21
+ require_relative "coolhand/batch_body_cap"
19
22
  require_relative "coolhand/api_service"
20
23
  require_relative "coolhand/logger_service"
21
24
  require_relative "coolhand/feedback_service"
22
25
  require_relative "coolhand/template_service"
23
26
  require_relative "coolhand/optimization_feedback_link_service"
24
27
  require_relative "coolhand/workload_service"
25
- require_relative "coolhand/public_content_service"
28
+ require_relative "coolhand/help_center_service"
26
29
  require_relative "coolhand/log_service"
27
30
  require_relative "coolhand/webhook_interceptor"
28
31
  # Only when Rails is already loaded; the gem has no Rails dependency.
@@ -148,10 +151,10 @@ module Coolhand
148
151
  LogService.new
149
152
  end
150
153
 
151
- # Creates a new PublicContentService, for reading the Coolhand help center. Needs no API key; a
154
+ # Creates a new HelpCenterService, for reading the Coolhand help center. Needs no API key; a
152
155
  # configured one is sent and lifts the anonymous rate limit.
153
- def public_content_service
154
- PublicContentService.new
156
+ def help_center_service
157
+ HelpCenterService.new
155
158
  end
156
159
 
157
160
  # Creates a new OptimizationFeedbackLinkService instance, for linking feedback to an
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coolhand
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Carroll
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-10-21 00:00:00.000000000 Z
12
+ date: 2026-10-26 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: base64
@@ -60,6 +60,9 @@ files:
60
60
  - lib/coolhand.rb
61
61
  - lib/coolhand/api_service.rb
62
62
  - lib/coolhand/base_interceptor.rb
63
+ - lib/coolhand/batch_body_cap.rb
64
+ - lib/coolhand/body_redactor.rb
65
+ - lib/coolhand/body_stream_tap.rb
63
66
  - lib/coolhand/collector.rb
64
67
  - lib/coolhand/configuration.rb
65
68
  - lib/coolhand/declarable.rb
@@ -69,6 +72,7 @@ files:
69
72
  - lib/coolhand/default_intercept_path_patterns.yml
70
73
  - lib/coolhand/errors.rb
71
74
  - lib/coolhand/feedback_service.rb
75
+ - lib/coolhand/help_center_service.rb
72
76
  - lib/coolhand/log_queue.rb
73
77
  - lib/coolhand/log_service.rb
74
78
  - lib/coolhand/logger_service.rb
@@ -78,12 +82,13 @@ files:
78
82
  - lib/coolhand/open_ai/webhook_validator.rb
79
83
  - lib/coolhand/optimization_feedback_link_service.rb
80
84
  - lib/coolhand/pagination.rb
81
- - lib/coolhand/public_content_service.rb
85
+ - lib/coolhand/path_normalizer.rb
82
86
  - lib/coolhand/rails_auto_declare.rb
83
87
  - lib/coolhand/railtie.rb
84
88
  - lib/coolhand/read_query.rb
85
89
  - lib/coolhand/read_requests.rb
86
90
  - lib/coolhand/response_warnings.rb
91
+ - lib/coolhand/string_body_redactor.rb
87
92
  - lib/coolhand/template_service.rb
88
93
  - lib/coolhand/version.rb
89
94
  - lib/coolhand/vertex/batch_result_processor.rb