Module: SignalWire::Security::WebhookValidator

Defined in:
lib/signalwire/security/webhook_validator.rb

Overview

Stateless validator for SignalWire-signed webhook requests.

Both Scheme A (JSON, hex digest) and Scheme B (form-encoded, base64 digest with bodySHA256 fallback) per porting-sdk/webhooks.md are tried by the combined entry point.

The two public entry points are exposed via “module_function“ so they can be invoked as “WebhookValidator.validate_webhook_signature(…)“. All internal helpers are deliberately “_“-prefixed and private so they don’t pollute the public surface (“audit_no_cheat_tests“ and “signature_dump.rb“ skip “_“-prefixed methods).

Class Method Summary collapse

Class Method Details

._b64_hmac_sha1(key, message) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



156
157
158
159
160
# File 'lib/signalwire/security/webhook_validator.rb', line 156

def self._b64_hmac_sha1(key, message)
  Base64.strict_encode64(
    OpenSSL::HMAC.digest('SHA1', key.to_s, message.to_s)
  )
end

._build_url_with_port(parsed, port) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



284
285
286
287
288
289
290
291
292
293
# File 'lib/signalwire/security/webhook_validator.rb', line 284

def self._build_url_with_port(parsed, port)
  netloc_host = parsed.host.include?(':') ? "[#{parsed.host}]" : parsed.host
  prefix = "#{parsed.scheme}://"
  prefix += "#{parsed.userinfo}@" if parsed.userinfo
  rest = +''
  rest << (parsed.path || '')
  rest << "?#{parsed.query}" if parsed.query
  rest << "##{parsed.fragment}" if parsed.fragment
  "#{prefix}#{netloc_host}:#{port}#{rest}"
end

._build_url_without_port(parsed) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



296
297
298
299
300
301
302
303
304
305
# File 'lib/signalwire/security/webhook_validator.rb', line 296

def self._build_url_without_port(parsed)
  netloc_host = parsed.host.include?(':') ? "[#{parsed.host}]" : parsed.host
  prefix = "#{parsed.scheme}://"
  prefix += "#{parsed.userinfo}@" if parsed.userinfo
  rest = +''
  rest << (parsed.path || '')
  rest << "?#{parsed.query}" if parsed.query
  rest << "##{parsed.fragment}" if parsed.fragment
  "#{prefix}#{netloc_host}#{rest}"
end

._candidate_urls(url) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Return the URL variants to try for Scheme B port normalization.

  • If the URL already has a non-standard port: just the input URL.

  • If https + no port: input URL AND url with “:443“.

  • If http + no port: input URL AND url with “:80“.

  • If https + “:443“ / http + “:80“: input URL AND url without port.

  • Otherwise (any explicit non-standard port): just the input URL.



235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'lib/signalwire/security/webhook_validator.rb', line 235

def self._candidate_urls(url)
  parsed = URI.parse(url)
  host = parsed.host
  return [url] if host.nil? || host.empty?

  scheme = (parsed.scheme || '').downcase
  standard = { 'http' => 80, 'https' => 443 }[scheme]
  port = parsed.port

  candidates = [url]

  if standard && parsed.respond_to?(:default_port) && port == parsed.default_port &&
     !_explicit_port?(url, scheme)
    # No explicit port in original URL; URI added the default.
    with_port_url = _build_url_with_port(parsed, standard)
    candidates << with_port_url if with_port_url != url
  elsif standard && port == standard && _explicit_port?(url, scheme)
    # Original URL had the standard port spelled out — also try without.
    without_port_url = _build_url_without_port(parsed)
    candidates << without_port_url if without_port_url != url
  end
  # Else: non-standard explicit port — only try as-is.

  candidates
rescue URI::InvalidURIError
  [url]
end

._check_body_sha256(url, raw_body) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

If URL has “?bodySHA256=<hex>“, verify “sha256_hex(raw_body)“ matches. Returns true if the param is absent (no constraint), or present and matches. Returns false only when the param is present and mismatches.



311
312
313
314
315
316
317
318
319
320
321
322
323
324
# File 'lib/signalwire/security/webhook_validator.rb', line 311

