qube_sync 0.2.3 → 0.3.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 63193db3e3b92339d612a4a8c6e3d6ce24210d5b5c5f4531dd366919ac6b4d5b
4
- data.tar.gz: ce66acd7f18854ddb140963cff03bd7bd45d104a52d9ff1861157ab289c90e51
3
+ metadata.gz: 6d91d913272b0ecdd7f2b2bf91b584e2ab2291841a3badf4641d67d385841b51
4
+ data.tar.gz: 00d4062eead7a747ddc51ee0eaf44f5e25799532028b3ed30343ec25f7e7843e
5
5
  SHA512:
6
- metadata.gz: b14331b42bb25d936b09367adb0bd09a1f9aace021b5149bbc4fa5363ed7ca660513dd7404f20dc869255afa05fe245dd89e70b6f4b01ab8f6402eeb76501a33
7
- data.tar.gz: 4c1d75d9d2fde8fe3aea44b150d6fa5f87c62e5cd1987a78ab198b69ef853273f4292230971b0ad7c62e08591095736eda229b0a128c46efd2c6cbdcff48daaa
6
+ metadata.gz: b244afce7cc38609ca0b100fb4f85102c13ca194d7d5cfd61d792950c37402cf84dd28e868830be11060bf5948482607ef206749c9b6c5b7f302da9df2883db6
7
+ data.tar.gz: 923cf67564f27f9be8a715ec059bdb4dfa70befc478eaeba7188bfe3a02c203b21b63d68e4db827c3d55727c4070c4c3d7f2c229f603dc75e109a7e6d9e7db8f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-10-23
4
+
5
+ Adds `QubeSync::Webhooks`, which verifies a delivery and parses it into a typed event. Nothing
6
+ existing changes: `verify_and_build_webhook!` works as it did in 0.2.4.
7
+
8
+ - `QubeSync::Webhooks.verify(raw_body, header, secrets:, tolerance: 300, now: Time.now)` checks
9
+ the `X-QUBE-Signature` header and returns an event, or raises
10
+ `QubeSync::Webhooks::SignatureError` (a `QubeSync::Error`) whose `reason` is `:missing`,
11
+ `:malformed`, `:stale` or `:mismatch`. It takes the secret as an argument, one or a list while
12
+ a secret rotates, and never reads the environment. Its messages never hold the secret, a
13
+ signature or the body.
14
+ - Passing a parsed body raises `TypeError`, and an empty list of secrets raises `ArgumentError`.
15
+ A body that verifies but isn't a JSON object raises `QubeSync::Webhooks::ParseError`.
16
+ - `QubeSync::Webhooks.parse(raw_body)` (or `Event.from_json`) builds the event without
17
+ verifying.
18
+ - One event class per type: `RequestEvent`, `WorkflowRunEvent`, `NotificationEvent`,
19
+ `ConnectionEvent`, `NameIndexEvent` and `UnknownEvent` (never refused). Each has `type`,
20
+ `event`, `timestamp`, `app_id`, `sandbox`, `dedupe_key`, `to_h` and `[]`; a missing key reads
21
+ as `nil`. Workflow runs and connections have `newer_than?(stored_revision)`. Notifications
22
+ don't, because they're never skipped by revision.
23
+ - Specs run every case in `spec/fixtures/test_vectors.json`, generated by the QuBe Sync server.
24
+
25
+ ## [0.2.4] - 2026-10-22
26
+
27
+ Security fixes for webhook verification. Upgrading needs no code change, but needs Ruby 3.3 or
28
+ newer.
29
+
30
+ - Requires Ruby 3.3 or newer (the oldest Ruby still supported) and Faraday 2.14.3 or newer, which
31
+ fixes GHSA-98m9-hrrm-r99r, GHSA-33mh-2634-fwr2 and GHSA-5rv5-xj5j-3484. 0.2.3 allowed Ruby 2.6
32
+ and any Faraday 2.
33
+
34
+ - `verify_and_build_webhook!` verifies real deliveries: it signs `"<t>.<raw body>"` as the
35
+ server does and reads `t` and every `v1` from `X-QUBE-Signature` by key. 0.2.3 signed the body
36
+ alone and compared against the `v1=`-prefixed parts, so no real delivery verified.
37
+ - Signatures are compared in constant time (`OpenSSL.fixed_length_secure_compare`), not with
38
+ `==`.
39
+ - The timestamp must be within 300 seconds of now, either way. It was 500 seconds old, and any
40
+ timestamp in the future passed. `max_age` (seconds) still sets the window.
41
+ - A missing or malformed header raises `InvalidWebhookSignatureError`, not `NoMethodError`.
42
+ - The stale error says how old (or how far ahead) the timestamp was; it used to print
43
+ `#{max_age}ms` literally.
44
+ - `RequestBuilder` sends `true` and `false` as the text `"true"` and `"false"`. 0.2.3 dropped
45
+ them, so `b.IsActive true` went out as an empty `<IsActive/>`.
46
+ - Specs run against a delivery signed by the QuBe Sync server (`spec/fixtures/webhook_vectors.json`).
47
+
3
48
  ## [0.1.0] - 2025-01-31
