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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +176 -0
  3. data/DESIGN-NOTES.md +241 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +1042 -0
  6. data/lib/axn/webhooks/dispatch.rb +129 -0
  7. data/lib/axn/webhooks/errors.rb +48 -0
  8. data/lib/axn/webhooks/handler.rb +15 -0
  9. data/lib/axn/webhooks/header_value.rb +29 -0
  10. data/lib/axn/webhooks/inbound/build_request.rb +23 -0
  11. data/lib/axn/webhooks/inbound/challenge.rb +37 -0
  12. data/lib/axn/webhooks/inbound/challenge_required.rb +35 -0
  13. data/lib/axn/webhooks/inbound/dsl.rb +240 -0
  14. data/lib/axn/webhooks/inbound/endpoint.rb +221 -0
  15. data/lib/axn/webhooks/inbound/parsers.rb +20 -0
  16. data/lib/axn/webhooks/inbound/respond_context.rb +17 -0
  17. data/lib/axn/webhooks/inbound/router.rb +104 -0
  18. data/lib/axn/webhooks/inbound.rb +124 -0
  19. data/lib/axn/webhooks/outbound/callable_arity.rb +99 -0
  20. data/lib/axn/webhooks/outbound/config.rb +442 -0
  21. data/lib/axn/webhooks/outbound/deliver.rb +425 -0
  22. data/lib/axn/webhooks/outbound/dsl.rb +121 -0
  23. data/lib/axn/webhooks/outbound/emit.rb +181 -0
  24. data/lib/axn/webhooks/outbound/envelope.rb +23 -0
  25. data/lib/axn/webhooks/outbound/signer.rb +376 -0
  26. data/lib/axn/webhooks/outbound/subscriber.rb +152 -0
  27. data/lib/axn/webhooks/outbound/target_policy.rb +135 -0
  28. data/lib/axn/webhooks/outbound/transport.rb +59 -0
  29. data/lib/axn/webhooks/outbound.rb +73 -0
  30. data/lib/axn/webhooks/request.rb +230 -0
  31. data/lib/axn/webhooks/resolvers.rb +43 -0
  32. data/lib/axn/webhooks/respond.rb +26 -0
  33. data/lib/axn/webhooks/response.rb +116 -0
  34. data/lib/axn/webhooks/signature.rb +268 -0
  35. data/lib/axn/webhooks/static_respond.rb +22 -0
  36. data/lib/axn/webhooks/vendor_facet.rb +25 -0
  37. data/lib/axn/webhooks/verifiers/basic_auth.rb +128 -0
  38. data/lib/axn/webhooks/verifiers/hmac.rb +58 -0
  39. data/lib/axn/webhooks/verifiers/standard_webhooks.rb +129 -0
  40. data/lib/axn/webhooks/verifiers.rb +50 -0
  41. data/lib/axn/webhooks/verify.rb +106 -0
  42. data/lib/axn/webhooks/version.rb +7 -0
  43. data/lib/axn/webhooks.rb +61 -0
  44. data/lib/axn-webhooks.rb +3 -0
  45. metadata +128 -0
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ # A deferred request-value lookup used inside an `inbound` block, e.g.
6
+ # `verify :hmac, signature: header("X-Sig")`. Called with the Request at verify time.
7
+ class Resolver
8
+ def initialize(&blk)
9
+ @blk = blk
10
+ end
11
+
12
+ def call(request) = @blk.call(request)
13
+ end
14
+
15
+ module Resolvers
16
+ module_function
17
+
18
+ def header(name) = Resolver.new { |req| req.header(name) }
19
+ def raw_body = Resolver.new(&:raw_body)
20
+ def params = Resolver.new(&:params)
21
+ def url = Resolver.new(&:url)
22
+
23
+ # Resolve a declared value against the request:
24
+ # Resolver -> call(request); Symbol -> request.public_send(sym);
25
+ # Proc -> call(request) (or call for a 0-arity proc); else the literal.
26
+ # Exactly the shapes `resolve` below defers — the single source of truth for "is this resolved
27
+ # per request, or used as a literal?". Boot-time secret checks ask this rather than
28
+ # `respond_to?(:call)`: a credential-provider object or a Method responds to #call but is NOT
29
+ # resolved here, so exempting it from validation let it declare cleanly and then fail every
30
+ # request with a type error instead of failing loudly at boot (Codex review).
31
+ def deferred?(value) = value.is_a?(Resolver) || value.is_a?(Symbol) || value.is_a?(Proc)
32
+
33
+ def resolve(value, request)
34
+ case value
35
+ when Resolver then value.call(request)
36
+ when Symbol then request.public_send(value)
37
+ when Proc then value.arity.zero? ? value.call : value.call(request)
38
+ else value
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ # The respond stage as an Axn: runs the endpoint's custom `respond` block against the handler's
6
+ # result to build a Response. Built as an Axn so a raise inside the (user-supplied) respond block
7
+ # — e.g. reading an exposure the handler forgot to set — is reported once via on_exception and
8
+ # mapped to a 500 by Endpoint#to_response, never an unhandled exception escaping the HTTP mapper.
9
+ class Respond
10
+ include Axn
11
+ include Axn::Webhooks::VendorFacet
12
+
13
+ expects :handler_result
14
+ expects :responder
15
+ # Type-constrained: a respond block that returns a non-Response (e.g. a raw String instead
16
+ # of `text("…")`) fails outbound validation here → mapped to a 500, preserving the
17
+ # `to_response -> Response` contract rather than leaking a bad object to the Rack renderer.
18
+ exposes :response, type: Axn::Webhooks::Response
19
+ error "Webhook respond failed"
20
+
21
+ def call
22
+ expose response: Inbound::RespondContext.new.instance_exec(handler_result, &responder)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Axn
6
+ module Webhooks
7
+ # A Rails-agnostic HTTP response value: status + body + headers. Produced by
8
+ # `Endpoint#to_response`/`#challenge_response` from the pipeline's Axn::Result. `#to_rack`
9
+ # renders it as the [status, headers, body] triple Endpoint#call(env) returns.
10
+ class Response
11
+ attr_reader :status, :body, :headers
12
+
13
+ def initialize(status: 200, body: "", headers: {})
14
+ @status = status
15
+ # deep_freeze (not `.freeze`) so a caller-owned String body isn't frozen in place —
16
+ # `String#to_s` returns self, so `.freeze` would mutate the handler's own string.
17
+ @body = deep_freeze(body.to_s)
18
+ # Keys are lower-cased (Rack 3's SPEC forbids uppercase in response header keys, and
19
+ # Rack::Lint rejects them). Keys AND values are frozen deeply (Array multi-value headers
20
+ # freeze their elements too) so a caller's mutable value can't mutate this rendered-later value.
21
+ # Values carrying CR/LF (or any other byte RFC 7230 forbids) are DROPPED, not rendered:
22
+ # a `respond`/`static_respond`/`unauthorized_headers` declaration that echoes request data
23
+ # into a header would otherwise let a sender inject headers or split the response. Dropped
24
+ # rather than raised so a rendering mistake degrades to a missing header instead of a 500,
25
+ # matching what the outbound half already does with a subscriber's custom headers.
26
+ @headers = headers.each_with_object({}) do |(key, value), frozen|
27
+ safe = sanitize_header(key, value)
28
+ next if safe.nil?
29
+
30
+ frozen[key.to_s.downcase.freeze] = deep_freeze(safe)
31
+ end.freeze
32
+ freeze
33
+ end
34
+
35
+ # An Array multi-value header (Set-Cookie, per Rack 3) is filtered element-wise so one bad
36
+ # cookie doesn't discard the good ones; nil means "drop this header entirely".
37
+ def sanitize_header(key, value)
38
+ if value.is_a?(Array)
39
+ kept = value.select { |element| HeaderValue.safe?(element) }
40
+ warn_dropped(key) if kept.size != value.size
41
+ return kept.empty? ? nil : kept
42
+ end
43
+
44
+ return value if HeaderValue.safe?(value)
45
+
46
+ warn_dropped(key)
47
+ nil
48
+ end
49
+ private :sanitize_header
50
+
51
+ # Never logs the value itself — it is attacker-influenced by construction here, and echoing it
52
+ # into the log is a smaller version of the same injection problem.
53
+ def warn_dropped(key)
54
+ Axn.config.logger.warn(
55
+ "[axn-webhooks] dropping response header #{key.to_s.downcase.inspect} — value contains a " \
56
+ "forbidden control character (or has an invalid encoding)",
57
+ )
58
+ end
59
+ private :warn_dropped
60
+
61
+ def self.ack(status: 200, headers: {}) = new(status:, headers:)
62
+
63
+ def self.text(body, status: 200, headers: {})
64
+ new(status:, body:, headers: { "content-type" => "text/plain" }.merge(headers))
65
+ end
66
+
67
+ def self.xml(body, status: 200, headers: {})
68
+ new(status:, body:, headers: { "content-type" => "application/xml" }.merge(headers))
69
+ end
70
+
71
+ # A Hash/Array body is JSON-encoded; a String is assumed pre-serialized and passed through.
72
+ def self.json(body, status: 200, headers: {})
73
+ body = JSON.generate(body) unless body.is_a?(String)
74
+ new(status:, body:, headers: { "content-type" => "application/json" }.merge(headers))
75
+ end
76
+
77
+ # A plausible HTTP status: an Integer inside the range HTTP defines. Shared by the
78
+ # `unparseable_status` config setting and the per-endpoint `dispatch unparseable_status:`, so
79
+ # both reject the same values against the same bound.
80
+ def self.valid_status?(value) = value.is_a?(Integer) && (200..599).cover?(value)
81
+
82
+ def self.service_unavailable(retry_after: nil)
83
+ headers = retry_after ? { "retry-after" => retry_after.to_s } : {}
84
+ new(status: 503, headers:)
85
+ end
86
+
87
+ # The same body and headers under a different status. The unparseable-body mapping needs it: a
88
+ # declared `static_respond` block picked its status for the success path, but the gem owns the
89
+ # outcome->status mapping, so the body a vendor keys on survives and only the status is restamped.
90
+ def with_status(status) = self.class.new(status:, body:, headers:)
91
+
92
+ def ==(other)
93
+ other.is_a?(self.class) && status == other.status && body == other.body && headers == other.headers
94
+ end
95
+
96
+ # [status, headers, body] — the Rack app return contract. Headers are already lower-cased
97
+ # (see #initialize); body is wrapped in an Array, Rack's documented minimal body contract.
98
+ # Return a mutable copy of headers so Rails middleware can add headers (e.g., ETag).
99
+ # Array header values (multi-value headers like Set-Cookie) are duped to be mutable so
100
+ # middleware like Rack::Utils.set_cookie_header! can append; String values pass through.
101
+ # Rack 3 requires Array headers, not newline-joined Strings.
102
+ def to_rack = [status, headers.transform_values { |value| value.is_a?(Array) ? value.dup : value }, [body]]
103
+
104
+ private
105
+
106
+ # Freeze a header value so it can't be mutated after construction. Handles the two Rack
107
+ # header-value shapes: a String, and an Array of Strings (multi-value headers) whose
108
+ # elements are frozen too.
109
+ def deep_freeze(value)
110
+ return value.map { |element| deep_freeze(element) }.freeze if value.is_a?(Array)
111
+
112
+ value.frozen? ? value : value.dup.freeze
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+
6
+ module Axn
7
+ module Webhooks
8
+ # The shared HMAC primitive. Pure functions over bytes — no Request, no Rack, no axn.
9
+ # Both inbound `verify` and outbound `sign` build on this. ALWAYS constant-time.
10
+ module Signature
11
+ DIGESTS = { sha256: "SHA256", sha1: "SHA1", md5: "MD5" }.freeze
12
+ # Named so a caller can validate an `encoding:` up front rather than discovering it inside
13
+ # `encode` mid-request (Outbound::Signer::HmacSigner does exactly that at declaration time).
14
+ ENCODINGS = %i[hex base64 base64_urlsafe].freeze
15
+ UNITS = { seconds: 1, ms: 1_000, milliseconds: 1_000, microseconds: 1_000_000 }.freeze
16
+
17
+ # Infer the unit from the timestamp's magnitude, per-timestamp. The default, because a vendor
18
+ # can send more than one unit -- Lob delivers epoch-seconds via Svix and epoch-ms from its
19
+ # dashboard, and no static unit: is correct for both (PRO-3142).
20
+ AUTO = :auto
21
+
22
+ # The bands :auto reads magnitude against. The three scales sit 1000x apart and their
23
+ # plausible-date ranges don't overlap, so every timestamp a vendor could legitimately send
24
+ # falls in exactly one band:
25
+ #
26
+ # value >= 1e11 can't be seconds -- that's the year 5138; as ms it's 1973.
27
+ # value >= 1e14 can't be ms -- that's the year 5138; as microseconds it's 1973.
28
+ #
29
+ # A misread is therefore impossible in the 1973..5138 range, and outside it a misread can only
30
+ # produce a ~56-year skew, which the tolerance window rejects. Inference never widens what is
31
+ # accepted: no wrong-scale reading of a stale timestamp lands inside a tolerance of any
32
+ # realistic size.
33
+ AUTO_MS_FLOOR = 100_000_000_000
34
+ AUTO_US_FLOOR = 100_000_000_000_000
35
+
36
+ # The distinct scales, for diagnostics. Excludes the :milliseconds alias (same divisor as :ms)
37
+ # so a mismatch is never reported as the caller's own unit under a different name.
38
+ CANONICAL_UNITS = %i[seconds ms microseconds].freeze
39
+
40
+ # The outcome of a signature check, with the CAUSE of a rejection named. Deliberately not
41
+ # called Result — `Axn::Result` already owns that word in this codebase's vocabulary.
42
+ #
43
+ # `reason` is one of REASONS (nil when ok). `skew` is set only for :replay_window, in seconds,
44
+ # signed (positive = the timestamp is in the past). `suggested_unit` is set only when a
45
+ # *pinned* `unit:` is what pushed the timestamp out of the window (see .mismatched_unit,
46
+ # PRO-3142) — nil for a genuine replay, which is what separates the Lob outage (os-app#5128)
47
+ # from a real stale delivery. Under the default `unit: AUTO` it is nil essentially always.
48
+ Check = Data.define(:ok, :reason, :skew, :suggested_unit) do
49
+ def ok? = ok
50
+ end
51
+
52
+ # The last two belong to `verify :basic_auth`, which rejects for reasons that have nothing to
53
+ # do with a signature. Keeping them out would stamp every Basic-auth rejection
54
+ # `:signature_mismatch` — the exact misdirection PRO-3141 added `reason` to end, on endpoints
55
+ # where no signature exists.
56
+ REASONS = %i[
57
+ replay_window replay_timestamp_invalid signature_missing signature_mismatch
58
+ credentials_missing credentials_mismatch
59
+ ].freeze
60
+
61
+ OK = Check.new(ok: true, reason: nil, skew: nil, suggested_unit: nil).freeze
62
+
63
+ # The verdict a bare falsey return from a custom `verify` block is read as — it rejected the
64
+ # signature without saying more, which is exactly :signature_mismatch.
65
+ MISMATCH = Check.new(ok: false, reason: :signature_mismatch, skew: nil, suggested_unit: nil).freeze
66
+
67
+ # "The request carried no signature at all", for a custom `verify` block to return in place of
68
+ # MISMATCH. Exported because that distinction is only available to a verifier that reads the
69
+ # header itself: a bare falsey return collapses to :signature_mismatch, which on a guessable
70
+ # public path buries the alertable case (a rotated secret, or a URL we rebuild wrong) under
71
+ # ordinary unsigned scanner traffic. `:hmac` reports it via hmac_check below.
72
+ SIGNATURE_MISSING = Check.new(ok: false, reason: :signature_missing, skew: nil, suggested_unit: nil).freeze
73
+
74
+ # `verify :basic_auth`'s two verdicts. CREDENTIALS_MISSING covers both "no Authorization at
75
+ # all" and "an Authorization that isn't Basic", but only the second reaches Verify over HTTP:
76
+ # the first is the bare handshake leg, which Endpoint answers with the challenge before
77
+ # verifying (PRO-3148, and see BasicAuth#challenge_required?).
78
+ CREDENTIALS_MISSING = Check.new(ok: false, reason: :credentials_missing, skew: nil, suggested_unit: nil).freeze
79
+ CREDENTIALS_MISMATCH = Check.new(ok: false, reason: :credentials_mismatch, skew: nil, suggested_unit: nil).freeze
80
+
81
+ module_function
82
+
83
+ # Verify a candidate signature header against the HMAC of `payload`.
84
+ # `signature` may hold several whitespace/comma-separated candidates (key rotation);
85
+ # returns true if ANY matches. Never raises on hostile input.
86
+ # rubocop:disable Naming/PredicateMethod -- it IS a predicate, but `hmac` is the documented
87
+ # public entry point (README, every direct caller); renaming it to `hmac?` is a breaking change.
88
+ def hmac(secret:, payload:, signature:, digest: :sha256, encoding: :hex, prefix: nil,
89
+ timestamp: nil, tolerance: NO_TOLERANCE, now: nil, unit: AUTO)
90
+ hmac_check(secret:, payload:, signature:, digest:, encoding:, prefix:, timestamp:, tolerance:, now:, unit:).ok?
91
+ end
92
+ # rubocop:enable Naming/PredicateMethod
93
+
94
+ # Same check as `hmac`, but returns a Check naming WHY a rejection happened rather than a
95
+ # bare false. `hmac` is this method's `.ok?`, so the replay window lives in exactly one
96
+ # place and every caller (both built-in verifiers, and `Signature.hmac` itself) agrees.
97
+ def hmac_check(secret:, payload:, signature:, digest: :sha256, encoding: :hex, prefix: nil,
98
+ timestamp: nil, tolerance: NO_TOLERANCE, now: nil, unit: AUTO)
99
+ # Validate unit: unconditionally — a misconfigured unit: is a config error independent of
100
+ # whether replay protection is active or the request happens to carry a signature.
101
+ validate_unit!(unit)
102
+ tolerance = validate_tolerance!(tolerance)
103
+
104
+ if tolerance
105
+ now ||= Time.now
106
+ drift = skew(timestamp:, now:, unit:)
107
+ return rejected(:replay_timestamp_invalid) if drift.nil?
108
+
109
+ if drift.abs > tolerance.to_i
110
+ # Only asked on the rejection path — it re-runs the window against each other scale.
111
+ return rejected(:replay_window, skew: drift,
112
+ suggested_unit: mismatched_unit(timestamp:, tolerance:, now:, unit:))
113
+ end
114
+ end
115
+
116
+ return SIGNATURE_MISSING if signature.nil? || signature.to_s.empty?
117
+
118
+ expected = compute(secret:, payload:, digest:, encoding:)
119
+ return OK if candidates(signature, prefix:).any? { |candidate| secure_compare(candidate, expected) }
120
+
121
+ rejected(:signature_mismatch)
122
+ end
123
+
124
+ # The encoded expected signature for `payload`. Reused by outbound's Signer::StandardWebhooksSigner.
125
+ def compute(secret:, payload:, digest: :sha256, encoding: :hex)
126
+ # A blank secret is a WEAK KEY, not a failure: "" is a legal HMAC key, so the digest it
127
+ # produces is one any stranger can compute. Guarded at this chokepoint — the lowest layer
128
+ # every signing and verification path funnels through — so the public primitive is safe on
129
+ # its own, not merely when reached via a strategy that happens to check first. The README's
130
+ # own example passes `ENV["WEBHOOK_SECRET"]` straight in, and a set-but-empty env var is
131
+ # routine in k8s ConfigMaps and CI. Never interpolates the value.
132
+ raise ArgumentError, "secret must be a non-empty String (got #{secret.is_a?(String) ? 'an empty String' : secret.class})" \
133
+ unless secret.is_a?(String) && !secret.empty?
134
+
135
+ raw = OpenSSL::HMAC.digest(openssl_digest(digest), secret, payload.to_s)
136
+ encode(raw, encoding)
137
+ end
138
+
139
+ # Constant-time comparison. False (never raises) on nil or length mismatch.
140
+ def secure_compare(candidate, expected)
141
+ return false if candidate.nil? || expected.nil?
142
+ return false unless candidate.bytesize == expected.bytesize
143
+
144
+ OpenSSL.fixed_length_secure_compare(candidate, expected)
145
+ end
146
+
147
+ # True when `timestamp` is present, parseable, and within ±tolerance seconds of `now`.
148
+ def within_tolerance?(timestamp:, tolerance:, now: nil, unit: AUTO)
149
+ # `tolerance:` is required here, so there is no "omitted" case to honor — any blank value is
150
+ # an explicit one, and `nil.to_i` silently collapsing the window to 0 is the same
151
+ # coerce-instead-of-reject shape the audit flagged elsewhere. Fails closed today (0 rejects
152
+ # nearly everything) rather than open, but it should say so rather than pretend.
153
+ validate_tolerance!(tolerance)
154
+
155
+ drift = skew(timestamp:, now:, unit:)
156
+ !drift.nil? && drift.abs <= tolerance
157
+ end
158
+
159
+ # Seconds between `now` and `timestamp`, signed (positive = `timestamp` is in the past).
160
+ # nil when the timestamp is absent or unparseable — a distinct condition from "far away",
161
+ # which is why the two get separate rejection reasons. Goes through coerce_epoch, so `unit:`
162
+ # (including AUTO's per-timestamp inference) applies here exactly as it does to the window.
163
+ def skew(timestamp:, now: nil, unit: AUTO)
164
+ epoch = coerce_epoch(timestamp, unit)
165
+ return nil if epoch.nil?
166
+
167
+ (now || Time.now).to_i - epoch
168
+ end
169
+
170
+ # Diagnostic: the unit that WOULD have put `timestamp` inside the window, when `unit` didn't.
171
+ # nil when `unit` already fits, when the timestamp is missing/unparseable, or when no scale
172
+ # rescues it -- i.e. nil for a genuine replay, a symbol for a misconfigured `unit:`. Pure;
173
+ # logging and failure-reason classification belong to the caller.
174
+ def mismatched_unit(timestamp:, tolerance:, now: nil, unit: AUTO)
175
+ validate_unit!(unit)
176
+ return nil if within_tolerance?(timestamp:, tolerance:, now:, unit:)
177
+
178
+ CANONICAL_UNITS.find do |candidate|
179
+ candidate != unit && within_tolerance?(timestamp:, tolerance:, now:, unit: candidate)
180
+ end
181
+ end
182
+
183
+ def rejected(reason, skew: nil, suggested_unit: nil) = Check.new(ok: false, reason:, skew:, suggested_unit:)
184
+ private_class_method :rejected
185
+
186
+ def openssl_digest(digest)
187
+ DIGESTS.fetch(digest) { raise ArgumentError, "unsupported digest: #{digest.inspect}" }
188
+ end
189
+ private_class_method :openssl_digest
190
+
191
+ def encode(raw, encoding)
192
+ case encoding
193
+ when :hex then raw.unpack1("H*")
194
+ when :base64 then Base64.strict_encode64(raw)
195
+ when :base64_urlsafe then Base64.urlsafe_encode64(raw)
196
+ else raise ArgumentError, "unsupported encoding: #{encoding.inspect}"
197
+ end
198
+ end
199
+ private_class_method :encode
200
+
201
+ # Splits a signature header on whitespace and commas. Phase 2's :standard_webhooks preset
202
+ # sends v1,<sig> version-tagged candidates; callers must deliberately strip the v1, tag
203
+ # before this splitter to avoid splitting v1,<sig> into two tokens.
204
+ def candidates(signature, prefix:)
205
+ signature.to_s.split(/[\s,]+/).reject(&:empty?).map do |token|
206
+ if prefix
207
+ token.start_with?(prefix) ? token.delete_prefix(prefix) : nil
208
+ else
209
+ token
210
+ end
211
+ end.compact
212
+ end
213
+ private_class_method :candidates
214
+
215
+ def coerce_epoch(timestamp, unit)
216
+ validate_unit!(unit)
217
+
218
+ case timestamp
219
+ when Time then timestamp.to_i
220
+ when Integer then timestamp / divisor_for(timestamp, unit)
221
+ when String then (coerce_epoch(Integer(timestamp, 10), unit) if timestamp.match?(/\A-?\d+\z/))
222
+ end
223
+ end
224
+ private_class_method :coerce_epoch
225
+
226
+ # A fixed unit's divisor is a constant; :auto's depends on the value being converted, so this
227
+ # takes the value rather than resolving off the unit alone.
228
+ def divisor_for(value, unit)
229
+ return UNITS.fetch(unit) unless unit == AUTO
230
+
231
+ case value.abs
232
+ when 0...AUTO_MS_FLOOR then 1
233
+ when AUTO_MS_FLOOR...AUTO_US_FLOOR then 1_000
234
+ else 1_000_000
235
+ end
236
+ end
237
+ private_class_method :divisor_for
238
+
239
+ # Raises on an unrecognized unit. Called eagerly by `hmac` (independent of whether replay
240
+ # protection is active or the request carries a signature), so a misconfigured `unit:` is a
241
+ # loud config error rather than a silent 401.
242
+ # Distinguishes "caller omitted tolerance:" (no replay check — the documented default, and
243
+ # what `Signature.hmac(secret:, payload:, signature:)` relies on) from "caller PASSED a blank
244
+ # tolerance". The latter is a value that came from somewhere — `ENV["TOLERANCE"]&.to_i` on an
245
+ # unset var is the shape — and silently turning replay protection OFF for it is the one place
246
+ # this gem failed open where everything else fails closed (security audit).
247
+ #
248
+ # Same stance `unit:` already takes: default only on absence, never on an explicit blank.
249
+ NO_TOLERANCE = Object.new.freeze
250
+
251
+ def validate_tolerance!(tolerance)
252
+ return nil if tolerance.equal?(NO_TOLERANCE)
253
+ return tolerance if tolerance.is_a?(Numeric) && tolerance.positive?
254
+
255
+ raise ArgumentError,
256
+ "tolerance must be a positive number of seconds, or omitted entirely to skip the " \
257
+ "replay window (got #{tolerance.inspect})"
258
+ end
259
+
260
+ def validate_unit!(unit)
261
+ return if unit == AUTO || UNITS.key?(unit)
262
+
263
+ raise ArgumentError, "unsupported unit: #{unit.inspect}"
264
+ end
265
+ private_class_method :validate_unit!
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ # The static_respond stage as an Axn: runs the endpoint's static_respond block — which reads
6
+ # no handler result, unlike Respond — to build a Response. Built as an Axn so a raise inside
7
+ # the (user-supplied) block is reported once via on_exception and mapped to a 500 by
8
+ # Endpoint#default_ack, never an unhandled exception escaping the HTTP mapper.
9
+ class StaticRespond
10
+ include Axn
11
+ include Axn::Webhooks::VendorFacet
12
+
13
+ expects :responder
14
+ exposes :response, type: Axn::Webhooks::Response
15
+ error "Webhook static_respond failed"
16
+
17
+ def call
18
+ expose response: Inbound::RespondContext.new.instance_exec(&responder)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ # Included by each pipeline Axn (ChallengeRequired/Verify/Dispatch/Respond/Challenge) to stamp the endpoint's
6
+ # registered vendor name onto the pipeline as the configured observability facet
7
+ # (Axn::Webhooks.config.vendor_facet). See internal-docs/plans/2026-07-18-axn-webhooks-inbound-
8
+ # phase-5.md, Decision B, for why both facets are declared unconditionally: `dimension`/`tag`
9
+ # are one-time class-level declarations, but the facet TYPE is a live runtime setting and the
10
+ # vendor name is per-endpoint — so each resolver reads the live setting fresh, per call, and
11
+ # "claims" the vendor value only for the currently-selected facet type. A resolver returning nil
12
+ # makes Axn::Core::Tagging.resolve omit that facet entirely, so at most one of {dimension, tag}
13
+ # is ever actually stamped.
14
+ module VendorFacet
15
+ def self.included(base)
16
+ base.class_eval do
17
+ expects :vendor, allow_blank: true, default: nil
18
+
19
+ dimension :vendor, -> { vendor if Axn::Webhooks.config.vendor_facet == :dimension }
20
+ tag :vendor, -> { vendor if Axn::Webhooks.config.vendor_facet == :tag }
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module Axn
6
+ module Webhooks
7
+ module Verifiers
8
+ # HTTP Basic auth (RFC 7617) as a verify strategy.
9
+ #
10
+ # Unlike the signature strategies, Basic auth is a two-legged protocol: a client that does
11
+ # NOT authenticate preemptively sends its first request with no `Authorization` header,
12
+ # expects a 401 carrying `WWW-Authenticate: Basic realm="…"`, and only then repeats the
13
+ # request with credentials. Twilio documents exactly this behaviour for webhook URLs, and it
14
+ # is why this strategy is a class rather than a bare lambda: `#unauthorized_headers` is what
15
+ # Endpoint#to_response attaches to the 401, and without it the second leg never happens and
16
+ # every webhook is silently dropped.
17
+ class BasicAuth
18
+ DEFAULT_REALM = "Webhook"
19
+
20
+ # A control character can't be represented in an RFC 7230 quoted-string at all, and CR/LF
21
+ # would split the response header outright. Rejected at declaration time (this runs inside
22
+ # `Axn::Webhooks.inbound`, i.e. at boot) rather than silently scrubbed: a realm is developer
23
+ # config, so a typo should fail the deploy, not ship a subtly malformed challenge.
24
+ CONTROL_CHARS = /[\x00-\x1F\x7F]/
25
+
26
+ def initialize(username:, password:, realm: DEFAULT_REALM)
27
+ raise Axn::Webhooks::Error, "verify :basic_auth realm cannot contain control characters" if realm.to_s.match?(CONTROL_CHARS)
28
+
29
+ @username = username
30
+ @password = password
31
+ @realm = realm.to_s
32
+ end
33
+
34
+ attr_reader :realm
35
+
36
+ # A verifier holds credentials by definition, and Verify's per-call logging renders its
37
+ # `verifier:` input — so the default Object#inspect would put the plaintext password in the
38
+ # application log on every single request. Same treatment Request gets, and for the same
39
+ # reason. The realm is safe (and useful) to show: it's already broadcast in the challenge.
40
+ def inspect = "#<#{self.class.name} realm=#{realm.inspect} credentials=[REDACTED]>"
41
+
42
+ # PP does not route through #inspect — Kernel#pretty_print walks instance variables
43
+ # directly — so without this `pp verifier` leaks exactly what #inspect just redacted.
44
+ def pretty_print(printer) = printer.text(inspect)
45
+
46
+ def call(request)
47
+ # Fail closed on a misconfigured deploy rather than comparing against "": an unset
48
+ # credential pair would otherwise authenticate `Authorization: Basic Og==` for anyone.
49
+ # Blank-but-present counts as missing — CI and secret managers can both set an empty
50
+ # string. Raising (not returning false) makes it a reported exception, since a 401 that
51
+ # means "we are misconfigured" is indistinguishable from one that means "you are not
52
+ # Twilio" and would otherwise present as an unexplained outage.
53
+ #
54
+ # Guarded BEFORE any #to_s (security audit): coercing first let non-Strings through, and
55
+ # `false.to_s` is the non-empty String "false" — so a `false` credential pair sailed past
56
+ # an emptiness check and collapsed the login to the guessable constant `false:false`.
57
+ expected_username = Verifiers.require_secret!("verify :basic_auth", Resolvers.resolve(@username, request), label: "username")
58
+ expected_password = Verifiers.require_secret!("verify :basic_auth", Resolvers.resolve(@password, request), label: "password")
59
+
60
+ username, password = credentials(request)
61
+ # Absent/non-Basic credentials are reported apart from wrong ones. Under RFC 7617 a bare
62
+ # first request is the handshake working, not a failure, so it's the highest-volume
63
+ # rejection on a healthy endpoint — conflating it with a real credential problem would
64
+ # bury the latter in expected traffic.
65
+ return Signature::CREDENTIALS_MISSING unless username
66
+
67
+ # `&` rather than `&&` so the comparison doesn't short-circuit on the username.
68
+ matched = secure_compare(username, expected_username) & secure_compare(password, expected_password)
69
+ matched ? Signature::OK : Signature::CREDENTIALS_MISMATCH
70
+ end
71
+
72
+ # Is this request an authentication attempt at all? False for anything carrying an
73
+ # `Authorization` header — including a non-Basic scheme, which is a client that meant to
74
+ # authenticate and got it wrong, and stays a visible `:credentials_missing` rejection.
75
+ #
76
+ # True only for the bare first leg of the RFC 7617 handshake, which Endpoint answers with
77
+ # the challenge instead of running Verify: there is nothing to verify, and a reactive client
78
+ # sends one of these per *successful* webhook, so recording them as verify failures made the
79
+ # highest-volume outcome on a healthy endpoint a failure (PRO-3148).
80
+ #
81
+ # A blank header counts as absent — an empty `Authorization` presents no credentials and no
82
+ # scheme, so treating it as an attempt would 401 it with no telemetry either way.
83
+ def challenge_required?(request) = request.header("Authorization").to_s.strip.empty?
84
+
85
+ # The RFC 7617 challenge. Lower-cased key per Rack 3's response-header SPEC (Response
86
+ # lower-cases keys anyway; spelled that way here so the two agree on sight).
87
+ #
88
+ # The realm is an RFC 7230 quoted-string, so `"` and `\` are escaped rather than stripped —
89
+ # the realm a client displays should be the one that was configured. Stripping only quotes
90
+ # would leave a trailing backslash escaping the closing quote (`realm="Partner\"`), which
91
+ # is malformed enough that a client may reject the challenge and never retry — recreating
92
+ # the exact silent-drop failure this strategy exists to prevent.
93
+ def unauthorized_headers
94
+ { "www-authenticate" => %(Basic realm="#{realm.gsub(/([\\"])/, '\\\\\1')}") }
95
+ end
96
+
97
+ private
98
+
99
+ # [username, password], or nil when the header is absent or not a Basic credential —
100
+ # which is the normal, expected shape of a reactive client's first request.
101
+ def credentials(request)
102
+ scheme, encoded = request.header("Authorization").to_s.split(" ", 2)
103
+ return nil unless scheme&.casecmp?("Basic")
104
+
105
+ # split(":", 2) — a password may legitimately contain colons; a username may not.
106
+ username, password = Base64.decode64(encoded.to_s).split(":", 2)
107
+ [username.to_s, password.to_s]
108
+ end
109
+
110
+ # Hash first, then compare the two fixed-width digests — so the comparison itself is
111
+ # constant-time AND independent of credential length. The obvious alternative, the bytesize
112
+ # precheck in Signature#secure_compare, is fine for signatures (fixed width by construction)
113
+ # but would answer "is the password N characters long?" here, where both sides are
114
+ # arbitrary user-chosen strings. Same construction as ActiveSupport::SecurityUtils.
115
+ def secure_compare(candidate, expected)
116
+ OpenSSL.fixed_length_secure_compare(
117
+ OpenSSL::Digest::SHA256.digest(candidate),
118
+ OpenSSL::Digest::SHA256.digest(expected),
119
+ )
120
+ end
121
+ end
122
+
123
+ register(:basic_auth) do |username:, password:, realm: BasicAuth::DEFAULT_REALM|
124
+ BasicAuth.new(username:, password:, realm:)
125
+ end
126
+ end
127
+ end
128
+ end