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

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

Returns:

  • (Boolean)


89
90
91
92
93
94
95
96
# File 'lib/open_loam/inbound_webhooks.rb', line 89

def fresh_timestamp?(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 unauthorized("missing signature") if signature.blank?
  return unauthorized("bad signature") unless valid_signature?(source.secret, raw_body, signature)

  if source.timestamp_header.present?
    return unauthorized("stale or missing timestamp") unless fresh_timestamp?(header(headers, source.timestamp_header), 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 unauthorized(reason)
  Result.new(status: 401, reason: reason)
end

.valid_signature?(secret, body, provided) ⇒ Boolean

--- verification internals ---

Returns:

  • (Boolean)


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