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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +10 -8
- data/docs/anthropic.md +28 -83
- data/docs/configuration.md +31 -5
- data/docs/elevenlabs.md +1 -1
- data/docs/help-center.md +4 -4
- data/docs/openai.md +63 -4
- data/docs/vertex.md +1 -0
- data/lib/coolhand/base_interceptor.rb +137 -37
- data/lib/coolhand/batch_body_cap.rb +36 -0
- data/lib/coolhand/body_redactor.rb +111 -0
- data/lib/coolhand/body_stream_tap.rb +121 -0
- data/lib/coolhand/default_intercept_addresses.yml +8 -0
- data/lib/coolhand/{public_content_service.rb → help_center_service.rb} +1 -1
- data/lib/coolhand/logger_service.rb +54 -5
- data/lib/coolhand/net_http_interceptor.rb +190 -28
- data/lib/coolhand/open_ai/batch_result_processor.rb +139 -53
- data/lib/coolhand/open_ai/webhook_id_store.rb +7 -0
- data/lib/coolhand/open_ai/webhook_validator.rb +43 -28
- data/lib/coolhand/path_normalizer.rb +36 -0
- data/lib/coolhand/string_body_redactor.rb +83 -0
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand/vertex/batch_result_processor.rb +2 -2
- data/lib/coolhand/webhook_interceptor.rb +23 -0
- data/lib/coolhand.rb +7 -4
- metadata +8 -3
|
@@ -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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
data/lib/coolhand/version.rb
CHANGED
|
@@ -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/
|
|
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
|
|
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
|
|
154
|
-
|
|
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.
|
|
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-
|
|
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/
|
|
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
|