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