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,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ module Outbound
6
+ # A resolved fan-out target: a URL plus an optional subscriber identity. `id` is what survives
7
+ # the round trip through `Deliver`'s `call_async` re-enqueue (see `deliver.rb`'s
8
+ # `subscriber_id` expects) — it is NEVER a secret/token, which would otherwise sit plaintext in
9
+ # the queue backend for the life of a retry chain. Credentials are resolved per attempt from
10
+ # this identity instead (see Signer's `subscriber:` kwarg).
11
+ Subscriber = Data.define(:url, :id) do
12
+ def initialize(url:, id: nil)
13
+ super
14
+ end
15
+
16
+ class << self
17
+ # Normalizes whatever a static `to:` Array entry or a `subscribers`/`to:` lambda returned:
18
+ # already a Subscriber -> passed through; a bare String -> today's shape, `id: nil`; a Hash
19
+ # (Symbol or String keys) -> must include `:url`, may include `:id` (stringified so a
20
+ # caller can hand back an ActiveRecord id directly). Anything else -- including an unknown
21
+ # Hash key, e.g. `{ url:, secret: }` -- raises loudly rather than silently dropping a
22
+ # field the caller thought they were setting (Codex-style finding this design heads off).
23
+ def coerce(raw)
24
+ case raw
25
+ when Subscriber then coerce_subscriber(raw)
26
+ when String then new(url: raw, id: nil)
27
+ when Hash then coerce_hash(raw)
28
+ else
29
+ raise Axn::Webhooks::InvalidTarget,
30
+ "must be a String URL or a Hash (got #{raw.class})"
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # A resolver may construct a Subscriber directly (`Subscriber.new(url:, id: some_record.id)`)
37
+ # rather than going through the Hash path -- and unlike `coerce_hash`'s normalization, a
38
+ # bare `Subscriber.new` applies none. An Integer id would then reach `Emit` as
39
+ # `subscriber_id` and fail `Deliver`'s `expects :subscriber_id, type: String` despite
40
+ # passing every check here (Codex P2 finding). Returns the SAME object when its id is
41
+ # already a valid String, so the existing "passed through unchanged" identity contract
42
+ # holds for the common case -- but still runs it through the SAME encoding validation
43
+ # `normalize_id` applies, so a prebuilt Subscriber isn't a back door around it.
44
+ def coerce_subscriber(raw)
45
+ return raw if raw.id.nil?
46
+
47
+ if raw.id.is_a?(String)
48
+ validate_id_encoding!(raw.id)
49
+ return raw
50
+ end
51
+
52
+ new(url: raw.url, id: normalize_id(raw.id))
53
+ end
54
+
55
+ def coerce_hash(raw)
56
+ # A key that isn't a Symbol/String (e.g. an Integer, from a raw DB row map) has no
57
+ # #to_sym -- letting `to_sym` raise a bare NoMethodError here would propagate past
58
+ # `resolve_subscribers`'s per-row `rescue Axn::Webhooks::InvalidTarget`, aborting the
59
+ # WHOLE fan-out instead of rejecting just this one malformed row (Codex P2 finding).
60
+ #
61
+ # Named by CLASS only, never `.inspect` -- a plain Integer key is safe to show verbatim,
62
+ # but a resolver mistake could just as easily use a COMPOUND object as a key (e.g. a
63
+ # malformed `.to_h` transform keying by the record itself). That object's own #inspect
64
+ # would otherwise render into this message, which `resolve_subscribers` stores verbatim
65
+ # as a rejection's `:reason` -- an ActiveRecord-like model's #inspect commonly includes
66
+ # every attribute, secrets included (Codex P1 finding, round 13).
67
+ non_symbolizable = raw.keys.reject { |k| k.is_a?(Symbol) || k.is_a?(String) }
68
+ raise Axn::Webhooks::InvalidTarget, "Hash has non-Symbol/String key(s): #{non_symbolizable.map(&:class).inspect}" if non_symbolizable.any?
69
+
70
+ symbolized = begin
71
+ raw.to_h { |k, v| [k.to_sym, v] }
72
+ rescue EncodingError
73
+ # The check above only rejects a key that ISN'T a Symbol/String -- but a String CAN
74
+ # still fail `#to_sym` if it has an invalid encoding (a malformed byte sequence), even
75
+ # though it passes that "is a String" check. Letting THAT raise a bare EncodingError
76
+ # here would propagate past `resolve_subscribers`'s per-row `rescue
77
+ # Axn::Webhooks::InvalidTarget`, aborting the WHOLE fan-out instead of rejecting just
78
+ # this one malformed row (Codex P2 finding, round 22).
79
+ raise Axn::Webhooks::InvalidTarget, "Hash has a key with an invalid encoding"
80
+ end
81
+ unknown = symbolized.keys - %i[url id]
82
+ unknown_desc = unknown.map { |k| safe_key_name(k) }.join(", ")
83
+ raise Axn::Webhooks::InvalidTarget, "Hash has unknown key(s): [#{unknown_desc}]" if unknown.any?
84
+ # Names only key NAMES (matching the "unknown key(s)" message above), never `raw` itself
85
+ # -- this only reaches here when every key IS :url/:id (any other key is already
86
+ # caught, safely, above), but an :id VALUE isn't constrained to a simple scalar. A
87
+ # plausible mistake (passing the whole record instead of `record.id`) would otherwise
88
+ # have `raw.inspect` render that object's full #inspect verbatim (Codex P1 finding).
89
+ raise Axn::Webhooks::InvalidTarget, "Hash must include :url (keys present: #{symbolized.keys.inspect})" unless symbolized.key?(:url)
90
+
91
+ new(url: symbolized[:url], id: normalize_id(symbolized[:id]))
92
+ end
93
+
94
+ # `:id` is identity, never a credential -- but that's a PROMISE about what this field is
95
+ # FOR, not a guarantee about what a resolver actually hands back. A plausible mistake
96
+ # (passing the whole record instead of `record.id`, or a Hash like `{ token: "live-key" }`)
97
+ # used to be silently accepted by an unconditional `&.to_s` -- which for a Hash/Struct
98
+ # commonly renders every field verbatim (`Hash#to_s`/`Struct#to_s` are NOT safe-by-default
99
+ # the way a bare `Object#to_s` is). That string becomes `subscriber_id`, which isn't just
100
+ # log/rejection-message text: it's persisted in every async job payload (the exact channel
101
+ # this whole design exists to keep credential-free), exposed via `result.deliveries`, and
102
+ # stamped as an observability tag (Codex P1 finding, round 26). Only the documented scalar
103
+ # shapes are accepted; anything else raises rather than silently embedding its contents.
104
+ def normalize_id(id)
105
+ return nil if id.nil?
106
+ return validate_id_encoding!(id) if id.is_a?(String)
107
+
108
+ case id
109
+ when Integer, Symbol
110
+ validate_id_encoding!(id.to_s)
111
+ else
112
+ raise Axn::Webhooks::InvalidTarget, "id must be a String, Integer, or Symbol (got #{id.class})"
113
+ end
114
+ end
115
+
116
+ # A String `:id` passed through unchanged by every branch above -- `String#to_s` returns
117
+ # `self`, so an invalid byte sequence would otherwise reach `Deliver` untouched. `Emit`
118
+ # forwards it as `subscriber_id`, and a JSON-backed async adapter (Sidekiq) raises
119
+ # `JSON::GeneratorError` while SERIALIZING the enqueue payload -- an unexpected exception
120
+ # far from this validation, aborting the whole `emit` rather than rejecting just this one
121
+ # malformed row (Codex P2 finding, round 26).
122
+ def validate_id_encoding!(str)
123
+ raise Axn::Webhooks::InvalidTarget, "id has an invalid encoding" unless str.valid_encoding?
124
+
125
+ str
126
+ end
127
+
128
+ # A plausible field-name typo (`:secret`, `:api_key`, `:token` -- the "unknown key(s)"
129
+ # message exists to surface exactly this) is a short, simple identifier. `to_sym` already
130
+ # ran unconditionally over EVERY key by the time this method sees them (round 13's
131
+ # non-Symbol/String class-only fix doesn't apply here -- these keys already ARE Symbols),
132
+ # so a resolver mistake keying its row by a URL String instead of `url:` (a plausible
133
+ # `.to_h { |row| [row.url, row.id] }` bug) becomes a Symbol too, and echoing it verbatim
134
+ # would render the whole URL -- credentials commonly embedded in it included (Codex P1
135
+ # finding, round 20). Only a key matching this shape is safe to show as-is.
136
+ SAFE_KEY_NAME = /\A[A-Za-z_][A-Za-z0-9_]{0,49}\z/
137
+ private_constant :SAFE_KEY_NAME
138
+
139
+ def safe_key_name(key)
140
+ # `#match?` itself can raise (`Encoding::CompatibilityError`/`ArgumentError`) for a
141
+ # String/Symbol in an unexpected encoding -- treated as "not a safe name" here, same as
142
+ # every other malformed-input path in this file: never let a rejection-message helper
143
+ # become the thing that raises past `resolve_subscribers`'s rescue.
144
+ key.to_s.match?(SAFE_KEY_NAME) ? key.inspect : "<redacted>"
145
+ rescue Encoding::CompatibilityError, ArgumentError
146
+ "<redacted>"
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Axn
6
+ module Webhooks
7
+ module Outbound
8
+ # The single place a resolved subscriber row is validated -- shared by `Config`'s boot-time
9
+ # check of a static `to:` Array and `Config#resolve_subscribers`'s per-emission check of
10
+ # whatever `subscribers`/`to:` resolved to at runtime, so the two paths can never drift apart
11
+ # (they used to: only the static path was checked; see this file's origin,
12
+ # `Config#validate_url!`).
13
+ #
14
+ # `allowed_hosts`/`allow_url` are a HOST policy, not a network one: neither resolves DNS, so
15
+ # neither is proof against DNS rebinding or a hostname that resolves to a private IP at request
16
+ # time. `allow_url` is handed the parsed URI so an app that needs that guarantee can add its
17
+ # own resolution check there.
18
+ module TargetPolicy
19
+ module_function
20
+
21
+ def check!(raw, allowed_hosts: nil, allow_url: nil)
22
+ subscriber = Subscriber.coerce(raw)
23
+ uri = parse_url!(subscriber.url)
24
+ check_host_allowlist!(uri, allowed_hosts)
25
+ check_allow_url!(uri, allow_url)
26
+ snapshot(subscriber)
27
+ end
28
+
29
+ # A runtime `subscribers`/`to:` resolver may hand back a `Subscriber` it keeps its own
30
+ # reference to -- `Subscriber.coerce`'s Subscriber branch returns that SAME object when its
31
+ # `id` is already normalized, unlike a Hash/String row (which always produces a fresh one).
32
+ # Config's boot-time `deep_freeze!`/`config_owned` never runs over this path (it only covers
33
+ # a static `to:` Array), so nothing stops the caller from mutating `url`/`id` IN PLACE after
34
+ # this method has already validated them but before `Emit`'s fan-out reads them to actually
35
+ # deliver -- swapping in a URL that was never checked at all (Codex P2 finding, round 11).
36
+ # Dup+freezing fresh copies here closes that window regardless of what the caller does next.
37
+ #
38
+ # Called LAST, only once `parse_url!` has already confirmed `url` is a String -- calling
39
+ # this BEFORE that check (as an earlier version did) ran `.dup` on whatever a malformed row
40
+ # handed back verbatim; a non-duplicable object there (`Thread.current` -- anything without
41
+ # an allocator behaves the same) raises a bare TypeError, which `Config#check_targets`'s
42
+ # `rescue Axn::Webhooks::InvalidTarget` never catches -- aborting the WHOLE fan-out instead
43
+ # of rejecting just the one malformed row (Codex P2 finding, round 16). `id` needs no such
44
+ # ordering care: `Subscriber.coerce` already stringifies it (or leaves it nil) in every
45
+ # branch, so it's always dup-safe by the time ANY code here runs.
46
+ def snapshot(subscriber)
47
+ Subscriber.new(url: subscriber.url.dup.freeze, id: subscriber.id&.dup&.freeze)
48
+ end
49
+
50
+ def parse_url!(url)
51
+ # A non-String (e.g. a `URI` object) would parse fine here via `#to_s`, but the ORIGINAL
52
+ # object is what a static `to:` entry keeps in `@events` and what `Deliver` is later handed
53
+ # as `url:` (`expects :url, type: String`) -- accepted here, rejected at delivery time
54
+ # instead. Require a String outright rather than normalizing.
55
+ raise Axn::Webhooks::InvalidTarget, "URL must be a String (got #{url.class})" unless url.is_a?(String)
56
+
57
+ uri = URI.parse(url)
58
+ raise Axn::Webhooks::InvalidTarget, "URL #{redact_url(url)} must be http(s)" unless http_uri?(uri)
59
+
60
+ uri
61
+ rescue URI::Error
62
+ # `URI.parse` doesn't raise ONLY `URI::InvalidURIError` for a malformed URL -- a
63
+ # scheme-specific parser can raise a SIBLING class instead (e.g.
64
+ # `URI::InvalidComponentError` for `"mailto:foo"`, which has no `@`); that class is NOT a
65
+ # subclass of `InvalidURIError`, so rescuing only the latter let it escape uncaught past
66
+ # `Config#check_targets`'s `rescue Axn::Webhooks::InvalidTarget`, aborting the WHOLE
67
+ # fan-out instead of rejecting just the one malformed row (Codex P2 finding, round 17).
68
+ # `URI::Error` is the common ancestor of every URI parse failure.
69
+ raise Axn::Webhooks::InvalidTarget, "URL #{redact_url(url)} is not a valid URL"
70
+ end
71
+
72
+ def http_uri?(uri) = %w[http https].include?(uri.scheme) && !uri.host.to_s.empty?
73
+
74
+ def check_host_allowlist!(uri, allowed_hosts)
75
+ return if allowed_hosts.nil?
76
+
77
+ return if allowed_hosts.any? { |pattern| host_matches?(pattern, uri.host) }
78
+
79
+ raise Axn::Webhooks::InvalidTarget, "host #{uri.host.inspect} is not allowed (allowed_hosts: #{allowed_hosts.inspect})"
80
+ end
81
+
82
+ # A bare entry is an exact (case-insensitive) host match; a `*.suffix` entry matches any
83
+ # subdomain of `suffix` but NOT the bare suffix itself -- standard wildcard-cert semantics,
84
+ # so `*.customer.example` doesn't accidentally also allow the apex domain.
85
+ def host_matches?(pattern, host)
86
+ return false if host.nil?
87
+
88
+ if pattern.start_with?("*.")
89
+ host.downcase.end_with?(".#{pattern[2..].downcase}")
90
+ else
91
+ host.casecmp?(pattern)
92
+ end
93
+ end
94
+
95
+ # `allow_url` is always called with the parsed URI (arity 1 required at boot — see Config's
96
+ # setting validator — matching the same "the callable's whole purpose is examining its
97
+ # argument" precedent `backoff` already sets, rather than the zero-OR-one tolerance
98
+ # `user_agent`/a signing `secret` get).
99
+ def check_allow_url!(uri, allow_url)
100
+ return if allow_url.nil?
101
+
102
+ raise Axn::Webhooks::InvalidTarget, "URL #{redact_url(uri.to_s)} was rejected by allow_url" unless allow_url.call(uri)
103
+ end
104
+
105
+ # A webhook URL commonly carries a credential ITSELF -- HTTP Basic userinfo
106
+ # (`https://user:pass@host/...`), a signed/token query param, or -- the most common real
107
+ # shape (Slack/Discord/Teams incoming webhooks are exactly this) -- a secret token AS THE
108
+ # PATH, e.g. `https://hooks.example/services/T00/B00/<secret>`. There's no general way to
109
+ # tell a "meaningful, harmless" path apart from a "the path IS the secret" one, so no
110
+ # InvalidTarget message may echo more than the ORIGIN verbatim (Codex P1 finding, rounds 6
111
+ # and 8): every message above that would otherwise interpolate a URL routes through here
112
+ # first. Strips userinfo/PATH/query/fragment, keeping only scheme/host/port -- enough to
113
+ # debug which HOST was rejected and why, without ever risking a credential. Also used by
114
+ # `Config#redact_target`'s Hash-row `:url` handling, so a rejected `{ url:, id: }` row's
115
+ # own url gets the identical treatment.
116
+ def redact_url(url)
117
+ uri = URI.parse(url)
118
+ uri.user = nil
119
+ uri.password = nil
120
+ uri.path = ""
121
+ uri.query = nil
122
+ uri.fragment = nil
123
+ uri.to_s
124
+ rescue URI::Error, ArgumentError
125
+ # Same reasoning as `parse_url!`'s rescue (round 17): a scheme-specific parser can raise
126
+ # a `URI::Error` SIBLING of `InvalidURIError` (e.g. `InvalidComponentError`) that isn't
127
+ # caught by name -- and this method is called from INSIDE `parse_url!`'s own rescue
128
+ # handler to build the redacted message, so a narrower rescue here would raise a SECOND,
129
+ # uncaught exception in place of the `InvalidTarget` that call is trying to construct.
130
+ "<unparseable URL, #{url.to_s.bytesize} bytes>"
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Axn
7
+ module Webhooks
8
+ module Outbound
9
+ # The HTTP seam. Default is stdlib Net::HTTP (no runtime dependency); a consuming app may
10
+ # inject its own object responding to `.post(url:, body:, headers:)` via Outbound config.
11
+ module Transport
12
+ # `body:` defaults to nil (via the custom `initialize`) so a custom transport built against
13
+ # the pre-existing two-field shape keeps working unmodified.
14
+ Response = Data.define(:status, :headers, :body) do
15
+ def initialize(status:, headers:, body: nil)
16
+ super
17
+ end
18
+ end
19
+
20
+ # Raised by a transport for a genuinely retryable network condition. Deliver treats these
21
+ # (and 5xx/429/503) as retryable; anything else raised by a transport is an unexpected
22
+ # exception that propagates (the adapter's at-least-once crash safety net).
23
+ RETRYABLE_NETWORK_ERRORS = [
24
+ Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
25
+ Errno::ETIMEDOUT, SocketError, IOError
26
+ ].freeze
27
+
28
+ # Headers this transport owns regardless of what a caller sets. Net::HTTP rewrites both
29
+ # inside `send_request_with_body`, AFTER the caller's headers have been applied:
30
+ # Content-Length is regenerated from the body, Transfer-Encoding is deleted outright. A
31
+ # signature emitted under either name never leaves the process.
32
+ #
33
+ # This is the complete set for the built-in transport, established by observation rather
34
+ # than by reading the stdlib: `spec/.../transport_reserved_headers_spec.rb` drives a real
35
+ # socket and asserts exactly these two are clobbered while others (Host, Connection,
36
+ # Accept-Encoding, custom names) survive. The transport_spec stubs `Net::HTTP#request`, so
37
+ # it cannot see this — the rewriting happens inside the call it replaces.
38
+ RESERVED_HEADERS = %w[content-length transfer-encoding].freeze
39
+
40
+ module_function
41
+
42
+ def post(url:, body:, headers:, open_timeout: 5, read_timeout: 10)
43
+ uri = URI.parse(url)
44
+ http = Net::HTTP.new(uri.host, uri.port)
45
+ http.use_ssl = (uri.scheme == "https")
46
+ http.open_timeout = open_timeout
47
+ http.read_timeout = read_timeout
48
+
49
+ request = Net::HTTP::Post.new(uri.request_uri)
50
+ request.body = body
51
+ headers.each { |key, value| request[key] = value }
52
+
53
+ response = http.request(request)
54
+ Response.new(status: response.code.to_i, headers: response.to_hash.transform_values(&:first), body: response.body)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "outbound/callable_arity"
4
+ require_relative "outbound/subscriber"
5
+ require_relative "outbound/target_policy"
6
+ require_relative "outbound/signer"
7
+ require_relative "outbound/envelope"
8
+ require_relative "outbound/transport"
9
+ require_relative "outbound/config"
10
+ require_relative "outbound/dsl"
11
+ require_relative "outbound/deliver"
12
+ require_relative "outbound/emit"
13
+
14
+ module Axn
15
+ module Webhooks
16
+ # Process-global registration for outbound webhook emission (a single `outbound` block).
17
+ module Outbound
18
+ @config = nil
19
+ # Guards install/reset! only. `config` READS stay unsynchronized: what's published is a
20
+ # frozen Config, so a reader either sees the old one or the new one and never a half-built
21
+ # object — and `config` is read on every delivery attempt, where a lock would be real
22
+ # overhead protecting nothing.
23
+ @mutex = Mutex.new
24
+
25
+ class << self
26
+ def install(config)
27
+ @mutex.synchronize do
28
+ unless @config.nil?
29
+ Axn.config.logger.warn(
30
+ "[axn-webhooks] a second `Axn::Webhooks.outbound` block replaces the first — only one outbound declaration is active at a time",
31
+ )
32
+ end
33
+
34
+ @config = config
35
+ end
36
+ end
37
+
38
+ def reset! = @mutex.synchronize { @config = nil }
39
+
40
+ def config
41
+ @config || raise(Axn::Webhooks::Error, "no `outbound` block declared — call Axn::Webhooks.outbound { … } at boot")
42
+ end
43
+ end
44
+ end
45
+
46
+ # Declare outbound emission. Evaluated at boot (e.g. a Rails initializer).
47
+ def self.outbound(&block)
48
+ raise ArgumentError, "Axn::Webhooks.outbound requires a block" unless block
49
+
50
+ dsl = Outbound::DSL.new
51
+ dsl.instance_exec(&block)
52
+ Outbound.install(dsl.__config__)
53
+ end
54
+
55
+ # Emit an outbound webhook event. Fans out one signed, self-retrying delivery per subscriber.
56
+ # Raises loudly (Axn::Webhooks::Error) on an unknown event.
57
+ #
58
+ # `vendor:` is deliberately NOT resolved here: `Config#vendor_for` raises the same unknown-event
59
+ # error that `Emit` itself already raises internally (via `config.wire_type`), but resolving it
60
+ # ahead of `call!` would raise before axn's executor ever runs -- bypassing `on_exception`
61
+ # reporting for what should be a loud, REPORTED failure (Codex P2 finding). `Emit` resolves its
62
+ # own vendor once it's running inside that boundary.
63
+ #
64
+ # `to:` and `async:` are per-call overrides. `to:` REPLACES the event's declared targets for
65
+ # this call only (never merges) — the event must still be declared, since it supplies the wire
66
+ # `type` and `vendor`. `async: true` requires a configured adapter and raises without one;
67
+ # `async: false` forces the inline path. Omitted means today's `:auto`.
68
+ # rubocop:disable-next Naming/MethodParameterName
69
+ def self.emit(event, data: {}, to: nil, async: nil)
70
+ Outbound::Emit.call!(event:, data:, to:, async:)
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "rack/utils"
5
+ require "stringio"
6
+
7
+ module Axn
8
+ module Webhooks
9
+ # A Rails-agnostic view of an inbound webhook request. Verifiers and dispatchers read
10
+ # only from this object, so the same pipeline works behind a Rack mount, a controller,
11
+ # or a plain test constructor. Header lookup is case-insensitive.
12
+ class Request
13
+ def initialize(raw_body:, headers: {}, params: {}, url: nil, http_method: "POST", params_error: nil)
14
+ @raw_body = raw_body.frozen? ? raw_body : raw_body.dup.freeze
15
+ @headers = (headers || {}).each_with_object({}) { |(k, v), h| h[k.to_s.downcase] = v }
16
+ @params = (params || {}).dup.freeze
17
+ # The failure `extract_params` swallowed, if any — see #params. Kept so the POST-verification
18
+ # parse step can still see it, without it ever reaching the pre-verification path.
19
+ @params_error = params_error
20
+ @params_reads = 0
21
+ @url = url
22
+ @http_method = http_method.to_s.upcase
23
+ end
24
+
25
+ attr_reader :raw_body, :url, :http_method
26
+
27
+ # The exception raised while parsing the query/form params, or nil. Public so Dispatch can
28
+ # surface it AFTER verification (see #params).
29
+ attr_reader :params_error
30
+
31
+ # How many times #params has been read. A COUNT, not a flag, so a caller can scope the
32
+ # question to a window rather than the request's whole lifetime — Dispatch compares it either
33
+ # side of the parse call. A lifetime flag was wrong: a custom verifier or a
34
+ # `challenge_required` predicate legitimately reads params BEFORE the parse step, and counting
35
+ # that read re-opened the downgrade the gate exists to prevent (Codex review).
36
+ attr_reader :params_reads
37
+
38
+ # Always a Hash, never raises — this is reachable BEFORE verification (a custom verifier or a
39
+ # `challenge_required` predicate may read it), where a raise would let an unauthenticated
40
+ # sender turn a 401 into a reported 500. The swallowed failure is not lost: it is kept on
41
+ # #params_error and re-raised by the parse step, which runs only after verification.
42
+ def params
43
+ @params_reads += 1
44
+ @params
45
+ end
46
+
47
+ def header(name)
48
+ @headers[name.to_s.downcase]
49
+ end
50
+
51
+ # `raw_body` and `headers` are attacker-controlled webhook payloads of unknown sensitivity
52
+ # (bank account numbers, API credentials, mailing addresses have all shown up in the wild) —
53
+ # never render them. This is the one place that matters: axn's auto-logging, exception
54
+ # reports, and any other caller that inspects a Request all go through #inspect.
55
+ def inspect
56
+ "#<#{self.class.name} #{http_method} #{url} raw_body=[REDACTED] (#{raw_body.bytesize}b) headers=[REDACTED]>"
57
+ end
58
+
59
+ # `pp`/PP does not call #inspect by default (Kernel#pretty_print walks instance variables
60
+ # directly), so without this override `pp request` would leak the same fields #inspect redacts.
61
+ def pretty_print(printer)
62
+ printer.text(inspect)
63
+ end
64
+
65
+ # Build a Request from a Rack env, capturing the exact pristine body bytes — this (not a
66
+ # controller's already-parsed params) is why the spec chose a Rack mount over a controller
67
+ # concern (see "## Packaging" in the design spec).
68
+ #
69
+ # rack.input is OPTIONAL under Rack 3 (it was mandatory in Rack 2), so a bodyless request may
70
+ # omit the key entirely — Rack::MockRequest.env_for does exactly that, which is what a Rails
71
+ # integration/request spec builds. Treat a missing input as an empty body rather than a
72
+ # malformed env: the GET challenge handshake (Nylas, Meta) is bodyless by definition, so
73
+ # fetching here would 500 the very handshake `challenge` exists to serve.
74
+ #
75
+ # We rewind BEFORE reading, not only after. Under Rack 3, `Rack::Request#POST` no longer
76
+ # rewinds rack.input after parsing a form-urlencoded body — and Rails' default middleware
77
+ # stack runs `Rack::MethodOverride` (which calls `#POST` looking for `_method`) ahead of the
78
+ # router. So by the time a mounted endpoint runs, the input of every form-encoded POST is
79
+ # already at EOF and reads as "". That silently empties raw_body AND params for exactly the
80
+ # vendors that post forms (Twilio, Slack), breaking dispatch and signature verification alike.
81
+ def self.from_rack(env)
82
+ input = env["rack.input"]
83
+ rewind(input)
84
+ raw_body = input&.read || ""
85
+ rewind(input) # courtesy for anything downstream of us
86
+
87
+ content_type = env["CONTENT_TYPE"]
88
+ params_result = extract_params(env, raw_body, content_type)
89
+ new(
90
+ raw_body:,
91
+ headers: extract_headers(env),
92
+ params: params_result.value,
93
+ params_error: params_result.error,
94
+ url: extract_url(env),
95
+ http_method: env["REQUEST_METHOD"],
96
+ )
97
+ end
98
+
99
+ # Best-effort: a non-rewindable/non-seekable stream (pipe/socket, or a bare Rack::Builder mount
100
+ # with no Rack::RewindableInput::Middleware in front) is tolerated rather than raising
101
+ # mid-request. Nothing upstream can have consumed such a stream either, so a single forward
102
+ # read still yields the pristine body.
103
+ def self.rewind(input)
104
+ input&.rewind
105
+ rescue StandardError
106
+ nil
107
+ end
108
+ private_class_method :rewind
109
+
110
+ # HTTP_* env keys -> header names ("HTTP_X_SIG" -> "X-Sig"-ish; case doesn't matter, #header
111
+ # looks up case-insensitively). CONTENT_TYPE/CONTENT_LENGTH are Rack's two documented
112
+ # exceptions to the HTTP_* convention (never prefixed), so they're mapped explicitly.
113
+ def self.extract_headers(env)
114
+ headers = env.each_with_object({}) do |(key, value), acc|
115
+ next unless key.start_with?("HTTP_")
116
+
117
+ acc[key.delete_prefix("HTTP_").tr("_", "-")] = value
118
+ end
119
+ headers["Content-Type"] = env["CONTENT_TYPE"] if env["CONTENT_TYPE"]
120
+ headers["Content-Length"] = env["CONTENT_LENGTH"] if env["CONTENT_LENGTH"]
121
+ headers
122
+ end
123
+ private_class_method :extract_headers
124
+
125
+ # `params` reflects the request's PRIMARY param source — never a query+form merge, because
126
+ # `url` (below) already carries the query string. Merging both would double-count query
127
+ # params for URL-signing verifiers (e.g. Twilio's RequestValidator does
128
+ # `validate(req.url, req.params, signature)`, which HMACs the query string once via the url
129
+ # and would HMAC it a second time via params if it were also merged in).
130
+ #
131
+ # - form-urlencoded body on a request that carries one (Twilio's SMS/voice POST) -> params =
132
+ # form fields only; the query (if any) is still reachable via `url`.
133
+ # - multipart/form-data body -> same, parsed by Rack (Dropbox Sign posts the whole event as a
134
+ # single `json` field, and its verifier reads that field twice: Content-MD5 over it, then
135
+ # JSON.parse of it).
136
+ # - everything else (GET/HEAD query, JSON POST, etc.) -> params = query string (e.g. the
137
+ # Nylas/Meta GET challenge, read via `req.params["challenge"]`). GET/HEAD never carry a
138
+ # body, so even a form-urlencoded default Content-Type header on a GET (common on
139
+ # challenge requests) must not shadow the query string with an empty-body parse.
140
+ # Parsed params plus whatever failure produced them, so the caller decides when the failure
141
+ # matters. `value` is always a Hash.
142
+ ParamsResult = Data.define(:value, :error)
143
+
144
+ def self.extract_params(env, raw_body, content_type)
145
+ return parse_query(env["QUERY_STRING"]) if %w[GET HEAD].include?(env["REQUEST_METHOD"])
146
+
147
+ if content_type&.start_with?("application/x-www-form-urlencoded")
148
+ # Parsed from raw_body rather than via Rack, so this branch stays independent of Rack's
149
+ # form-hash caching (and of whatever position upstream middleware left rack.input in).
150
+ parse_query(raw_body)
151
+ elsif content_type&.start_with?("multipart/form-data")
152
+ ParamsResult.new(value: parse_multipart(env, raw_body), error: nil)
153
+ else
154
+ parse_query(env["QUERY_STRING"])
155
+ end
156
+ end
157
+ private_class_method :extract_params
158
+
159
+ # Same fail-soft contract as parse_multipart below, and for the same reason (security audit):
160
+ # this runs on an UNVERIFIED request, so a parse error must not crash the pipeline before
161
+ # `verify` gets to reject the sender. Rack 3's own limits are the trigger — QueryLimitError
162
+ # from a deeply-nested or param-heavy body, InvalidParameterError from bad %-encoding — and a
163
+ # ~600-byte hostile body was enough to turn a would-be 401 into a 500 AND fire
164
+ # Axn.config.on_exception once per request, i.e. an unauthenticated pager flood.
165
+ def self.parse_query(string)
166
+ ParamsResult.new(value: Rack::Utils.parse_nested_query(string), error: nil)
167
+ rescue StandardError => e
168
+ ParamsResult.new(value: {}, error: e)
169
+ end
170
+ private_class_method :parse_query
171
+
172
+ # Rack owns multipart parsing (boundary handling differs across Rack 3 minors), so delegate —
173
+ # but feed it a StringIO over the bytes we already captured, never the live rack.input.
174
+ # `Rack::Request#POST` reads its input, and reading the real stream a SECOND time is exactly
175
+ # what `from_rack` is built to avoid: a bare Rack/streaming host (no
176
+ # Rack::RewindableInput::Middleware) hands us an input that is readable but NOT rewindable, so
177
+ # the second read would see EOF and yield `{}` — reintroducing the empty-params bug this
178
+ # branch exists to fix, on every non-Rails host. Parsing the captured body also keeps us
179
+ # independent of wherever upstream middleware left the stream (Rack 3's MethodOverride leaves
180
+ # form POSTs at EOF) and leaves the caller's input untouched for anything downstream.
181
+ #
182
+ # CONTENT_LENGTH is restated because it must describe the substitute input, and the env is
183
+ # duped because `#POST` memoizes into rack.request.form_hash/form_input — a cache keyed to our
184
+ # synthetic StringIO has no business leaking into the caller's env.
185
+ #
186
+ # A malformed body must yield `{}`, not raise: this runs on an UNVERIFIED request, so any
187
+ # parse error Rack raises (Rack::Multipart::EmptyContentError and friends) would let a hostile
188
+ # sender crash the pipeline before `verify` ever gets to reject them.
189
+ def self.parse_multipart(env, raw_body)
190
+ parse_env = env.merge(
191
+ "rack.input" => StringIO.new(raw_body),
192
+ "CONTENT_LENGTH" => raw_body.bytesize.to_s,
193
+ )
194
+ Rack::Request.new(parse_env).POST.tap { adopt_tempfiles(env, parse_env) }
195
+ rescue StandardError
196
+ {}
197
+ end
198
+ private_class_method :parse_multipart
199
+
200
+ # File parts get spilled to Tempfiles, and Rack::TempfileReaper (in Rails' default stack)
201
+ # closes/unlinks whatever it finds under "rack.tempfiles" when the response body closes. Rack
202
+ # *assigns* that key (`env[RACK_TEMPFILES] = info.tmp_files`) rather than appending, so
203
+ # parsing against our dup would leave the caller's list empty — not merely stale — and the
204
+ # reaper would close nothing, holding an fd and an on-disk file per file-bearing delivery
205
+ # until GC finalized it. Hand the tempfiles back to the env the reaper actually reads.
206
+ #
207
+ # Appended in place when a list already exists: the reaper seeds `env[RACK_TEMPFILES] ||= []`
208
+ # on the way in, and an upstream middleware's tempfiles must survive our parse.
209
+ def self.adopt_tempfiles(env, parse_env)
210
+ tempfiles = parse_env["rack.tempfiles"]
211
+ return if tempfiles.nil? || tempfiles.empty?
212
+
213
+ existing = env["rack.tempfiles"]
214
+ existing.is_a?(Array) ? existing.concat(tempfiles) : env["rack.tempfiles"] = tempfiles
215
+ end
216
+ private_class_method :adopt_tempfiles
217
+
218
+ # Delegates to Rack's own URL builder, which correctly assembles scheme + host +
219
+ # SCRIPT_NAME (mount prefix) + PATH_INFO + query. A hand-rolled version that used
220
+ # PATH_INFO alone would drop the mount prefix for endpoints mounted via
221
+ # `mount Inbound[:vendor], at: "/webhooks/codat"` (Rails) or Rack::Builder#map, since
222
+ # Rack puts that prefix in SCRIPT_NAME and leaves only the remainder in PATH_INFO —
223
+ # breaking URL-based verifiers (e.g. Twilio's RequestValidator, which HMACs req.url).
224
+ def self.extract_url(env)
225
+ Rack::Request.new(env).url
226
+ end
227
+ private_class_method :extract_url
228
+ end
229
+ end
230
+ end