qube_sync 0.2.2 → 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 +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +81 -3
- data/lib/qube_sync/request_builder.rb +4 -2
- data/lib/qube_sync/version.rb +1 -1
- data/lib/qube_sync/webhooks/event.rb +228 -0
- data/lib/qube_sync/webhooks.rb +171 -0
- data/lib/qube_sync.rb +47 -11
- data/qube_sync.gemspec +2 -2
- metadata +17 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d91d913272b0ecdd7f2b2bf91b584e2ab2291841a3badf4641d67d385841b51
|
|
4
|
+
data.tar.gz: 00d4062eead7a747ddc51ee0eaf44f5e25799532028b3ed30343ec25f7e7843e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
122
|
-
QubeSync
|
|
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/
|
|
211
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/qubeintegrations/qube_sync_rb.
|
|
@@ -5,7 +5,7 @@ module QubeSync
|
|
|
5
5
|
def initialize(version:, &block)
|
|
6
6
|
@version = version
|
|
7
7
|
@stack = []
|
|
8
|
-
|
|
8
|
+
yield self if block_given?
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def method_missing(tag_name, *args, &block)
|
|
@@ -16,12 +16,14 @@ 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?
|
|
22
24
|
element["children"] = []
|
|
23
25
|
@stack.push(element)
|
|
24
|
-
|
|
26
|
+
block.call
|
|
25
27
|
@stack.pop
|
|
26
28
|
end
|
|
27
29
|
|
data/lib/qube_sync/version.rb
CHANGED
|
@@ -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
|
|
@@ -85,6 +88,13 @@ module QubeSync
|
|
|
85
88
|
def get_connection(connection_id)
|
|
86
89
|
get("connections/#{connection_id}").fetch("data")
|
|
87
90
|
end
|
|
91
|
+
|
|
92
|
+
def valid_connection?(connection_id)
|
|
93
|
+
get_connection(connection_id)
|
|
94
|
+
true
|
|
95
|
+
rescue Error => e
|
|
96
|
+
false
|
|
97
|
+
end
|
|
88
98
|
|
|
89
99
|
def queue_request(connection_id, request)
|
|
90
100
|
url = "connections/#{connection_id}/queued_requests"
|
|
@@ -121,12 +131,26 @@ module QubeSync
|
|
|
121
131
|
end
|
|
122
132
|
|
|
123
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.
|
|
124
136
|
def extract_signature_meta(header)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
+
|
|
128
152
|
{
|
|
129
|
-
timestamp:
|
|
153
|
+
timestamp: timestamp.to_i,
|
|
130
154
|
signatures: signatures
|
|
131
155
|
}
|
|
132
156
|
end
|
|
@@ -155,18 +179,30 @@ module QubeSync
|
|
|
155
179
|
OpenSSL::HMAC.hexdigest('sha256', api_secret, payload)
|
|
156
180
|
end
|
|
157
181
|
|
|
158
|
-
|
|
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)
|
|
159
186
|
meta = extract_signature_meta(signature)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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.")
|
|
163
192
|
end
|
|
164
|
-
|
|
165
|
-
|
|
193
|
+
|
|
194
|
+
expected = sign_payload("#{meta[:timestamp]}.#{body}")
|
|
195
|
+
if meta[:signatures].any? { |sig| secure_compare(expected, sig) }
|
|
166
196
|
JSON.parse(body)
|
|
167
197
|
else
|
|
168
198
|
raise InvalidWebhookSignatureError.new("Webhook signature mismatch")
|
|
169
199
|
end
|
|
170
|
-
|
|
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)
|
|
171
207
|
end
|
|
172
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 = ">=
|
|
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.
|
|
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.
|
|
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:
|
|
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.
|
|
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.
|
|
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:
|
|
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.
|
|
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: []
|