4
49
 
5
50
  - Initial release
data/README.md CHANGED
@@ -4,6 +4,8 @@ A simple wrapper around the QUBE Sync API.
4
4
 
5
5
  ## Installation
6
6
 
7
+ Requires Ruby 3.3 or newer.
8
+
7
9
  Install the gem and add to the application's Gemfile by executing:
8
10
 
9
11
  $ bundle add qube_sync
@@ -118,10 +120,86 @@ QubeSync.verify_and_build_webhook!(request.body.read, request.headers['X-Qube-Si
118
120
  # "<?xml version=\"1.0\" ?> <QBXML> <QBXMLMsgsRs> ... </QBXMLMsgsRs> </QBXML>"
119
121
  # }
120
122
 
121
- # The default max age for a webhook is 500ms (0.5 seconds). You can change this by passing a max_age option:
122
- QubeSync.verify_and_build_webhook!(request.body.read, request.headers['X-Qube-Signature'], max_age: 1_000)
123
+ # Pass the raw request body, before anything parses it. A bad or missing signature raises
124
+ # QubeSync::InvalidWebhookSignatureError; answer 401.
125
+ #
126
+ # The timestamp must be within 300 seconds (5 minutes) of now, either way, or it raises
127
+ # QubeSync::StaleWebhookError. max_age (in seconds) sets another window:
128
+ QubeSync.verify_and_build_webhook!(request.body.read, request.headers['X-Qube-Signature'], max_age: 120)
129
+ ```
130
+
131
+ ## Webhooks
132
+
133
+ `QubeSync::Webhooks` verifies a delivery's `X-QUBE-Signature` header and parses the body into a
134
+ typed event. It doesn't need Rails or Rack, and never reads the environment: pass it the raw body,
135
+ the header and your webhook secret.
136
+
137
+ ```ruby
138
+ require "qube_sync"
139
+
140
+ # raw_body: the request body exactly as received, before anything parses it.
141
+ # header: the X-QUBE-Signature header (nil when it's absent).
142
+ def handle_qube_webhook(raw_body, header)
143
+ event = QubeSync::Webhooks.verify(raw_body, header, secrets: ENV.fetch("QUBE_WEBHOOK_SECRET"))
144
+
145
+ case event.type
146
+ when "request"
147
+ # A queued request's answer. Deduplicate on event.dedupe_key ("<page_request_id>:<state>").
148
+ # event.response_json, event.error, event.page, event.iteration_state
149
+ when "workflow_run"
150
+ # A run is waiting for an answer, or has ended. Skip it unless event.newer_than?(stored)
151
+ # for the revision stored with event.reference: deliveries can arrive out of order.
152
+ # event.awaiting_input? / completed? / failed? / cancelled?, event.prompt, event.output
153
+ when "notification"
154
+ # Data a running workflow sent. Deduplicate on event.dedupe_key (the delivery_id), and
155
+ # never skip one by revision. event.name, event.data
156
+ when "connection"
157
+ # Connected, setup cancelled, stalled or resumed. Deduplicate on event.dedupe_key, and skip
158
+ # it unless event.newer_than?(the revision stored for event.connection_id).
159
+ when "name_index"
160
+ # event.connection_id, event.name_index
161
+ else
162
+ # An event this version doesn't know yet: acknowledge it.
163
+ end
164
+
165
+ 200
166
+ rescue QubeSync::Webhooks::SignatureError => e
167
+ # e.reason is :missing, :malformed, :stale or :mismatch. The message is safe to log.
168
+ warn "QuBe Sync webhook refused: #{e.message}"
169
+ 401
170
+ end
171
+ ```
172
+
173
+ - `secrets:` takes one secret or a list. While a secret rotates, a delivery carries one `v1`
174
+ signature per active secret, and either secret verifies it.
175
+ - The timestamp must be within 300 seconds of now, either way. `tolerance:` (seconds) sets
176
+ another window, and `now:` (unix seconds or a `Time`) sets the clock, for tests.
177
+ - Every event has `type`, `event`, `timestamp`, `app_id`, `sandbox`, `dedupe_key` and `to_h`
178
+ (the whole body). A missing key reads as `nil`, and `event["any_key"]` reads any other key.
179
+ - `QubeSync::Webhooks.parse(raw_body)` builds the event without verifying it, for tests or for
180
+ code that verified the delivery elsewhere.
181
+
182
+ In Rails, read the raw body with `request.raw_post`:
183
+
184
+ ```ruby
185
+ class QubeWebhooksController < ActionController::API
186
+ def create
187
+ event = QubeSync::Webhooks.verify(
188
+ request.raw_post,
189
+ request.headers[QubeSync::Webhooks::HEADER],
190
+ secrets: Rails.application.credentials.qube_webhook_secret
191
+ )
192
+ QubeWebhookJob.perform_later(event.to_h) # route by type in the job, as above
193
+ head :ok
194
+ rescue QubeSync::Webhooks::SignatureError => e
195
+ Rails.logger.warn("QuBe Sync webhook refused: #{e.message}")
196
+ head :unauthorized
197
+ end
198
+ end
123
199
  ```
124
200
 
201
+ `QubeSync.verify_and_build_webhook!` (0.2.4) still works, and still returns the parsed Hash.
202
+
125
203
  ## Development
126
204
 
127
205
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -130,4 +208,4 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
130
208
 
131
209
  ## Contributing
132
210
 
133
- Bug reports and pull requests are welcome on GitHub at https://github.com/qubeintegrations/qube_sync.
211
+ Bug reports and pull requests are welcome on GitHub at https://github.com/qubeintegrations/qube_sync_rb.
@@ -16,6 +16,8 @@ module QubeSync
16
16
  "name" => tag_name.to_s,
17
17
  }
18
18
  element["attributes"] = attributes unless attributes.empty?
19
+ # qbXML's booleans are the words true and false.
20
+ text = text.to_s if text == true || text == false
19
21
  element["text"] = text if text.is_a?(String) || text.is_a?(Numeric)
20
22
 
21
23
  if block_given?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module QubeSync
4
- VERSION = "0.2.3"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QubeSync
4
+ module Webhooks
5
+ # A parsed delivery. `QubeSync::Webhooks.parse` and `.verify` return one of the subclasses
6
+ # below, picked by the body: RequestEvent, WorkflowRunEvent, NotificationEvent,
7
+ # ConnectionEvent, NameIndexEvent, or UnknownEvent for anything else. Every accessor reads
8
+ # the body and returns nil for a missing key; `[]` reads any other key.
9
+ class Event
10
+ WORKFLOW_RUN_EVENTS = %w[
11
+ workflow_run.awaiting_input
12
+ workflow_run.completed
13
+ workflow_run.failed
14
+ workflow_run.cancelled
15
+ ].freeze
16
+ NOTIFICATION_EVENT = "workflow_run.notification"
17
+
18
+ # Picks the event class for a parsed body (a Hash with string keys).
19
+ def self.build(body)
20
+ event = body["event"]
21
+
22
+ klass =
23
+ if event.nil?
24
+ body["page_request_id"].nil? ? UnknownEvent : RequestEvent
25
+ elsif !event.is_a?(String)
26
+ UnknownEvent
27
+ elsif WORKFLOW_RUN_EVENTS.include?(event)
28
+ WorkflowRunEvent
29
+ elsif event == NOTIFICATION_EVENT
30
+ NotificationEvent
31
+ elsif event.start_with?("connection.")
32
+ ConnectionEvent
33
+ elsif event.start_with?("name_index.")
34
+ NameIndexEvent
35
+ else
36
+ UnknownEvent
37
+ end
38
+
39
+ klass.new(body)
40
+ end
41
+
42
+ # Parses a raw JSON body without verifying it. Same as `QubeSync::Webhooks.parse`.
43
+ def self.from_json(raw_body)
44
+ Webhooks.parse(raw_body)
45
+ end
46
+
47
+ def self.fields(*names)
48
+ names.each do |name|
49
+ key = name.to_s
50
+ define_method(name) { @body[key] }
51
+ end
52
+ end
53
+ private_class_method :fields
54
+
55
+ def initialize(body)
56
+ @body = body
57
+ end
58
+
59
+ fields :event, :timestamp, :app_id, :sandbox
60
+
61
+ # "request", "workflow_run", "notification", "connection", "name_index" or "unknown".
62
+ def type
63
+ self.class::TYPE
64
+ end
65
+
66
+ # The key to deduplicate deliveries on, or nil when this type has none.
67
+ def dedupe_key
68
+ nil
69
+ end
70
+
71
+ def reference
72
+ nil
73
+ end
74
+
75
+ def revision
76
+ nil
77
+ end
78
+
79
+ # Any key of the body, nil when it's missing.
80
+ def [](key)
81
+ @body[key.to_s]
82
+ end
83
+
84
+ def dig(*keys)
85
+ @body.dig(*keys.map { |key| key.is_a?(Symbol) ? key.to_s : key })
86
+ end
87
+
88
+ # The whole parsed body.
89
+ def to_h
90
+ @body
91
+ end
92
+
93
+ def ==(other)
94
+ other.instance_of?(self.class) && other.to_h == @body
95
+ end
96
+ alias eql? ==
97
+
98
+ def hash
99
+ [self.class, @body].hash
100
+ end
101
+
102
+ def inspect
103
+ "#<#{self.class.name} type=#{type.inspect} event=#{event.inspect}>"
104
+ end
105
+
106
+ # Shared by the types that carry a revision to compare against the stored one.
107
+ module Revisioned
108
+ def revision
109
+ @body["revision"]
110
+ end
111
+
112
+ # True when this delivery is newer than the revision stored for its subject (or nothing is
113
+ # stored yet). Skip the delivery when false: an older one arrived late.
114
+ def newer_than?(stored_revision)
115
+ return true if stored_revision.nil?
116
+ return false if revision.nil?
117
+
118
+ revision > stored_revision
119
+ end
120
+ end
121
+ end
122
+
123
+ # A queued request's answer (or error). Deduplicate on `dedupe_key`, page_request_id + state.
124
+ class RequestEvent < Event
125
+ TYPE = "request"
126
+
127
+ fields :id, :page_request_id, :state, :page, :iteration_state, :response_json, :error
128
+
129
+ def dedupe_key
130
+ "#{page_request_id}:#{state}"
131
+ end
132
+ end
133
+
134
+ # A workflow run that is waiting for an answer, or has ended. Store by `reference` and skip
135
+ # it unless `newer_than?` the revision you stored.
136
+ class WorkflowRunEvent < Event
137
+ include Event::Revisioned
138
+
139
+ TYPE = "workflow_run"
140
+
141
+ fields :run_id, :reference, :run_state, :workflow, :connection_id, :prompt, :outcome, :output,
142
+ :error, :summary, :cancel_reason, :links
143
+
144
+ def awaiting_input?
145
+ event == "workflow_run.awaiting_input"
146
+ end
147
+
148
+ def completed?
149
+ event == "workflow_run.completed"
150
+ end
151
+
152
+ def failed?
153
+ event == "workflow_run.failed"
154
+ end
155
+
156
+ def cancelled?
157
+ event == "workflow_run.cancelled"
158
+ end
159
+ end
160
+
161
+ # Data a running workflow sent along. Deduplicate on `delivery_id`; never skip one by revision.
162
+ class NotificationEvent < Event
163
+ TYPE = "notification"
164
+
165
+ fields :run_id, :reference, :revision, :delivery_id, :seq, :name, :state, :from, :data
166
+
167
+ def dedupe_key
168
+ delivery_id
169
+ end
170
+ end
171
+
172
+ # A connection was set up, cancelled, stalled or resumed. Deduplicate on `delivery_id`, and
173
+ # skip it unless `newer_than?` the connection's stored revision.
174
+ class ConnectionEvent < Event
175
+ include Event::Revisioned
176
+
177
+ TYPE = "connection"
178
+
179
+ fields :delivery_id, :connection
180
+
181
+ def connection_id
182
+ connection_hash["id"]
183
+ end
184
+
185
+ def stall_reason
186
+ connection_hash["stall_reason"]
187
+ end
188
+
189
+ def dedupe_key
190
+ delivery_id
191
+ end
192
+
193
+ def connected?
194
+ event == "connection.connected"
195
+ end
196
+
197
+ def setup_cancelled?
198
+ event == "connection.setup_cancelled"
199
+ end
200
+
201
+ def stalled?
202
+ event == "connection.stalled"
203
+ end
204
+
205
+ def resumed?
206
+ event == "connection.resumed"
207
+ end
208
+
209
+ private
210
+
211
+ def connection_hash
212
+ connection.is_a?(Hash) ? connection : {}
213
+ end
214
+ end
215
+
216
+ # A connection's name index finished building, or failed.
217
+ class NameIndexEvent < Event
218
+ TYPE = "name_index"
219
+
220
+ fields :connection_id, :name_index
221
+ end
222
+
223
+ # Anything this version of the library doesn't know. Never refused: acknowledge it (or log it).
224
+ class UnknownEvent < Event
225
+ TYPE = "unknown"
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module QubeSync
7
+ class Error < StandardError; end
8
+
9
+ # Verifies QuBe Sync webhook deliveries and parses them into typed events.
10
+ #
11
+ # event = QubeSync::Webhooks.verify(raw_body, header, secrets: ENV.fetch("QUBE_WEBHOOK_SECRET"))
12
+ #
13
+ # Framework-free: it never reads the environment or a request object. Pass the raw request body
14
+ # as received and the value of the `X-QUBE-Signature` header.
15
+ module Webhooks
16
+ # The header that carries the signature, and its name in a Rack env.
17
+ HEADER = "X-QUBE-Signature"
18
+ RACK_HEADER = "HTTP_X_QUBE_SIGNATURE"
19
+
20
+ # Seconds a delivery's timestamp may be from now, either way.
21
+ DEFAULT_TOLERANCE = 300
22
+
23
+ REASONS = %i[missing malformed stale mismatch].freeze
24
+
25
+ # A delivery that failed verification. Answer it with 401. `reason` is one of
26
+ # `:missing`, `:malformed`, `:stale`, `:mismatch`. The message is safe to log: it never holds
27
+ # a secret, a signature or the body.
28
+ class SignatureError < QubeSync::Error
29
+ attr_reader :reason
30
+
31
+ def initialize(reason, message = nil)
32
+ @reason = reason
33
+ super(message || default_message(reason))
34
+ end
35
+
36
+ private
37
+
38
+ def default_message(reason)
39
+ case reason
40
+ when :missing then "No #{HEADER} header"
41
+ when :malformed then "Malformed #{HEADER} header: expected t=<unix seconds>,v1=<hex>"
42
+ when :stale then "Webhook timestamp is outside the tolerance"
43
+ when :mismatch then "Webhook signature doesn't match any of the given secrets"
44
+ else "Webhook signature is invalid (#{reason})"
45
+ end
46
+ end
47
+ end
48
+
49
+ # A body that verified (or was handed to `parse`) but isn't a JSON object.
50
+ class ParseError < QubeSync::Error; end
51
+
52
+ module_function
53
+
54
+ # Verifies a delivery and returns its typed event (see `parse`). Raises SignatureError when
55
+ # the header is missing, malformed, stale or matches none of `secrets`.
56
+ #
57
+ # - `raw_body`: the request body as received (a String), before anything parses it.
58
+ # - `header`: the `X-QUBE-Signature` value (nil when absent).
59
+ # - `secrets:`: the app's webhook secret, or a list of them while one rotates.
60
+ # - `tolerance:`: seconds the timestamp may be from `now`, either way.
61
+ # - `now:`: unix seconds, or a Time.
62
+ def verify(raw_body, header, secrets:, tolerance: DEFAULT_TOLERANCE, now: Time.now)
63
+ raw_body = raw_body!(raw_body)
64
+ secrets = secrets!(secrets)
65
+ now = unix_seconds!(now)
66
+
67
+ header = header.to_s.b.strip
68
+ raise SignatureError.new(:missing) if header.empty?
69
+
70
+ timestamp, signatures = parse_header(header)
71
+
72
+ age = now - timestamp.to_i
73
+ if age.abs > tolerance
74
+ when_sent = age.positive? ? "#{age} seconds old" : "#{-age} seconds in the future"
75
+ raise SignatureError.new(
76
+ :stale,
77
+ "Webhook timestamp is #{when_sent}; it must be within #{tolerance} seconds of now. Check this server's clock."
78
+ )
79
+ end
80
+
81
+ signed = "#{timestamp}.".b + raw_body.b
82
+ expected = secrets.map { |secret| OpenSSL::HMAC.hexdigest("sha256", secret, signed) }
83
+ matched = expected.product(signatures).any? { |exp, sig| secure_compare(exp, sig) }
84
+ raise SignatureError.new(:mismatch) unless matched
85
+
86
+ parse(raw_body)
87
+ end
88
+
89
+ # Builds the typed event from a raw body without verifying it: for tests, and for code that
90
+ # verified the delivery elsewhere.
91
+ def parse(raw_body)
92
+ raw_body = raw_body!(raw_body)
93
+ text = raw_body.encoding == Encoding::UTF_8 ? raw_body : raw_body.dup.force_encoding(Encoding::UTF_8)
94
+
95
+ body =
96
+ begin
97
+ JSON.parse(text)
98
+ rescue JSON::ParserError, EncodingError
99
+ raise ParseError, "Webhook body isn't valid JSON"
100
+ end
101
+ raise ParseError, "Webhook body isn't a JSON object" unless body.is_a?(Hash)
102
+
103
+ Event.build(body)
104
+ end
105
+
106
+ # Splits the header into its `t` and its non-empty `v1`s, or raises :malformed.
107
+ def parse_header(header)
108
+ timestamp = nil
109
+ signatures = []
110
+
111
+ header.split(",").each do |part|
112
+ key, value = part.strip.split("=", 2)
113
+ next if value.nil?
114
+
115
+ case key
116
+ when "t" then timestamp ||= value
117
+ when "v1" then signatures << value unless value.empty?
118
+ end
119
+ end
120
+
121
+ unless timestamp&.match?(/\A[0-9]+\z/) && !signatures.empty?
122
+ raise SignatureError.new(:malformed)
123
+ end
124
+
125
+ [timestamp, signatures]
126
+ end
127
+
128
+ # Constant-time comparison of two byte strings; different lengths are simply false.
129
+ def secure_compare(a, b)
130
+ a = a.b
131
+ b = b.b
132
+ return false unless a.bytesize == b.bytesize
133
+
134
+ OpenSSL.fixed_length_secure_compare(a, b)
135
+ end
136
+
137
+ def raw_body!(raw_body)
138
+ return raw_body if raw_body.is_a?(String)
139
+
140
+ raise TypeError,
141
+ "Pass the raw request body as a String (in Rails, request.raw_post), not a #{raw_body.class}: " \
142
+ "the signature covers the exact bytes QuBe Sync sent"
143
+ end
144
+
145
+ def secrets!(secrets)
146
+ list = secrets.is_a?(String) ? [secrets] : Array(secrets)
147
+ raise ArgumentError, "secrets is empty: pass the app's webhook secret" if list.empty?
148
+ unless list.all? { |secret| secret.is_a?(String) && !secret.empty? }
149
+ raise ArgumentError, "each secret must be a non-empty String"
150
+ end
151
+
152
+ list
153
+ end
154
+
155
+ def unix_seconds!(now)
156
+ case now
157
+ when Integer then now
158
+ when Time then now.to_i
159
+ when Numeric then now.floor
160
+ else
161
+ return now.to_time.to_i if now.respond_to?(:to_time)
162
+
163
+ raise ArgumentError, "now must be unix seconds or a Time, not a #{now.class}"
164
+ end
165
+ end
166
+
167
+ private_class_method :parse_header, :secure_compare, :raw_body!, :secrets!, :unix_seconds!
168
+ end
169
+ end
170
+
171
+ require_relative "webhooks/event"
data/lib/qube_sync.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  require_relative "qube_sync/version"
2
2
  require_relative "qube_sync/request_builder"
3
+ require_relative "qube_sync/webhooks"
3
4
  require "faraday"
5
+ require "json"
6
+ require "openssl"
4
7
 
5
8
  module QubeSync
6
9
  class Error < StandardError; end
@@ -128,12 +131,26 @@ module QubeSync
128
131
  end
129
132
 
130
133
 
134
+ # Parses an `X-QUBE-Signature` header, `t=<unix seconds>,v1=<hex>[,v1=<hex>]`,
135
+ # by key. There is one `v1` per active webhook secret while a secret rotates.
131
136
  def extract_signature_meta(header)
132
- parts = header.split(',')
133
- ts = parts.first
134
- signatures = parts[1..-1]
137
+ timestamp = nil
138
+ signatures = []
139
+
140
+ header.to_s.split(",").each do |part|
141
+ key, value = part.strip.split("=", 2)
142
+ case key
143
+ when "t" then timestamp = value
144
+ when "v1" then signatures << value if value && !value.empty?
145
+ end
146
+ end
147
+
148
+ unless timestamp && timestamp.match?(/\A\d+\z/)
149
+ raise InvalidWebhookSignatureError.new("Invalid X-QUBE-Signature header: no t=<unix seconds>")
150
+ end
151
+
135
152
  {
136
- timestamp: ts.split("=").last.to_i,
153
+ timestamp: timestamp.to_i,
137
154
  signatures: signatures
138
155
  }
139
156
  end
@@ -162,18 +179,30 @@ module QubeSync
162
179
  OpenSSL::HMAC.hexdigest('sha256', api_secret, payload)
163
180
  end
164
181
 
165
- def verify_and_build_webhook!(body, signature, max_age: 500)
182
+ # Verifies a webhook against its `X-QUBE-Signature` header and returns the
183
+ # parsed body. `body` must be the raw request body, unparsed. The timestamp
184
+ # must be within `max_age` seconds of now, either way.
185
+ def verify_and_build_webhook!(body, signature, max_age: 300)
166
186
  meta = extract_signature_meta(signature)
167
-
168
- if meta[:timestamp] < Time.now.to_i - max_age
169
- raise StaleWebhookError.new('Timestamp more than #{max_age}ms old. To increase this, pass a different value for max_age.')
187
+
188
+ age = Time.now.to_i - meta[:timestamp]
189
+ if age.abs > max_age
190
+ when_sent = age.positive? ? "#{age} seconds old" : "#{-age} seconds in the future"
191
+ raise StaleWebhookError.new("Webhook timestamp is #{when_sent}; it must be within #{max_age} seconds of now. Check this server's clock.")
170
192
  end
171
-
172
- if meta[:signatures].detect { |sig| sign_payload(body) == sig }
193
+
194
+ expected = sign_payload("#{meta[:timestamp]}.#{body}")
195
+ if meta[:signatures].any? { |sig| secure_compare(expected, sig) }
173
196
  JSON.parse(body)
174
197
  else
175
198
  raise InvalidWebhookSignatureError.new("Webhook signature mismatch")
176
199
  end
177
-
200
+ end
201
+
202
+ # Constant-time string comparison, so a mismatch takes as long wherever it is.
203
+ def secure_compare(a, b)
204
+ return false unless a.bytesize == b.bytesize
205
+
206
+ OpenSSL.fixed_length_secure_compare(a, b)
178
207
  end
179
208
  end
data/qube_sync.gemspec CHANGED
@@ -15,7 +15,7 @@ Gem::Specification.new do |spec|
15
15
  Manage connections, queued requests, and more with the QUBE Sync API.
16
16
  """
17
17
  spec.homepage = "https://github.com/qubeintegrations/qube_sync_rb"
18
- spec.required_ruby_version = ">= 2.6.0"
18
+ spec.required_ruby_version = ">= 3.3.0"
19
19
 
20
20
  # spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'"
21
21
 
@@ -36,7 +36,7 @@ Gem::Specification.new do |spec|
36
36
  spec.require_paths = ["lib"]
37
37
 
38
38
  # Uncomment to register a new dependency of your gem
39
- spec.add_dependency "faraday", "~> 2.0"
39
+ spec.add_dependency "faraday", "~> 2.14", ">= 2.14.3"
40
40
 
41
41
 
42
42
  # For more information and examples about making a new gem, check out our
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: qube_sync
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Garrett Lancaster
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-10-24 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: faraday
@@ -15,14 +16,20 @@ dependencies:
15
16
  requirements:
16
17
  - - "~>"
17
18
  - !ruby/object:Gem::Version
18
- version: '2.0'
19
+ version: '2.14'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 2.14.3
19
23
  type: :runtime
20
24
  prerelease: false
21
25
  version_requirements: !ruby/object:Gem::Requirement
22
26
  requirements:
23
27
  - - "~>"
24
28
  - !ruby/object:Gem::Version
25
- version: '2.0'
29
+ version: '2.14'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 2.14.3
26
33
  description: "\n Easily create and manage QUBE Sync API resources in Ruby.\n\n Manage
27
34
  connections, queued requests, and more with the QUBE Sync API.\n "
28
35
  email:
@@ -39,6 +46,8 @@ files:
39
46
  - lib/qube_sync.rb
40
47
  - lib/qube_sync/request_builder.rb
41
48
  - lib/qube_sync/version.rb
49
+ - lib/qube_sync/webhooks.rb
50
+ - lib/qube_sync/webhooks/event.rb
42
51
  - qube_sync.gemspec
43
52
  - sig/qube_sync.rbs
44
53
  homepage: https://github.com/qubeintegrations/qube_sync_rb
@@ -47,6 +56,7 @@ metadata:
47
56
  homepage_uri: https://github.com/qubeintegrations/qube_sync_rb
48
57
  source_code_uri: https://github.com/qubeintegrations/qube_sync_rb
49
58
  changelog_uri: https://github.com/qubeintegrations/qube_sync_rb/blob/main/CHANGELOG.md
59
+ post_install_message:
50
60
  rdoc_options: []
51
61
  require_paths:
52
62
  - lib
@@ -54,14 +64,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
54
64
  requirements:
55
65
  - - ">="
56
66
  - !ruby/object:Gem::Version
57
- version: 2.6.0
67
+ version: 3.3.0
58
68
  required_rubygems_version: !ruby/object:Gem::Requirement
59
69
  requirements:
60
70
  - - ">="
61
71
  - !ruby/object:Gem::Version
62
72
  version: '0'
63
73
  requirements: []
64
- rubygems_version: 3.6.9
74
+ rubygems_version: 3.5.22
75
+ signing_key:
65
76
  specification_version: 4
66
77
  summary: Ruby library for the QUBE Sync API
67
78
  test_files: []