Module: OpenLoam::InboundWebhooks
- Defined in:
- lib/open_loam/inbound_webhooks.rb
Overview
Receiving webhooks FROM external systems (the inbound sibling of
OpenLoam::Webhooks). One entry point — ingest — does the whole verified,
replay-resistant pipeline and returns a Result the controller turns into an
HTTP status. Kept here (not in a controller) so it is testable without HTTP
and shared by the generated app and the demo.
THE ORDER OF CHECKS is deliberate — cheapest and least-trusting first:
1. body size -> 413 (never HMAC a huge body)
2. token resolve -> 404 (unknown/inactive source)
3. signature -> 401 (constant-time HMAC over the RAW body)
4. timestamp -> 401 (defense-in-depth; see note below)
5. dedupe -> 200 (a replay is idempotent, not an error)
6. ingest+publish -> 202
Every AUTH failure returns 401 with no distinguishing body, so a sender can't probe which check failed; the specific reason is logged server-side only.
REPLAY: the real defense is the (source_id, external_id) dedupe, and external_id is derived from the SIGNED body — never from a header. Anything an attacker can vary without invalidating the signature is a way to mint a fresh dedupe key and replay the delivery. The timestamp window is defense-in-depth only, for the same reason: unless the sender signs the timestamp, a replayer just refreshes it.
Defined Under Namespace
Classes: Result
Constant Summary collapse
- MAX_BYTES =
1_000_000
Class Method Summary collapse
-
.delivery_id(_source, _headers, body) ⇒ Object
The dedupe key must come from SIGNED material, which means the body: the HMAC covers the body alone, so a captured (body, signature) replayed with a fresh delivery-id header used to mint a new external_id every time and re-publish the event.
- .fresh_timestamp?(raw, tolerance) ⇒ Boolean
- .header(headers, name) ⇒ Object
- .ingest(token:, raw_body:, headers:) ⇒ Object
- .parse(raw_body) ⇒ Object
- .run_ingest(token, raw_body, headers) ⇒ Object
- .unauthorized(reason) ⇒ Object
-
.valid_signature?(secret, body, provided) ⇒ Boolean
--- verification internals ---.
Class Method Details
.delivery_id(_source, _headers, body) ⇒ Object
The dedupe key must come from SIGNED material, which means the body: the HMAC covers the body alone, so a captured (body, signature) replayed with a fresh delivery-id header used to mint a new external_id every time and re-publish the event. Cost: a sender emitting distinct deliveries with identical bodies sees the second deduped — its nonce belongs in the body.
103 104 105 |
# File 'lib/open_loam/inbound_webhooks.rb', line 103 def delivery_id(_source, _headers, body) Digest::SHA256.hexdigest(body) end |
.fresh_timestamp?(raw, tolerance) ⇒ Boolean
89 90 91 92 93 94 95 96 |
# File 'lib/open_loam/inbound_webhooks.rb', line 89 def (raw, tolerance) return false if raw.blank? seconds = (Integer(raw.to_s) rescue (Time.parse(raw.to_s).to_i rescue nil)) return false if seconds.nil? (Time.current.to_i - seconds).abs <= tolerance.to_i end |
.header(headers, name) ⇒ Object
107 108 109 110 111 112 113 114 |
# File 'lib/open_loam/inbound_webhooks.rb', line 107 def header(headers, name) return nil if name.blank? # ActionDispatch::Http::Headers is case-insensitive on []; a plain Hash # (tests) is not — try the given key then a couple of common casings. headers[name] || headers[name.to_s] || headers[name.to_s.downcase] || headers["HTTP_#{name.to_s.upcase.tr('-', '_')}"] end |
.ingest(token:, raw_body:, headers:) ⇒ Object
35 36 37 |
# File 'lib/open_loam/inbound_webhooks.rb', line 35 def ingest(token:, raw_body:, headers:) OpenLoam::Telemetry.span("inbound_webhook") { run_ingest(token, raw_body.to_s, headers) } end |
.parse(raw_body) ⇒ Object
116 117 118 119 120 |
# File 'lib/open_loam/inbound_webhooks.rb', line 116 def parse(raw_body) JSON.parse(raw_body) rescue JSON::ParserError { "raw" => raw_body } end |
.run_ingest(token, raw_body, headers) ⇒ Object
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
# File 'lib/open_loam/inbound_webhooks.rb', line 39 def run_ingest(token, raw_body, headers) return Result.new(status: 413, reason: "body too large") if raw_body.bytesize > MAX_BYTES source = OpenLoam::InboundWebhookSource.resolve(token) return Result.new(status: 404, reason: "unknown or inactive source") if source.nil? signature = header(headers, source.signature_header_key) return ("missing signature") if signature.blank? return ("bad signature") unless valid_signature?(source.secret, raw_body, signature) if source..present? return ("stale or missing timestamp") unless (header(headers, source.), source.tolerance) end external_id = delivery_id(source, headers, raw_body) begin delivery = nil OpenLoam::InboundWebhookDelivery.transaction do delivery = OpenLoam::InboundWebhookDelivery.create!( source: source, external_id: external_id, event_name: source.event_name, status: "received", received_at: Time.current, payload: parse(raw_body) ) # Scalar-only payload by convention (like the outbound path): the body # lives on the delivery row, subscribers read it from there. Publishing # inside the txn ties capture to the row — a publish failure rolls the # row back so the sender's retry isn't deduped away. OpenLoam::Events.publish(source.event_name, { source_id: source.id, delivery_id: delivery.id }) end Result.new(status: 202, reason: "accepted", delivery: delivery) rescue ActiveRecord::RecordNotUnique # A concurrent or replayed delivery with the same external_id — already # processed. Idempotent success, NOT a second publish. Result.new(status: 200, reason: "duplicate (already processed)") end end |
.unauthorized(reason) ⇒ Object
122 123 124 |
# File 'lib/open_loam/inbound_webhooks.rb', line 122 def (reason) Result.new(status: 401, reason: reason) end |
.valid_signature?(secret, body, provided) ⇒ Boolean
--- verification internals ---
78 79 80 81 82 83 84 85 86 87 |
# File 'lib/open_loam/inbound_webhooks.rb', line 78 def valid_signature?(secret, body, provided) expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, body) # Hash both to a fixed 64-hex length so the compare is constant-time and # never raises on an attacker-chosen length. ActiveSupport::SecurityUtils.fixed_length_secure_compare( Digest::SHA256.hexdigest(expected), Digest::SHA256.hexdigest(provided.to_s) ) rescue StandardError false end |