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,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
|