axn-webhooks 0.1.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 +7 -0
- data/CHANGELOG.md +176 -0
- data/DESIGN-NOTES.md +241 -0
- data/LICENSE.txt +21 -0
- data/README.md +1042 -0
- data/lib/axn/webhooks/dispatch.rb +129 -0
- data/lib/axn/webhooks/errors.rb +48 -0
- data/lib/axn/webhooks/handler.rb +15 -0
- data/lib/axn/webhooks/header_value.rb +29 -0
- data/lib/axn/webhooks/inbound/build_request.rb +23 -0
- data/lib/axn/webhooks/inbound/challenge.rb +37 -0
- data/lib/axn/webhooks/inbound/challenge_required.rb +35 -0
- data/lib/axn/webhooks/inbound/dsl.rb +240 -0
- data/lib/axn/webhooks/inbound/endpoint.rb +221 -0
- data/lib/axn/webhooks/inbound/parsers.rb +20 -0
- data/lib/axn/webhooks/inbound/respond_context.rb +17 -0
- data/lib/axn/webhooks/inbound/router.rb +104 -0
- data/lib/axn/webhooks/inbound.rb +124 -0
- data/lib/axn/webhooks/outbound/callable_arity.rb +99 -0
- data/lib/axn/webhooks/outbound/config.rb +442 -0
- data/lib/axn/webhooks/outbound/deliver.rb +425 -0
- data/lib/axn/webhooks/outbound/dsl.rb +121 -0
- data/lib/axn/webhooks/outbound/emit.rb +181 -0
- data/lib/axn/webhooks/outbound/envelope.rb +23 -0
- data/lib/axn/webhooks/outbound/signer.rb +376 -0
- data/lib/axn/webhooks/outbound/subscriber.rb +152 -0
- data/lib/axn/webhooks/outbound/target_policy.rb +135 -0
- data/lib/axn/webhooks/outbound/transport.rb +59 -0
- data/lib/axn/webhooks/outbound.rb +73 -0
- data/lib/axn/webhooks/request.rb +230 -0
- data/lib/axn/webhooks/resolvers.rb +43 -0
- data/lib/axn/webhooks/respond.rb +26 -0
- data/lib/axn/webhooks/response.rb +116 -0
- data/lib/axn/webhooks/signature.rb +268 -0
- data/lib/axn/webhooks/static_respond.rb +22 -0
- data/lib/axn/webhooks/vendor_facet.rb +25 -0
- data/lib/axn/webhooks/verifiers/basic_auth.rb +128 -0
- data/lib/axn/webhooks/verifiers/hmac.rb +58 -0
- data/lib/axn/webhooks/verifiers/standard_webhooks.rb +129 -0
- data/lib/axn/webhooks/verifiers.rb +50 -0
- data/lib/axn/webhooks/verify.rb +106 -0
- data/lib/axn/webhooks/version.rb +7 -0
- data/lib/axn/webhooks.rb +61 -0
- data/lib/axn-webhooks.rb +3 -0
- metadata +128 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Axn
|
|
6
|
+
module Webhooks
|
|
7
|
+
module Outbound
|
|
8
|
+
# A single delivery attempt + the self-managed retry engine. Built as an Axn: metrics/OTel/
|
|
9
|
+
# structured logs per attempt come free. Retryable responses reschedule via axn's
|
|
10
|
+
# adapter-agnostic call_async(_async: { wait: }) seam (never branching on adapter type);
|
|
11
|
+
# unexpected exceptions propagate so the async adapter retries the un-acked job (at-least-once).
|
|
12
|
+
class Deliver
|
|
13
|
+
include Axn
|
|
14
|
+
include Axn::Webhooks::VendorFacet
|
|
15
|
+
|
|
16
|
+
# The headers Deliver adds AFTER the signer's, and therefore the ones a signer must not
|
|
17
|
+
# emit: Ruby Hash keys are case-sensitive so a differently-cased duplicate survives the
|
|
18
|
+
# merge below, but Net::HTTP is case-INSENSITIVE and the later assignment wins — silently
|
|
19
|
+
# replacing the signature. Signer::HmacSigner rejects these at declaration time.
|
|
20
|
+
MANAGED_HEADERS = %w[content-type user-agent].freeze
|
|
21
|
+
|
|
22
|
+
# A plausible field-name (`Authorization`, `content_type` -- the documented common case of
|
|
23
|
+
# writing a `headers` resolver with a Symbol/String literal key) is a short, simple
|
|
24
|
+
# identifier -- used by `key_desc` to decide whether a malformed header's key is safe to
|
|
25
|
+
# log as-is (see `add_custom_header`).
|
|
26
|
+
PLAUSIBLE_FIELD_NAME = /\A[A-Za-z_][A-Za-z0-9_]{0,49}\z/
|
|
27
|
+
|
|
28
|
+
# RFC 7230's `field-value` grammar forbids every control byte except HTAB (0x09) -- CR/LF
|
|
29
|
+
# (0x0D/0x0A) are the ones Net::HTTP itself raises on, but any OTHER control byte (NUL,
|
|
30
|
+
# BEL, ...) is equally invalid on the wire and unvalidated here would reach the receiver
|
|
31
|
+
# (see `add_custom_header`).
|
|
32
|
+
FORBIDDEN_HEADER_VALUE_BYTES = /[\x00-\x08\x0A-\x1F\x7F]/
|
|
33
|
+
|
|
34
|
+
# sensitive: BOTH of these (security audit). The most common real webhook URL shape —
|
|
35
|
+
# Slack/Discord/Teams incoming hooks — carries a secret token AS THE PATH, which is exactly
|
|
36
|
+
# why TargetPolicy.redact_url exists; and `body` is the caller's own event payload, routinely
|
|
37
|
+
# PII. Without these, axn's per-call auto-logging rendered a live third-party token and the
|
|
38
|
+
# payload into the application log on EVERY delivery. The inbound half already marks
|
|
39
|
+
# request/verifier sensitive; this is the same boundary on the sending side.
|
|
40
|
+
expects :url, type: String, sensitive: true
|
|
41
|
+
expects :webhook_id, type: String
|
|
42
|
+
expects :body, type: String, sensitive: true
|
|
43
|
+
expects :event, type: String
|
|
44
|
+
expects :attempt, type: Integer, default: 1
|
|
45
|
+
# A DB-backed subscriber's own identity (its String id, not a secret/token) -- nil for
|
|
46
|
+
# today's declared-Array `to:` (no row to identify). Threaded through so a per-attempt
|
|
47
|
+
# secret/header resolver and the exhaustion report can name which subscription this was.
|
|
48
|
+
expects :subscriber_id, type: String, allow_blank: true, default: nil
|
|
49
|
+
|
|
50
|
+
# Bounded to the events a sending app declares — same shape as inbound's unconditional
|
|
51
|
+
# `reason` dimension, not a per-request identity.
|
|
52
|
+
dimension :event, -> { event }
|
|
53
|
+
# UNBOUNDED (a subscriber id off a DB table, unlike `event`) -- axn's `dimension` is the
|
|
54
|
+
# metrics facet and must stay bounded; `tag` is the high-cardinality log/trace facet with no
|
|
55
|
+
# metrics-billing cost (see Axn.config.logger.debug config comment near vendor_facet, and
|
|
56
|
+
# `Axn::Webhooks::VendorFacet`'s own dimension/tag split). Getting this backwards would
|
|
57
|
+
# quietly blow up a metrics backend's cardinality limits the first time a real subscriber
|
|
58
|
+
# table is wired up.
|
|
59
|
+
tag :subscriber_id, -> { subscriber_id }
|
|
60
|
+
|
|
61
|
+
# Only reports when `@exhaustion_error` was set by `retry_or_exhaust!`'s exhaustion branch
|
|
62
|
+
# (see `report_exhaustion_if_needed`) -- a permanent-4xx `fail!` (in `#call`) also fires
|
|
63
|
+
# `on_failure` (axn dispatches it for ANY `fail!`), but must NOT page: it never sets that
|
|
64
|
+
# ivar, so the guard holds. Registering here (rather than reporting inline before `fail!`)
|
|
65
|
+
# means the report runs once axn has already finalized `action.result` as a failure (see
|
|
66
|
+
# `with_exception_handling` in axn's executor.rb: `@context.__record_exception` runs before
|
|
67
|
+
# the `:failure` callback dispatch) -- so a reporter reading `action.result` observes the
|
|
68
|
+
# settled failure it exists to describe (Codex P2 finding).
|
|
69
|
+
on_failure :report_exhaustion_if_needed
|
|
70
|
+
|
|
71
|
+
def call
|
|
72
|
+
# Scoped deliberately to ONLY `post` (not the whole method): a network error talking to
|
|
73
|
+
# the receiver is a retryable delivery failure, but if `retry_or_exhaust!`'s own
|
|
74
|
+
# `call_async` raises while ENQUEUING the follow-up job (e.g. a Redis/Sidekiq outage),
|
|
75
|
+
# that must propagate as a loud exception — not get caught here and misinterpreted as
|
|
76
|
+
# another delivery network error, which would re-run retry_or_exhaust! a second time in
|
|
77
|
+
# the same attempt (a duplicate enqueue). Letting it propagate means the current job goes
|
|
78
|
+
# un-acked and the async adapter's own retry path handles the outage (at-least-once).
|
|
79
|
+
response = nil
|
|
80
|
+
begin
|
|
81
|
+
response = post
|
|
82
|
+
rescue *Transport::RETRYABLE_NETWORK_ERRORS => e
|
|
83
|
+
return retry_or_exhaust!(network_error: e)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
return if success?(response.status) # 2xx -> done
|
|
87
|
+
return retry_or_exhaust!(retry_after: header_value(response.headers, "retry-after")) if retryable?(response.status)
|
|
88
|
+
|
|
89
|
+
fail!(permanent_failure_message(response))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def config = Axn::Webhooks::Outbound.config
|
|
95
|
+
|
|
96
|
+
def post
|
|
97
|
+
config.transport.post(**post_args)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Timeouts are only forwarded to the built-in Transport — a custom injected transport
|
|
101
|
+
# (e.g. Faraday-backed) owns its own timeout configuration, and the documented seam is
|
|
102
|
+
# `.post(url:, body:, headers:)`; passing it kwargs it never declared would raise.
|
|
103
|
+
def post_args
|
|
104
|
+
args = { url:, body:, headers: signed_headers }
|
|
105
|
+
args.merge!(open_timeout: config.open_timeout, read_timeout: config.read_timeout) if config.transport == Transport
|
|
106
|
+
args
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Sign per attempt with a FRESH timestamp (so the receiver's replay window accepts a retry),
|
|
110
|
+
# reusing the stable webhook_id for idempotent dedup. Merge order is deliberate: custom
|
|
111
|
+
# (PRO-3214's per-destination `headers`) -> signer -> Deliver-managed, so the signer and
|
|
112
|
+
# Deliver always win a same-position `.merge`. That alone isn't the whole defense (Net::HTTP
|
|
113
|
+
# is case-insensitive, Hash keys are not, so a DIFFERENTLY-cased duplicate survives the merge
|
|
114
|
+
# and Net::HTTP still picks the later one) -- `custom_headers` below additionally drops any
|
|
115
|
+
# subscriber-supplied name that collides, case-insensitively, with either bucket.
|
|
116
|
+
def signed_headers
|
|
117
|
+
# `TargetPolicy.snapshot` (not a bare `Subscriber.new`) -- `url`/`subscriber_id` here are
|
|
118
|
+
# reconstructed fresh from THIS attempt's job payload, ordinary mutable Strings, never the
|
|
119
|
+
# frozen copy `TargetPolicy.check!` validated at resolution time (that copy lives only in
|
|
120
|
+
# `Emit`'s Resolution; `Deliver` never sees it again). A same-position `url:` hash-literal
|
|
121
|
+
# key below and the `url:` used here reference the SAME object -- so a subscriber-aware
|
|
122
|
+
# `sign`/`headers` resolver that mutated `subscriber.url` in place would silently swap the
|
|
123
|
+
# destination `post_args` already captured, sending the request to a host that was never
|
|
124
|
+
# checked against `allowed_hosts`/`allow_url` at all (Codex P2 finding, round 15). Freezing
|
|
125
|
+
# a fresh copy here means that mutation attempt raises loudly instead.
|
|
126
|
+
subscriber = TargetPolicy.snapshot(Subscriber.new(url:, id: subscriber_id))
|
|
127
|
+
signer_headers = config.signer.call(id: webhook_id, timestamp: Time.now.to_i, body:, subscriber:)
|
|
128
|
+
|
|
129
|
+
custom_headers(subscriber, signer_headers)
|
|
130
|
+
.merge(signer_headers)
|
|
131
|
+
.merge("content-type" => "application/json", "user-agent" => user_agent) # MANAGED_HEADERS
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Per-destination extra headers (PRO-3214) -- a malformed or colliding entry is DROPPED with
|
|
135
|
+
# a warning, not raised: a bad row from a `headers` resolver shouldn't crash an otherwise-
|
|
136
|
+
# deliverable attempt (a `headers` callable that itself raises is a different matter and
|
|
137
|
+
# propagates unchanged -- see `resolve_custom_headers`).
|
|
138
|
+
def custom_headers(subscriber, signer_headers)
|
|
139
|
+
raw = resolve_custom_headers(subscriber)
|
|
140
|
+
return {} if raw.nil?
|
|
141
|
+
|
|
142
|
+
# A permanent misconfiguration (the resolver forgot to return a Hash, or a conditional
|
|
143
|
+
# fell through to `false`) would otherwise raise NoMethodError from unconditional
|
|
144
|
+
# iteration below -- an UNEXPECTED exception the async adapter reads as a transient
|
|
145
|
+
# crash and retries forever, even though the malformed result will never become valid
|
|
146
|
+
# (Codex P2 finding).
|
|
147
|
+
unless raw.is_a?(Hash)
|
|
148
|
+
Axn.config.logger.warn("[axn-webhooks] dropping the headers resolver result -- expected a Hash, got #{raw.class}")
|
|
149
|
+
return {}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Transport::RESERVED_HEADERS (content-length/transfer-encoding) are reserved
|
|
153
|
+
# unconditionally, not only for the built-in transport -- matching `sign :hmac`'s own
|
|
154
|
+
# header-name validation, which treats them the same way regardless of what `transport`
|
|
155
|
+
# ends up configured. Net::HTTP silently REWRITES both, AFTER headers are applied, so a
|
|
156
|
+
# subscriber-controlled value under either name would otherwise pass every check here
|
|
157
|
+
# and just never reach the receiver -- the delivery reports success regardless (Codex
|
|
158
|
+
# P2 finding).
|
|
159
|
+
#
|
|
160
|
+
# `signer_headers.keys` is normalized to Strings here: a custom `sign` block returning a
|
|
161
|
+
# Symbol-keyed Hash (`{ "X-Signature": ... }`, the natural way to write that literal)
|
|
162
|
+
# would otherwise put a Symbol into `reserved`, and below, `r.casecmp?(key)` returns nil
|
|
163
|
+
# -- not a match, but not an error either -- whenever `r` and `key` are different types,
|
|
164
|
+
# even when case-identical. That silently let a subscriber-controlled `headers` entry
|
|
165
|
+
# ship ALONGSIDE the signer's real header under the same wire name (Codex P2 finding,
|
|
166
|
+
# round 11).
|
|
167
|
+
reserved = MANAGED_HEADERS + Transport::RESERVED_HEADERS + signer_headers.keys.map(&:to_s)
|
|
168
|
+
raw.each_with_object({}) { |(key, value), out| add_custom_header(out, key, value, reserved) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def resolve_custom_headers(subscriber)
|
|
172
|
+
callable = config.headers
|
|
173
|
+
return nil if callable.nil?
|
|
174
|
+
|
|
175
|
+
# Same precedence (and the Proc/#parameters quirk it works around) as the signing secret
|
|
176
|
+
# -- see Signer::StandardWebhooksSigner#resolve_secret (Codex P1 finding).
|
|
177
|
+
CallableArity.prefers_zero_args?(callable) ? callable.call : callable.call(subscriber)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Net::HTTP requires String keys/values; a non-String pair would otherwise raise mid-flight,
|
|
181
|
+
# a boot-clean declaration turned into a per-attempt crash. A key colliding, CASE-
|
|
182
|
+
# INSENSITIVELY, with a Deliver-managed header or one the signer just emitted this attempt is
|
|
183
|
+
# dropped for the reason `signed_headers`' comment gives: Hash keys don't collide there, but
|
|
184
|
+
# Net::HTTP's header line does, silently, and it is always the LATER assignment that survives
|
|
185
|
+
# -- which a subscriber-controlled row must never be allowed to be for webhook-signature.
|
|
186
|
+
def add_custom_header(out, key, value, reserved)
|
|
187
|
+
# NEVER logs `value` -- `{ Authorization: "Bearer live-token" }` (a plain Symbol-keyed
|
|
188
|
+
# Hash literal, the single most natural way to write this in Ruby) fails the String-key
|
|
189
|
+
# check, and logging the value unconditionally here would copy a live credential
|
|
190
|
+
# straight into application logs the moment anyone wrote a `headers` resolver this way
|
|
191
|
+
# (Codex P1 finding). The key name alone is enough to debug "which header was malformed" --
|
|
192
|
+
# true for the DOCUMENTED common case (a plain Symbol/String literal like `Authorization:`)
|
|
193
|
+
# -- but `headers` exists specifically to carry credentials, and a resolver could just as
|
|
194
|
+
# easily build a Symbol/String key DYNAMICALLY from one (`token.to_sym`, or a String key
|
|
195
|
+
# paired with a non-String value, which is what actually routes a row into this branch) --
|
|
196
|
+
# Symbol/String content is exactly as unconstrained as a compound object's in that case
|
|
197
|
+
# (Codex P1 finding, round 25; round 16 fixed the compound-object case but still trusted
|
|
198
|
+
# ANY Symbol/String verbatim). `key_desc` below only shows a key matching a plausible
|
|
199
|
+
# field-name shape as-is; anything else -- compound, or Symbol/String that doesn't look
|
|
200
|
+
# like one -- is named by class only.
|
|
201
|
+
unless key.is_a?(String) && value.is_a?(String)
|
|
202
|
+
Axn.config.logger.warn("[axn-webhooks] dropping a custom header with a non-String key or value (key: #{key_desc(key)})")
|
|
203
|
+
return
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The built-in Transport rejects CR/LF in a header VALUE, but a String KEY containing
|
|
207
|
+
# CR/LF (or a space/colon) would otherwise reach `request[key] = value` unchanged --
|
|
208
|
+
# Net::HTTP serializes whatever key it's handed straight into the wire header line, so a
|
|
209
|
+
# subscriber-controlled `headers` resolver could inject an entirely separate header
|
|
210
|
+
# (Codex P1 finding). Same grammar `sign :hmac`'s own header options are validated
|
|
211
|
+
# against at boot.
|
|
212
|
+
#
|
|
213
|
+
# `#match?` itself isn't safe to call unconditionally: a String in a DIFFERENT encoding
|
|
214
|
+
# than the Regexp (e.g. UTF-16LE) raises `Encoding::CompatibilityError`, and a malformed
|
|
215
|
+
# byte sequence in its OWN declared encoding raises `ArgumentError` -- either way an
|
|
216
|
+
# UNEXPECTED exception escaping delivery, which the async adapter reads as a transient
|
|
217
|
+
# crash and retries forever on a resolver result that will never become valid (Codex P2
|
|
218
|
+
# finding, round 19). `safely_matches?`'s `on_error:` picks what a raised encoding error
|
|
219
|
+
# should be treated as -- `false` here (didn't match a valid field-name -- malformed,
|
|
220
|
+
# drop it).
|
|
221
|
+
unless safely_matches?(key, Signer::HEADER_NAME, on_error: false)
|
|
222
|
+
# NEVER logs `key` here -- unlike the non-String/Symbol-key and unknown-Hash-key cases
|
|
223
|
+
# elsewhere in this file, THIS key already passed "is a String" and just failed the
|
|
224
|
+
# valid-header-name check, so its content is unconstrained. `headers` exists
|
|
225
|
+
# specifically to carry credentials, and a resolver mistake could hand back the
|
|
226
|
+
# credential ITSELF as the key instead of a proper header name (e.g.
|
|
227
|
+
# `{ "Bearer live-token" => "x" }`, or a URL-keyed map) -- logging it in full would copy
|
|
228
|
+
# that credential straight into application logs (Codex P1 finding, round 24). A byte
|
|
229
|
+
# count is enough to debug "the key was malformed" without risking its content.
|
|
230
|
+
Axn.config.logger.warn("[axn-webhooks] dropping custom header with an invalid HTTP field-name or encoding (key: #{key.bytesize}-byte String)")
|
|
231
|
+
return
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Net::HTTP itself raises `ArgumentError: header field value cannot include CR/LF` for a
|
|
235
|
+
# value containing either -- unlike a malformed KEY (above), which it happily serializes
|
|
236
|
+
# verbatim. Left unvalidated, a permanently-malformed value would raise an UNEXPECTED
|
|
237
|
+
# exception on every attempt, which the async adapter reads as a transient crash and
|
|
238
|
+
# retries forever, rather than being dropped like every other malformed entry here
|
|
239
|
+
# (Codex P2 finding). `on_error: true` here (treat a raised encoding error as "found
|
|
240
|
+
# CR/LF" -- malformed, drop it) for the same reason `safely_matches?` exists above.
|
|
241
|
+
#
|
|
242
|
+
# Broadened beyond CR/LF to every control byte the RFC 7230 `field-value` grammar forbids
|
|
243
|
+
# (HTAB, 0x09, is the one control byte it explicitly permits) -- a value containing NUL or
|
|
244
|
+
# another stray control character (e.g. BEL) passed this check unvalidated and the
|
|
245
|
+
# built-in Transport serialized it straight onto the wire, where it's equally invalid and
|
|
246
|
+
# can get an otherwise-valid webhook rejected by the receiver or a proxy in between (Codex
|
|
247
|
+
# P2 finding, round 25).
|
|
248
|
+
if safely_matches?(value, FORBIDDEN_HEADER_VALUE_BYTES, on_error: true)
|
|
249
|
+
Axn.config.logger.warn(
|
|
250
|
+
"[axn-webhooks] dropping custom header #{key.inspect} -- value contains a forbidden control character (or has an invalid/incompatible encoding)",
|
|
251
|
+
)
|
|
252
|
+
return
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
if reserved.any? { |r| r.casecmp?(key) }
|
|
256
|
+
Axn.config.logger.warn(
|
|
257
|
+
"[axn-webhooks] dropping custom header #{key.inspect} -- collides with a header Deliver or the active signer already sets",
|
|
258
|
+
)
|
|
259
|
+
return
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
out[key] = value
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Only a key matching a plausible field-name shape is safe to show as-is (a Symbol/String
|
|
266
|
+
# built DYNAMICALLY from a credential is exactly as unconstrained as a compound object's
|
|
267
|
+
# #inspect -- see `add_custom_header`'s comment above); anything else, including a
|
|
268
|
+
# Symbol/String that doesn't look like a field name, is named by class only.
|
|
269
|
+
def key_desc(key)
|
|
270
|
+
return "instance of #{key.class}" unless key.is_a?(String) || key.is_a?(Symbol)
|
|
271
|
+
|
|
272
|
+
safely_matches?(key.to_s, PLAUSIBLE_FIELD_NAME, on_error: false) ? key.inspect : "instance of #{key.class}"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# `String#match?` raises rather than returning a boolean for two encoding failure modes:
|
|
276
|
+
# `Encoding::CompatibilityError` when `string`'s encoding differs from the Regexp's (e.g. a
|
|
277
|
+
# UTF-16LE header key against a US-ASCII/UTF-8 Regexp), and `ArgumentError` for a malformed
|
|
278
|
+
# byte sequence in `string`'s own declared encoding. Both are treated as "this string is
|
|
279
|
+
# unusable" -- `on_error:` supplies what that should count as for the specific check calling
|
|
280
|
+
# this (see call sites above).
|
|
281
|
+
def safely_matches?(string, regex, on_error:)
|
|
282
|
+
string.match?(regex)
|
|
283
|
+
rescue Encoding::CompatibilityError, ArgumentError
|
|
284
|
+
on_error
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def user_agent
|
|
288
|
+
suffix = resolve_user_agent_suffix
|
|
289
|
+
return "axn-webhooks/#{Axn::Webhooks::VERSION}" if suffix.nil?
|
|
290
|
+
|
|
291
|
+
"axn-webhooks/#{Axn::Webhooks::VERSION} (#{suffix})"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def resolve_user_agent_suffix
|
|
295
|
+
configured = config.user_agent
|
|
296
|
+
return nil if configured.nil?
|
|
297
|
+
|
|
298
|
+
configured.respond_to?(:call) ? configured.call : configured
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def success?(status) = (200..299).cover?(status)
|
|
302
|
+
|
|
303
|
+
# 5xx, plus the "come back later" 4xx codes.
|
|
304
|
+
def retryable?(status) = status >= 500 || [408, 425, 429].include?(status)
|
|
305
|
+
|
|
306
|
+
# The receiver-supplied body is the one piece of detail a permanent 4xx can offer beyond
|
|
307
|
+
# its status code — truncated so a verbose error page never blows up a log line or an
|
|
308
|
+
# exception report.
|
|
309
|
+
def permanent_failure_message(response)
|
|
310
|
+
"permanent delivery failure (HTTP #{response.status}) for #{event} to #{safe_url}#{truncated_body(response.body)}"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# net/http labels every response body ASCII-8BIT regardless of actual content, so `body` may
|
|
314
|
+
# hold arbitrary bytes (invalid UTF-8, or valid multibyte UTF-8 mislabeled as binary). Slice
|
|
315
|
+
# BYTES first (encoding-agnostic, so the cut itself never raises), then force UTF-8 and
|
|
316
|
+
# `scrub` — which also repairs a multibyte character split at the 500-byte boundary — before
|
|
317
|
+
# appending the UTF-8 ellipsis, so the two `+` operands are always compatible.
|
|
318
|
+
# Origin only — scheme://host[:port]. A Slack/Discord/Teams hook puts its secret in the
|
|
319
|
+
# path (and some receivers use a signed query token), so a bare `url` in any message that
|
|
320
|
+
# reaches a log or an error tracker publishes a live credential. Correlate on webhook_id /
|
|
321
|
+
# subscriber_id instead: both are credential-free by design.
|
|
322
|
+
def safe_url = TargetPolicy.redact_url(url)
|
|
323
|
+
|
|
324
|
+
def truncated_body(body)
|
|
325
|
+
return "" if body.nil? || body.empty?
|
|
326
|
+
|
|
327
|
+
bytes = body.b
|
|
328
|
+
truncated = bytes.bytesize > 500
|
|
329
|
+
snippet = bytes.byteslice(0, 500).force_encoding(Encoding::UTF_8).scrub("�")
|
|
330
|
+
snippet += "…" if truncated
|
|
331
|
+
": #{snippet}"
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Only reschedule when BOTH attempts remain AND an async adapter is actually configured for
|
|
335
|
+
# Deliver to reschedule itself onto — otherwise `call_async` would raise a ScriptError
|
|
336
|
+
# (NotImplementedError) that escapes axn's StandardError-only exception boundary entirely,
|
|
337
|
+
# crashing the caller (e.g. Emit's synchronous best-effort fallback fan-out loop). No
|
|
338
|
+
# adapter configured is therefore treated the same as an exhausted retry budget: report
|
|
339
|
+
# once, fail! quietly (no crash, no cross-process retries — matches the documented
|
|
340
|
+
# best-effort promise of the sync fallback path).
|
|
341
|
+
def retry_or_exhaust!(retry_after: nil, network_error: nil)
|
|
342
|
+
if attempt >= config.max_attempts || !async_configured?
|
|
343
|
+
@exhaustion_error = network_error || Axn::Webhooks::Error.new("outbound delivery exhausted for #{event} to #{safe_url}")
|
|
344
|
+
return fail!(terminal_message)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
delay = [config.backoff.call(attempt), parse_retry_after(retry_after)].compact.max
|
|
348
|
+
self.class.call_async(url:, webhook_id:, body:, event:, vendor:, subscriber_id:, attempt: attempt + 1,
|
|
349
|
+
_async: { wait: delay })
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def terminal_message
|
|
353
|
+
return "delivery exhausted after #{attempt} attempts for #{event} to #{safe_url}" if attempt >= config.max_attempts
|
|
354
|
+
|
|
355
|
+
"delivery failed for #{event} to #{safe_url} (no async adapter configured to retry attempt #{attempt + 1})"
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Presence check ONLY (never branches on adapter type) — mirrors Dispatch's own
|
|
359
|
+
# `async_adapter_configured?` exactly, but against `self.class` since Deliver reschedules
|
|
360
|
+
# ITSELF. An explicit per-class setting (including `false`) always wins over the global
|
|
361
|
+
# default.
|
|
362
|
+
def async_configured?
|
|
363
|
+
return !!self.class._async_adapter unless self.class._async_adapter.nil?
|
|
364
|
+
|
|
365
|
+
Axn.config.default_async?
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# HTTP header names are case-insensitive, but `Transport` is a public injectable seam — a
|
|
369
|
+
# custom transport (e.g. Faraday-backed) may return a plain Hash with "Retry-After" or
|
|
370
|
+
# "RETRY-AFTER" rather than the lowercased keys net/http's `to_hash` produces. Look up by
|
|
371
|
+
# name case-insensitively instead of assuming lowercase.
|
|
372
|
+
def header_value(headers, name)
|
|
373
|
+
headers.each { |k, v| return v if k.to_s.casecmp?(name) }
|
|
374
|
+
nil
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Retry-After per RFC 7231: either delay-seconds (integer) or an HTTP-date. For the
|
|
378
|
+
# HTTP-date form, compute the remaining seconds until that instant, clamped to >= 0 (a
|
|
379
|
+
# past/now date means "no extra delay beyond backoff", not "retry immediately forever").
|
|
380
|
+
def parse_retry_after(value)
|
|
381
|
+
return nil if value.nil? || value.to_s.empty?
|
|
382
|
+
|
|
383
|
+
return Integer(value, 10) if value.to_s.match?(/\A\d+\z/)
|
|
384
|
+
|
|
385
|
+
begin
|
|
386
|
+
[(Time.httpdate(value) - Time.now).to_i, 0].max
|
|
387
|
+
rescue ArgumentError
|
|
388
|
+
nil
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Gated `on_failure` handler (registered above, at class-body level): fires on EVERY
|
|
393
|
+
# `fail!` (including the permanent-4xx branch in `#call`), but only reports when
|
|
394
|
+
# `retry_or_exhaust!`'s exhaustion branch actually set `@exhaustion_error` — a permanent-4xx
|
|
395
|
+
# `fail!` never sets it, so this is a no-op there.
|
|
396
|
+
def report_exhaustion_if_needed
|
|
397
|
+
return unless @exhaustion_error
|
|
398
|
+
|
|
399
|
+
report_exhaustion(@exhaustion_error)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Report ONCE at exhaustion via axn's configured reporter (Honeybadger at Teamshares),
|
|
403
|
+
# WITHOUT raising — raising would trigger the adapter to retry the already-exhausted job.
|
|
404
|
+
# `action:` must be the running INSTANCE (`self`), not the class — axn's own internal
|
|
405
|
+
# callers always pass the instance (see executor.rb), and `on_exception` relies on
|
|
406
|
+
# instance-only state (e.g. `action.result`) to enrich the report; the configured reporter
|
|
407
|
+
# itself may also expect a real action instance. `report_exhaustion` is itself an instance
|
|
408
|
+
# method, so `self` here already IS that instance. Called from `report_exhaustion_if_needed`
|
|
409
|
+
# (an `on_failure` callback), which runs AFTER axn has finalized `action.result` as a
|
|
410
|
+
# failure — see the `on_failure` doc comment above `retry_or_exhaust!` for why that ordering
|
|
411
|
+
# matters.
|
|
412
|
+
def report_exhaustion(error)
|
|
413
|
+
# The reporter itself may throw; guard it best-effort (logs+swallows in prod/test, re-raises
|
|
414
|
+
# in dev only when Axn.config.best_effort_raises_in_dev) so a broken reporter never turns
|
|
415
|
+
# exhaustion into a raise the async adapter would retry. `action: self` routes the warn to
|
|
416
|
+
# the running instance, matching axn's own internal best_effort callers.
|
|
417
|
+
Axn::Extensions.best_effort("reporting outbound delivery exhaustion", action: self) do
|
|
418
|
+
# url: redacted to its origin — this context is shipped to an external error tracker.
|
|
419
|
+
Axn.config.on_exception(error, action: self, context: { event:, url: safe_url, webhook_id:, attempt:, subscriber_id: })
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module Webhooks
|
|
5
|
+
module Outbound
|
|
6
|
+
# Receiver for the `Axn::Webhooks.outbound do … end` block.
|
|
7
|
+
class DSL
|
|
8
|
+
# Distinguishes "argument omitted" from "an explicit falsy value was passed" for
|
|
9
|
+
# `allow_url`/`headers` below -- `callable = nil` as the default can't tell `allow_url`
|
|
10
|
+
# (nothing given) apart from `allow_url false` (a caller-supplied, non-callable value that
|
|
11
|
+
# must be rejected at boot). `callable || block` treated both identically, silently
|
|
12
|
+
# DISABLING the host policy / header resolver for an explicit `false` rather than
|
|
13
|
+
# surfacing the setting's own "must be a callable" validation error (Codex P1 finding).
|
|
14
|
+
UNSET = Object.new.freeze
|
|
15
|
+
private_constant :UNSET
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@events = {}
|
|
19
|
+
@sign_spec = nil
|
|
20
|
+
@default_subscribers = nil
|
|
21
|
+
@max_attempts = nil
|
|
22
|
+
@backoff = nil
|
|
23
|
+
@transport = nil
|
|
24
|
+
@vendor = nil
|
|
25
|
+
@user_agent = nil
|
|
26
|
+
@open_timeout = nil
|
|
27
|
+
@read_timeout = nil
|
|
28
|
+
@allowed_hosts = nil
|
|
29
|
+
@allow_url = nil
|
|
30
|
+
@headers = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def sign(strategy = nil, **opts, &block)
|
|
34
|
+
@sign_spec = { strategy:, opts:, block: }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def subscribers(resolver = nil, &block)
|
|
38
|
+
@default_subscribers = resolver || block
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def max_attempts(value) = @max_attempts = value
|
|
42
|
+
def backoff(callable = nil, &block) = @backoff = callable || block
|
|
43
|
+
def transport(obj) = @transport = obj
|
|
44
|
+
|
|
45
|
+
# The observability facet (Axn::Webhooks.config.vendor_facet) stamped on every Emit/Deliver
|
|
46
|
+
# for events with no per-event override — see `event`'s `vendor:`.
|
|
47
|
+
def vendor(value) = @vendor = value
|
|
48
|
+
|
|
49
|
+
# A suffix identifying the sending app/deploy, appended to the fixed
|
|
50
|
+
# "axn-webhooks/<version>" User-Agent as "axn-webhooks/<version> (<value>)". Plain value or
|
|
51
|
+
# a zero-arity callable, resolved per delivery attempt.
|
|
52
|
+
def user_agent(value = nil, &block) = @user_agent = value || block
|
|
53
|
+
|
|
54
|
+
def timeouts(open: nil, read: nil)
|
|
55
|
+
@open_timeout = open
|
|
56
|
+
@read_timeout = read
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A host policy for resolved targets (both a static `to:` Array and a runtime
|
|
60
|
+
# `subscribers`/`to:` lambda's return value go through the same check) -- see TargetPolicy
|
|
61
|
+
# for exact matching semantics. Splat-friendly: `allowed_hosts "a.example", "b.example"` and
|
|
62
|
+
# `allowed_hosts %w[a.example b.example]` both work.
|
|
63
|
+
def allowed_hosts(*values) = @allowed_hosts = values.flatten
|
|
64
|
+
|
|
65
|
+
# A general escape hatch alongside `allowed_hosts` -- called with the parsed URI, must
|
|
66
|
+
# return truthy to allow the target through. Both nil by default (no host policy at all).
|
|
67
|
+
def allow_url(callable = UNSET, &block) = @allow_url = resolve_settable(callable, block)
|
|
68
|
+
|
|
69
|
+
# Per-destination extra headers (e.g. a subscriber's bearer token) -- resolved fresh per
|
|
70
|
+
# DELIVERY ATTEMPT from the Subscriber, never stored, same convention `sign`'s `secret:`
|
|
71
|
+
# follows. 0-arity (ignores the subscriber) or 1-arity (receives it). nil by default.
|
|
72
|
+
def headers(callable = UNSET, &block) = @headers = resolve_settable(callable, block)
|
|
73
|
+
|
|
74
|
+
# rubocop:disable-next Naming/MethodParameterName
|
|
75
|
+
def event(name, to: nil, type: nil, vendor: nil)
|
|
76
|
+
@events[name.to_sym] = { to:, type:, vendor: }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Internal: build the resolved Config, validating declarations.
|
|
80
|
+
def __config__
|
|
81
|
+
# A pure declaration mistake (decided once at boot, never at runtime) — ArgumentError, not
|
|
82
|
+
# Axn::Webhooks::Error, matching Config's own misconfiguration-vs-runtime split.
|
|
83
|
+
raise ArgumentError, "outbound block must declare `sign`" if @sign_spec.nil?
|
|
84
|
+
|
|
85
|
+
@events.each do |name, spec|
|
|
86
|
+
next unless spec[:to].is_a?(Array) && spec[:to].empty?
|
|
87
|
+
|
|
88
|
+
Axn.config.logger.warn("[axn-webhooks] outbound event #{name.inspect} declares an empty `to:` — it will deliver nowhere")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
Config.new(
|
|
92
|
+
signer: Signer.build(**@sign_spec),
|
|
93
|
+
events: @events,
|
|
94
|
+
default_subscribers: @default_subscribers,
|
|
95
|
+
max_attempts: @max_attempts,
|
|
96
|
+
backoff: @backoff,
|
|
97
|
+
transport: @transport,
|
|
98
|
+
vendor: @vendor,
|
|
99
|
+
user_agent: @user_agent,
|
|
100
|
+
open_timeout: @open_timeout,
|
|
101
|
+
read_timeout: @read_timeout,
|
|
102
|
+
allowed_hosts: @allowed_hosts,
|
|
103
|
+
allow_url: @allow_url,
|
|
104
|
+
headers: @headers,
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# `callable` is UNSET only when the method was called with no positional argument at
|
|
111
|
+
# all -- an explicit non-callable value (including `false`) must survive to `Config`'s
|
|
112
|
+
# own setting validator rather than being silently coerced into "not set".
|
|
113
|
+
def resolve_settable(callable, block)
|
|
114
|
+
return block if block
|
|
115
|
+
|
|
116
|
+
UNSET.equal?(callable) ? nil : callable
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|