def self._check_body_sha256(url, raw_body)
  parsed = URI.parse(url)
  return true if parsed.query.nil? || parsed.query.empty?

  # Use _parse_form_body for consistency (handles repeated keys etc).
  qparams = _parse_form_body(parsed.query)
  body_hash = qparams.find { |(k, _)| k == 'bodySHA256' }
  return true if body_hash.nil?

  actual = Digest::SHA256.hexdigest(raw_body.to_s)
  _safe_eq(actual, body_hash[1])
rescue URI::InvalidURIError
  true
end

._explicit_port?(url, _scheme) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Heuristic: was a port explicitly written into the URL string? “URI“ always populates “port“ (with the default), so we have to look at the raw string.

Returns:

  • (Boolean)


267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/signalwire/security/webhook_validator.rb', line 267

def self._explicit_port?(url, _scheme)
  # Look for ``:NNN`` between the host and the path / query / end.
  # Avoid false positives in ``://``, userinfo, IPv6 brackets, etc.
  no_scheme = url.sub(%r{\A[^:]+://}, '')
  no_userinfo = no_scheme.sub(/\A[^@\/?#]*@/, '')
  # Strip IPv6 zone if any: "[..]" then look after ]
  if no_userinfo.start_with?('[')
    after_bracket = no_userinfo.sub(/\A\[[^\]]*\]/, '')
    !!(after_bracket =~ /\A:\d+/)
  else
    host_and_rest = no_userinfo
    host_part, _sep, _rest = host_and_rest.partition(%r{[/?#]})
    !!(host_part =~ /:\d+\z/)
  end
end

._hex_hmac_sha1(key, message) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



151
152
153
# File 'lib/signalwire/security/webhook_validator.rb', line 151

def self._hex_hmac_sha1(key, message)
  OpenSSL::HMAC.hexdigest('SHA1', key.to_s, message.to_s)
end

._parse_form_body(raw_body) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Best-effort parse of an x-www-form-urlencoded body. Returns an array of [key, value] pairs (preserving order, including duplicate keys). Returns [] if the body doesn’t decode as form data.



210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/signalwire/security/webhook_validator.rb', line 210

def self._parse_form_body(raw_body)
  return [] if raw_body.nil? || raw_body.empty?

  pairs = []
  raw_body.split('&').each do |chunk|
    next if chunk.empty?

    k, _eq, v = chunk.partition('=')
    decoded_k = CGI.unescape(k)
    decoded_v = CGI.unescape(v)
    pairs << [decoded_k, decoded_v]
  end
  pairs
rescue StandardError
  []
end

._safe_eq(a, b) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Constant-time string compare. Returns false on any error so malformed inputs never raise.



165
166
167
168
169
# File 'lib/signalwire/security/webhook_validator.rb', line 165

def self._safe_eq(a, b)
  Rack::Utils.secure_compare(a.to_s, b.to_s)
rescue StandardError
  false
end

._sorted_concat_params(params) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Concatenate form params per Scheme B rules:

  • Sort by key, ASCII ascending.

  • For repeated keys (Array values OR multiple [k,v] pairs): keep original submission order, emit “key + value“ once per occurrence.

  • Non-string values are stringified via “to_s“.



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/signalwire/security/webhook_validator.rb', line 177

def self._sorted_concat_params(params)
  return '' if params.nil? || (params.respond_to?(:empty?) && params.empty?)

  items = []
  if params.is_a?(Hash)
    params.each do |k, v|
      if v.is_a?(Array)
        v.each { |vi| items << [k.to_s, vi] }
      else
        items << [k.to_s, v]
      end
    end
  elsif params.is_a?(Array)
    params.each do |pair|
      # Accept [k, v] pairs (the most common form).
      next unless pair.is_a?(Array) && pair.length >= 2

      items << [pair[0].to_s, pair[1]]
    end
  else
    return ''
  end

  # Stable sort by key — preserves original order within repeated keys.
  items = items.each_with_index.sort_by { |(k, _v), idx| [k, idx] }.map(&:first)

  items.map { |k, v| "#{k}#{v.nil? ? '' : v}" }.join
end

.validate_request(signing_key, signature, url, params_or_raw_body) ⇒ Boolean

Legacy “@signalwire/compatibility-api“ drop-in entry point.

If “params_or_raw_body“ is a “String“, delegates to validate_webhook_signature (Scheme A then Scheme B with parsed form).

If it’s a “Hash“ or an array of (key, value) pairs, treats it as pre-parsed form params and runs Scheme B directly (with URL port normalization and optional bodySHA256 fallback).

Parameters:

  • signing_key (String)
  • signature (String, nil)
  • url (String)
  • params_or_raw_body (String, Hash, Array, nil)

Returns:

  • (Boolean)

Raises:

  • (ArgumentError)

    when “signing_key“ is missing.

  • (TypeError)

    when “params_or_raw_body“ is neither a String, Hash, nor an array of pairs.



121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/signalwire/security/webhook_validator.rb', line 121

def self.validate_request(signing_key, signature, url, params_or_raw_body)
  raise ArgumentError, 'signing_key is required' if signing_key.nil? || signing_key.to_s.empty?
  return false if signature.nil? || signature.to_s.empty?

  if params_or_raw_body.is_a?(String)
    return validate_webhook_signature(signing_key, signature, url, params_or_raw_body)
  end

  params_or_raw_body = [] if params_or_raw_body.nil?

  unless params_or_raw_body.is_a?(Hash) || params_or_raw_body.is_a?(Array)
    raise TypeError,
          'params_or_raw_body must be a String (raw body) or a Hash/Array of form params'
  end

  # Pre-parsed form params → Scheme B only.
  concat = _sorted_concat_params(params_or_raw_body)
  _candidate_urls(url.to_s).each do |candidate_url|
    expected_b = _b64_hmac_sha1(signing_key, candidate_url + concat)
    # bodySHA256 has no raw body to verify here — skip that check.
    return true if _safe_eq(expected_b, signature)
  end
  false
end

.validate_webhook_signature(signing_key, signature, url, raw_body) ⇒ Boolean

Validate a SignalWire webhook signature against both schemes.

Parameters:

  • signing_key (String)

    Customer’s Signing Key from the Dashboard. UTF-8 string, secret. “nil“ / empty raises “ArgumentError“ —that’s a programming error, not a validation failure.

  • signature (String, nil)

    The “X-SignalWire-Signature“ header value (or “X-Twilio-Signature“ for cXML compat). Missing / empty returns false without raising.

  • url (String)

    The full URL SignalWire POSTed to (scheme, host, optional port, path, query). Must match what the platform saw —see the URL reconstruction section of porting-sdk/webhooks.md.

  • raw_body (String)

    The raw request body bytes as a UTF-8 string, BEFORE any JSON / form parsing. Must be a “String“ — passing a parsed Hash raises “TypeError“.

Returns:

  • (Boolean)

    true if the signature matches either Scheme A or Scheme B (with port-normalization variants and optional bodySHA256 fallback). false otherwise.

Raises:

  • (ArgumentError)

    when “signing_key“ is missing.

  • (TypeError)

    when “raw_body“ is not a String.



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/signalwire/security/webhook_validator.rb', line 67

def self.validate_webhook_signature(signing_key, signature, url, raw_body)
  raise ArgumentError, 'signing_key is required' if signing_key.nil? || signing_key.to_s.empty?
  unless raw_body.is_a?(String)
    raise TypeError,
          'raw_body must be a String — did you pass parsed JSON by mistake?'
  end
  return false if signature.nil? || signature.to_s.empty?

  # ------------------------------------------------------------------
  # Scheme A — RELAY/SWML/JSON: hex(HMAC-SHA1(key, url + raw_body))
  # ------------------------------------------------------------------
  expected_a = _hex_hmac_sha1(signing_key, url.to_s + raw_body)
  return true if _safe_eq(expected_a, signature)

  # ------------------------------------------------------------------
  # Scheme B — Compat/cXML form: base64(HMAC-SHA1(key, url + sorted_concat))
  # Try parsed form params and the empty-params fallback (for JSON on
  # the compat surface). Try with-port and without-port URL variants.
  # ------------------------------------------------------------------
  parsed_params = _parse_form_body(raw_body)
  param_shapes  = [parsed_params, []]

  _candidate_urls(url.to_s).each do |candidate_url|
    param_shapes.each do |shape|
      concat = _sorted_concat_params(shape)
      expected_b = _b64_hmac_sha1(signing_key, candidate_url + concat)
      next unless _safe_eq(expected_b, signature)

      # If the URL carries bodySHA256, the body hash must match too.
      return true if _check_body_sha256(candidate_url, raw_body)
      # bodySHA256 mismatched — keep trying other shapes/urls.
    end
  end

  false
end