signalwire-sdk 2.0.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/LICENSE +21 -0
- data/README.md +259 -0
- data/bin/swaig-test +872 -0
- data/lib/signalwire/agent/agent_base.rb +2134 -0
- data/lib/signalwire/contexts/context_builder.rb +861 -0
- data/lib/signalwire/core/logging_config.rb +54 -0
- data/lib/signalwire/datamap/data_map.rb +315 -0
- data/lib/signalwire/logging.rb +92 -0
- data/lib/signalwire/pom/prompt_object_model.rb +269 -0
- data/lib/signalwire/pom/section.rb +202 -0
- data/lib/signalwire/prefabs/concierge.rb +92 -0
- data/lib/signalwire/prefabs/faq_bot.rb +67 -0
- data/lib/signalwire/prefabs/info_gatherer.rb +79 -0
- data/lib/signalwire/prefabs/receptionist.rb +74 -0
- data/lib/signalwire/prefabs/survey.rb +75 -0
- data/lib/signalwire/relay/action.rb +291 -0
- data/lib/signalwire/relay/call.rb +523 -0
- data/lib/signalwire/relay/client.rb +789 -0
- data/lib/signalwire/relay/constants.rb +124 -0
- data/lib/signalwire/relay/message.rb +137 -0
- data/lib/signalwire/relay/relay_event.rb +670 -0
- data/lib/signalwire/rest/http_client.rb +159 -0
- data/lib/signalwire/rest/namespaces/addresses.rb +19 -0
- data/lib/signalwire/rest/namespaces/calling.rb +179 -0
- data/lib/signalwire/rest/namespaces/chat.rb +18 -0
- data/lib/signalwire/rest/namespaces/compat.rb +229 -0
- data/lib/signalwire/rest/namespaces/datasphere.rb +39 -0
- data/lib/signalwire/rest/namespaces/fabric.rb +235 -0
- data/lib/signalwire/rest/namespaces/imported_numbers.rb +18 -0
- data/lib/signalwire/rest/namespaces/logs.rb +46 -0
- data/lib/signalwire/rest/namespaces/lookup.rb +18 -0
- data/lib/signalwire/rest/namespaces/mfa.rb +26 -0
- data/lib/signalwire/rest/namespaces/number_groups.rb +32 -0
- data/lib/signalwire/rest/namespaces/phone_numbers.rb +124 -0
- data/lib/signalwire/rest/namespaces/project.rb +33 -0
- data/lib/signalwire/rest/namespaces/pubsub.rb +18 -0
- data/lib/signalwire/rest/namespaces/queues.rb +28 -0
- data/lib/signalwire/rest/namespaces/recordings.rb +18 -0
- data/lib/signalwire/rest/namespaces/registry.rb +67 -0
- data/lib/signalwire/rest/namespaces/short_codes.rb +26 -0
- data/lib/signalwire/rest/namespaces/sip_profile.rb +22 -0
- data/lib/signalwire/rest/namespaces/verified_callers.rb +24 -0
- data/lib/signalwire/rest/namespaces/video.rb +129 -0
- data/lib/signalwire/rest/pagination.rb +89 -0
- data/lib/signalwire/rest/phone_call_handler.rb +56 -0
- data/lib/signalwire/rest/rest_client.rb +114 -0
- data/lib/signalwire/runtime.rb +98 -0
- data/lib/signalwire/security/session_manager.rb +124 -0
- data/lib/signalwire/security/webhook_middleware.rb +191 -0
- data/lib/signalwire/security/webhook_validator.rb +327 -0
- data/lib/signalwire/server/agent_server.rb +413 -0
- data/lib/signalwire/serverless/lambda_handler.rb +251 -0
- data/lib/signalwire/skills/builtin/api_ninjas_trivia.rb +99 -0
- data/lib/signalwire/skills/builtin/claude_skills.rb +92 -0
- data/lib/signalwire/skills/builtin/custom_skills.rb +54 -0
- data/lib/signalwire/skills/builtin/datasphere.rb +153 -0
- data/lib/signalwire/skills/builtin/datasphere_serverless.rb +107 -0
- data/lib/signalwire/skills/builtin/datetime.rb +97 -0
- data/lib/signalwire/skills/builtin/google_maps.rb +168 -0
- data/lib/signalwire/skills/builtin/info_gatherer.rb +189 -0
- data/lib/signalwire/skills/builtin/joke.rb +65 -0
- data/lib/signalwire/skills/builtin/math.rb +176 -0
- data/lib/signalwire/skills/builtin/mcp_gateway.rb +121 -0
- data/lib/signalwire/skills/builtin/native_vector_search.rb +116 -0
- data/lib/signalwire/skills/builtin/play_background_file.rb +86 -0
- data/lib/signalwire/skills/builtin/spider.rb +169 -0
- data/lib/signalwire/skills/builtin/swml_transfer.rb +118 -0
- data/lib/signalwire/skills/builtin/weather_api.rb +92 -0
- data/lib/signalwire/skills/builtin/web_search.rb +141 -0
- data/lib/signalwire/skills/builtin/wikipedia_search.rb +125 -0
- data/lib/signalwire/skills/skill_base.rb +82 -0
- data/lib/signalwire/skills/skill_manager.rb +97 -0
- data/lib/signalwire/skills/skill_registry.rb +258 -0
- data/lib/signalwire/swaig/function_result.rb +777 -0
- data/lib/signalwire/swml/document.rb +84 -0
- data/lib/signalwire/swml/schema.json +12250 -0
- data/lib/signalwire/swml/schema.rb +81 -0
- data/lib/signalwire/swml/service.rb +650 -0
- data/lib/signalwire/utils/schema_utils.rb +298 -0
- data/lib/signalwire/utils/serverless.rb +19 -0
- data/lib/signalwire/utils/url_validator.rb +138 -0
- data/lib/signalwire/version.rb +5 -0
- data/lib/signalwire.rb +114 -0
- metadata +225 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright (c) 2025 SignalWire
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the MIT License.
|
|
6
|
+
# See LICENSE file in the project root for full license information.
|
|
7
|
+
#
|
|
8
|
+
# Rack middleware for SignalWire webhook signature validation.
|
|
9
|
+
#
|
|
10
|
+
# This adapter wraps {SignalWire::Security::WebhookValidator} so it can be
|
|
11
|
+
# inserted into any Rack pipeline (Rails, Sinatra, plain Rack).
|
|
12
|
+
#
|
|
13
|
+
# Why a custom middleware rather than vanilla Rack?
|
|
14
|
+
#
|
|
15
|
+
# - We MUST capture the raw bytes BEFORE any JSON / form parser consumes
|
|
16
|
+
# the stream — re-serialization changes whitespace and key order, which
|
|
17
|
+
# breaks the Scheme A digest. The middleware reads ``rack.input``, then
|
|
18
|
+
# rewinds the IO and stashes the bytes on
|
|
19
|
+
# ``env['signalwire.raw_body']`` so downstream handlers can re-parse
|
|
20
|
+
# without re-reading the stream.
|
|
21
|
+
# - Reverse-proxy / ngrok deployments need the URL the platform POSTed
|
|
22
|
+
# to, which differs from the URL the SDK sees. The middleware honors
|
|
23
|
+
# ``X-Forwarded-Proto`` / ``X-Forwarded-Host`` when ``trust_proxy``
|
|
24
|
+
# is true, plus the ``SWML_PROXY_URL_BASE`` env var, with the request
|
|
25
|
+
# URL as last resort.
|
|
26
|
+
# - The legacy cXML/Compatibility scheme used the ``X-Twilio-Signature``
|
|
27
|
+
# header. We accept it as an alias of ``X-SignalWire-Signature`` so users
|
|
28
|
+
# migrating from the legacy SDK can keep their callers unchanged.
|
|
29
|
+
#
|
|
30
|
+
# Usage::
|
|
31
|
+
#
|
|
32
|
+
# use SignalWire::Security::WebhookMiddleware,
|
|
33
|
+
# signing_key: ENV['SIGNALWIRE_SIGNING_KEY'],
|
|
34
|
+
# trust_proxy: true,
|
|
35
|
+
# paths: ['/', '/swaig', '/post_prompt']
|
|
36
|
+
|
|
37
|
+
require 'rack'
|
|
38
|
+
require_relative 'webhook_validator'
|
|
39
|
+
|
|
40
|
+
module SignalWire
|
|
41
|
+
module Security
|
|
42
|
+
# Rack middleware that rejects webhook requests with bad signatures.
|
|
43
|
+
#
|
|
44
|
+
# Configure with the customer's Signing Key (and optional ``trust_proxy``
|
|
45
|
+
# to honor X-Forwarded headers). Mount upstream of any body-parsing
|
|
46
|
+
# middleware so the raw bytes survive intact.
|
|
47
|
+
class WebhookMiddleware
|
|
48
|
+
SIGNALWIRE_SIGNATURE_HEADER = 'HTTP_X_SIGNALWIRE_SIGNATURE'
|
|
49
|
+
TWILIO_COMPAT_SIGNATURE_HEADER = 'HTTP_X_TWILIO_SIGNATURE'
|
|
50
|
+
|
|
51
|
+
# Key under which the captured raw body is stashed on the request env.
|
|
52
|
+
RAW_BODY_ENV_KEY = 'signalwire.raw_body'
|
|
53
|
+
|
|
54
|
+
# @param app [#call] the wrapped Rack app.
|
|
55
|
+
# @param signing_key [String] customer Signing Key (required, non-empty).
|
|
56
|
+
# @param trust_proxy [Boolean] honor X-Forwarded-Proto / X-Forwarded-Host
|
|
57
|
+
# when reconstructing the request URL. Default false — proxy headers
|
|
58
|
+
# are spoofable, so opt in only when you control the proxy.
|
|
59
|
+
# @param paths [Array<String>, nil] when set, only apply the validation
|
|
60
|
+
# on these PATH_INFO values; everything else passes through. When nil,
|
|
61
|
+
# apply to every request.
|
|
62
|
+
# @param methods [Array<String>, nil] limit to these HTTP methods. When
|
|
63
|
+
# nil, apply to every method.
|
|
64
|
+
#
|
|
65
|
+
# @raise [ArgumentError] when ``signing_key`` is missing.
|
|
66
|
+
def initialize(app, signing_key:, trust_proxy: false, paths: nil, methods: ['POST'])
|
|
67
|
+
raise ArgumentError, 'signing_key is required' if signing_key.nil? || signing_key.to_s.empty?
|
|
68
|
+
|
|
69
|
+
@app = app
|
|
70
|
+
@signing_key = signing_key
|
|
71
|
+
@trust_proxy = trust_proxy
|
|
72
|
+
@paths = paths.nil? ? nil : Array(paths).map(&:to_s)
|
|
73
|
+
@methods = methods.nil? ? nil : Array(methods).map { |m| m.to_s.upcase }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @api private
|
|
77
|
+
def call(env)
|
|
78
|
+
return @app.call(env) unless _applies?(env)
|
|
79
|
+
|
|
80
|
+
# Capture raw body BEFORE any other middleware reads the stream.
|
|
81
|
+
raw_body = _read_raw_body(env)
|
|
82
|
+
env[RAW_BODY_ENV_KEY] = raw_body
|
|
83
|
+
|
|
84
|
+
signature = _extract_signature_header(env)
|
|
85
|
+
return _forbidden if signature.nil? || signature.empty?
|
|
86
|
+
|
|
87
|
+
url = _reconstruct_url(env)
|
|
88
|
+
|
|
89
|
+
valid = begin
|
|
90
|
+
WebhookValidator.validate_webhook_signature(@signing_key, signature, url, raw_body)
|
|
91
|
+
rescue ArgumentError, TypeError
|
|
92
|
+
# Programming errors at the boundary — never leak which branch
|
|
93
|
+
# tripped. Reject the request without raising.
|
|
94
|
+
return _forbidden
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
return _forbidden unless valid
|
|
98
|
+
|
|
99
|
+
@app.call(env)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# @api private
|
|
105
|
+
def _applies?(env)
|
|
106
|
+
if @methods
|
|
107
|
+
method = env['REQUEST_METHOD'].to_s.upcase
|
|
108
|
+
return false unless @methods.include?(method)
|
|
109
|
+
end
|
|
110
|
+
if @paths
|
|
111
|
+
path = env['PATH_INFO'].to_s
|
|
112
|
+
# Exact match — paths is intentionally a tight allowlist.
|
|
113
|
+
return false unless @paths.include?(path)
|
|
114
|
+
end
|
|
115
|
+
true
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @api private
|
|
119
|
+
def _read_raw_body(env)
|
|
120
|
+
input = env['rack.input']
|
|
121
|
+
return '' if input.nil?
|
|
122
|
+
|
|
123
|
+
body = input.read
|
|
124
|
+
# Rewind so downstream middleware / handlers can read the body too.
|
|
125
|
+
# Some Rack inputs (e.g. Tempfile-backed) are seekable; StringIO is
|
|
126
|
+
# always rewindable.
|
|
127
|
+
input.rewind if input.respond_to?(:rewind)
|
|
128
|
+
body.to_s
|
|
129
|
+
rescue StandardError
|
|
130
|
+
''
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @api private
|
|
134
|
+
def _extract_signature_header(env)
|
|
135
|
+
sig = env[SIGNALWIRE_SIGNATURE_HEADER]
|
|
136
|
+
sig = env[TWILIO_COMPAT_SIGNATURE_HEADER] if sig.nil? || sig.empty?
|
|
137
|
+
sig
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @api private
|
|
141
|
+
# Reconstruct the public URL SignalWire POSTed to.
|
|
142
|
+
#
|
|
143
|
+
# Resolution order (highest priority first):
|
|
144
|
+
# 1. ``SWML_PROXY_URL_BASE`` env var (joined with the request path + query).
|
|
145
|
+
# 2. ``X-Forwarded-Proto`` / ``X-Forwarded-Host`` headers, when
|
|
146
|
+
# ``trust_proxy`` is true and X-Forwarded-Host is present.
|
|
147
|
+
# 3. ``scheme://host[:port]/path?query`` derived from the rack env.
|
|
148
|
+
def _reconstruct_url(env)
|
|
149
|
+
path = env['PATH_INFO'].to_s
|
|
150
|
+
path = '/' if path.empty?
|
|
151
|
+
query = env['QUERY_STRING'].to_s
|
|
152
|
+
path_and_query = query.empty? ? path : "#{path}?#{query}"
|
|
153
|
+
|
|
154
|
+
proxy_base = ENV['SWML_PROXY_URL_BASE']
|
|
155
|
+
return "#{proxy_base.sub(%r{/+\z}, '')}#{path_and_query}" if proxy_base && !proxy_base.empty?
|
|
156
|
+
|
|
157
|
+
if @trust_proxy
|
|
158
|
+
fwd_host = env['HTTP_X_FORWARDED_HOST']
|
|
159
|
+
fwd_proto = env['HTTP_X_FORWARDED_PROTO'] || 'https'
|
|
160
|
+
if fwd_host && !fwd_host.empty?
|
|
161
|
+
return "#{fwd_proto}://#{fwd_host}#{path_and_query}"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
scheme = env['rack.url_scheme'] || 'http'
|
|
166
|
+
host = env['HTTP_HOST'] || env['SERVER_NAME']
|
|
167
|
+
port = env['SERVER_PORT']
|
|
168
|
+
# Only include port if it's non-standard AND not already in HTTP_HOST.
|
|
169
|
+
host_with_port =
|
|
170
|
+
if host && host.include?(':')
|
|
171
|
+
host
|
|
172
|
+
elsif port && (
|
|
173
|
+
(scheme == 'http' && port.to_s != '80') ||
|
|
174
|
+
(scheme == 'https' && port.to_s != '443')
|
|
175
|
+
)
|
|
176
|
+
"#{host}:#{port}"
|
|
177
|
+
else
|
|
178
|
+
host
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
"#{scheme}://#{host_with_port}#{path_and_query}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# @api private
|
|
185
|
+
def _forbidden
|
|
186
|
+
# No body detail — never leak which branch tripped.
|
|
187
|
+
[403, { 'content-type' => 'text/plain' }, ['']]
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright (c) 2025 SignalWire
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the MIT License.
|
|
6
|
+
# See LICENSE file in the project root for full license information.
|
|
7
|
+
#
|
|
8
|
+
# Webhook signature validation for SignalWire-signed HTTP requests.
|
|
9
|
+
#
|
|
10
|
+
# Implements both schemes from porting-sdk/webhooks.md:
|
|
11
|
+
#
|
|
12
|
+
# - Scheme A (RELAY/SWML/JSON): hex(HMAC-SHA1(key, url + raw_body))
|
|
13
|
+
# - Scheme B (Compat/cXML form): base64(HMAC-SHA1(key, url + sortedFormParams))
|
|
14
|
+
# with optional bodySHA256 query-param fallback for JSON-on-compat-surface.
|
|
15
|
+
#
|
|
16
|
+
# Public API:
|
|
17
|
+
# SignalWire::Security::WebhookValidator.validate_webhook_signature(
|
|
18
|
+
# signing_key, signature, url, raw_body) -> Boolean
|
|
19
|
+
# SignalWire::Security::WebhookValidator.validate_request(
|
|
20
|
+
# signing_key, signature, url, params_or_raw_body) -> Boolean
|
|
21
|
+
#
|
|
22
|
+
# All comparisons use ``Rack::Utils.secure_compare`` (constant-time) so the
|
|
23
|
+
# secret is not leaked over repeated requests.
|
|
24
|
+
|
|
25
|
+
require 'base64'
|
|
26
|
+
require 'cgi'
|
|
27
|
+
require 'digest'
|
|
28
|
+
require 'openssl'
|
|
29
|
+
require 'rack/utils'
|
|
30
|
+
require 'uri'
|
|
31
|
+
|
|
32
|
+
module SignalWire
|
|
33
|
+
module Security
|
|
34
|
+
# Stateless validator for SignalWire-signed webhook requests.
|
|
35
|
+
#
|
|
36
|
+
# Both Scheme A (JSON, hex digest) and Scheme B (form-encoded, base64
|
|
37
|
+
# digest with bodySHA256 fallback) per porting-sdk/webhooks.md are
|
|
38
|
+
# tried by the combined entry point.
|
|
39
|
+
#
|
|
40
|
+
# The two public entry points are exposed via ``module_function`` so
|
|
41
|
+
# they can be invoked as ``WebhookValidator.validate_webhook_signature(...)``.
|
|
42
|
+
# All internal helpers are deliberately ``_``-prefixed and private so
|
|
43
|
+
# they don't pollute the public surface (``audit_no_cheat_tests`` and
|
|
44
|
+
# ``signature_dump.rb`` skip ``_``-prefixed methods).
|
|
45
|
+
module WebhookValidator
|
|
46
|
+
# Validate a SignalWire webhook signature against both schemes.
|
|
47
|
+
#
|
|
48
|
+
# @param signing_key [String] Customer's Signing Key from the Dashboard.
|
|
49
|
+
# UTF-8 string, secret. ``nil`` / empty raises ``ArgumentError`` —
|
|
50
|
+
# that's a programming error, not a validation failure.
|
|
51
|
+
# @param signature [String, nil] The ``X-SignalWire-Signature`` header
|
|
52
|
+
# value (or ``X-Twilio-Signature`` for cXML compat). Missing / empty
|
|
53
|
+
# returns false without raising.
|
|
54
|
+
# @param url [String] The full URL SignalWire POSTed to (scheme, host,
|
|
55
|
+
# optional port, path, query). Must match what the platform saw —
|
|
56
|
+
# see the URL reconstruction section of porting-sdk/webhooks.md.
|
|
57
|
+
# @param raw_body [String] The raw request body bytes as a UTF-8 string,
|
|
58
|
+
# BEFORE any JSON / form parsing. Must be a ``String`` — passing a
|
|
59
|
+
# parsed Hash raises ``TypeError``.
|
|
60
|
+
#
|
|
61
|
+
# @return [Boolean] true if the signature matches either Scheme A or
|
|
62
|
+
# Scheme B (with port-normalization variants and optional bodySHA256
|
|
63
|
+
# fallback). false otherwise.
|
|
64
|
+
#
|
|
65
|
+
# @raise [ArgumentError] when ``signing_key`` is missing.
|
|
66
|
+
# @raise [TypeError] when ``raw_body`` is not a String.
|
|
67
|
+
def self.validate_webhook_signature(signing_key, signature, url, raw_body)
|
|
68
|
+
raise ArgumentError, 'signing_key is required' if signing_key.nil? || signing_key.to_s.empty?
|
|
69
|
+
unless raw_body.is_a?(String)
|
|
70
|
+
raise TypeError,
|
|
71
|
+
'raw_body must be a String — did you pass parsed JSON by mistake?'
|
|
72
|
+
end
|
|
73
|
+
return false if signature.nil? || signature.to_s.empty?
|
|
74
|
+
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
# Scheme A — RELAY/SWML/JSON: hex(HMAC-SHA1(key, url + raw_body))
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
expected_a = _hex_hmac_sha1(signing_key, url.to_s + raw_body)
|
|
79
|
+
return true if _safe_eq(expected_a, signature)
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
# Scheme B — Compat/cXML form: base64(HMAC-SHA1(key, url + sorted_concat))
|
|
83
|
+
# Try parsed form params and the empty-params fallback (for JSON on
|
|
84
|
+
# the compat surface). Try with-port and without-port URL variants.
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
parsed_params = _parse_form_body(raw_body)
|
|
87
|
+
param_shapes = [parsed_params, []]
|
|
88
|
+
|
|
89
|
+
_candidate_urls(url.to_s).each do |candidate_url|
|
|
90
|
+
param_shapes.each do |shape|
|
|
91
|
+
concat = _sorted_concat_params(shape)
|
|
92
|
+
expected_b = _b64_hmac_sha1(signing_key, candidate_url + concat)
|
|
93
|
+
next unless _safe_eq(expected_b, signature)
|
|
94
|
+
|
|
95
|
+
# If the URL carries bodySHA256, the body hash must match too.
|
|
96
|
+
return true if _check_body_sha256(candidate_url, raw_body)
|
|
97
|
+
# bodySHA256 mismatched — keep trying other shapes/urls.
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Legacy ``@signalwire/compatibility-api`` drop-in entry point.
|
|
105
|
+
#
|
|
106
|
+
# If ``params_or_raw_body`` is a ``String``, delegates to
|
|
107
|
+
# {validate_webhook_signature} (Scheme A then Scheme B with parsed form).
|
|
108
|
+
#
|
|
109
|
+
# If it's a ``Hash`` or an array of (key, value) pairs, treats it as
|
|
110
|
+
# pre-parsed form params and runs Scheme B directly (with URL port
|
|
111
|
+
# normalization and optional bodySHA256 fallback).
|
|
112
|
+
#
|
|
113
|
+
# @param signing_key [String]
|
|
114
|
+
# @param signature [String, nil]
|
|
115
|
+
# @param url [String]
|
|
116
|
+
# @param params_or_raw_body [String, Hash, Array, nil]
|
|
117
|
+
# @return [Boolean]
|
|
118
|
+
# @raise [ArgumentError] when ``signing_key`` is missing.
|
|
119
|
+
# @raise [TypeError] when ``params_or_raw_body`` is neither a String,
|
|
120
|
+
# Hash, nor an array of pairs.
|
|
121
|
+
def self.validate_request(signing_key, signature, url, params_or_raw_body)
|
|
122
|
+
raise ArgumentError, 'signing_key is required' if signing_key.nil? || signing_key.to_s.empty?
|
|
123
|
+
return false if signature.nil? || signature.to_s.empty?
|
|
124
|
+
|
|
125
|
+
if params_or_raw_body.is_a?(String)
|
|
126
|
+
return validate_webhook_signature(signing_key, signature, url, params_or_raw_body)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
params_or_raw_body = [] if params_or_raw_body.nil?
|
|
130
|
+
|
|
131
|
+
unless params_or_raw_body.is_a?(Hash) || params_or_raw_body.is_a?(Array)
|
|
132
|
+
raise TypeError,
|
|
133
|
+
'params_or_raw_body must be a String (raw body) or a Hash/Array of form params'
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Pre-parsed form params → Scheme B only.
|
|
137
|
+
concat = _sorted_concat_params(params_or_raw_body)
|
|
138
|
+
_candidate_urls(url.to_s).each do |candidate_url|
|
|
139
|
+
expected_b = _b64_hmac_sha1(signing_key, candidate_url + concat)
|
|
140
|
+
# bodySHA256 has no raw body to verify here — skip that check.
|
|
141
|
+
return true if _safe_eq(expected_b, signature)
|
|
142
|
+
end
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# ----------------------------------------------------------------------
|
|
147
|
+
# Internal helpers (underscore-prefixed: not part of the public surface).
|
|
148
|
+
# ----------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
# @api private
|
|
151
|
+
def self._hex_hmac_sha1(key, message)
|
|
152
|
+
OpenSSL::HMAC.hexdigest('SHA1', key.to_s, message.to_s)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @api private
|
|
156
|
+
def self._b64_hmac_sha1(key, message)
|
|
157
|
+
Base64.strict_encode64(
|
|
158
|
+
OpenSSL::HMAC.digest('SHA1', key.to_s, message.to_s)
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# @api private
|
|
163
|
+
# Constant-time string compare. Returns false on any error so malformed
|
|
164
|
+
# inputs never raise.
|
|
165
|
+
def self._safe_eq(a, b)
|
|
166
|
+
Rack::Utils.secure_compare(a.to_s, b.to_s)
|
|
167
|
+
rescue StandardError
|
|
168
|
+
false
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# @api private
|
|
172
|
+
# Concatenate form params per Scheme B rules:
|
|
173
|
+
# - Sort by key, ASCII ascending.
|
|
174
|
+
# - For repeated keys (Array values OR multiple [k,v] pairs): keep
|
|
175
|
+
# original submission order, emit ``key + value`` once per occurrence.
|
|
176
|
+
# - Non-string values are stringified via ``to_s``.
|
|
177
|
+
def self._sorted_concat_params(params)
|
|
178
|
+
return '' if params.nil? || (params.respond_to?(:empty?) && params.empty?)
|
|
179
|
+
|
|
180
|
+
items = []
|
|
181
|
+
if params.is_a?(Hash)
|
|
182
|
+
params.each do |k, v|
|
|
183
|
+
if v.is_a?(Array)
|
|
184
|
+
v.each { |vi| items << [k.to_s, vi] }
|
|
185
|
+
else
|
|
186
|
+
items << [k.to_s, v]
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
elsif params.is_a?(Array)
|
|
190
|
+
params.each do |pair|
|
|
191
|
+
# Accept [k, v] pairs (the most common form).
|
|
192
|
+
next unless pair.is_a?(Array) && pair.length >= 2
|
|
193
|
+
|
|
194
|
+
items << [pair[0].to_s, pair[1]]
|
|
195
|
+
end
|
|
196
|
+
else
|
|
197
|
+
return ''
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Stable sort by key — preserves original order within repeated keys.
|
|
201
|
+
items = items.each_with_index.sort_by { |(k, _v), idx| [k, idx] }.map(&:first)
|
|
202
|
+
|
|
203
|
+
items.map { |k, v| "#{k}#{v.nil? ? '' : v}" }.join
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# @api private
|
|
207
|
+
# Best-effort parse of an x-www-form-urlencoded body. Returns an
|
|
208
|
+
# array of [key, value] pairs (preserving order, including duplicate
|
|
209
|
+
# keys). Returns [] if the body doesn't decode as form data.
|
|
210
|
+
def self._parse_form_body(raw_body)
|
|
211
|
+
return [] if raw_body.nil? || raw_body.empty?
|
|
212
|
+
|
|
213
|
+
pairs = []
|
|
214
|
+
raw_body.split('&').each do |chunk|
|
|
215
|
+
next if chunk.empty?
|
|
216
|
+
|
|
217
|
+
k, _eq, v = chunk.partition('=')
|
|
218
|
+
decoded_k = CGI.unescape(k)
|
|
219
|
+
decoded_v = CGI.unescape(v)
|
|
220
|
+
pairs << [decoded_k, decoded_v]
|
|
221
|
+
end
|
|
222
|
+
pairs
|
|
223
|
+
rescue StandardError
|
|
224
|
+
[]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# @api private
|
|
228
|
+
# Return the URL variants to try for Scheme B port normalization.
|
|
229
|
+
#
|
|
230
|
+
# - If the URL already has a non-standard port: just the input URL.
|
|
231
|
+
# - If https + no port: input URL AND url with ``:443``.
|
|
232
|
+
# - If http + no port: input URL AND url with ``:80``.
|
|
233
|
+
# - If https + ``:443`` / http + ``:80``: input URL AND url without port.
|
|
234
|
+
# - Otherwise (any explicit non-standard port): just the input URL.
|
|
235
|
+
def self._candidate_urls(url)
|
|
236
|
+
parsed = URI.parse(url)
|
|
237
|
+
host = parsed.host
|
|
238
|
+
return [url] if host.nil? || host.empty?
|
|
239
|
+
|
|
240
|
+
scheme = (parsed.scheme || '').downcase
|
|
241
|
+
standard = { 'http' => 80, 'https' => 443 }[scheme]
|
|
242
|
+
port = parsed.port
|
|
243
|
+
|
|
244
|
+
candidates = [url]
|
|
245
|
+
|
|
246
|
+
if standard && parsed.respond_to?(:default_port) && port == parsed.default_port &&
|
|
247
|
+
!_explicit_port?(url, scheme)
|
|
248
|
+
# No explicit port in original URL; URI added the default.
|
|
249
|
+
with_port_url = _build_url_with_port(parsed, standard)
|
|
250
|
+
candidates << with_port_url if with_port_url != url
|
|
251
|
+
elsif standard && port == standard && _explicit_port?(url, scheme)
|
|
252
|
+
# Original URL had the standard port spelled out — also try without.
|
|
253
|
+
without_port_url = _build_url_without_port(parsed)
|
|
254
|
+
candidates << without_port_url if without_port_url != url
|
|
255
|
+
end
|
|
256
|
+
# Else: non-standard explicit port — only try as-is.
|
|
257
|
+
|
|
258
|
+
candidates
|
|
259
|
+
rescue URI::InvalidURIError
|
|
260
|
+
[url]
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# @api private
|
|
264
|
+
# Heuristic: was a port explicitly written into the URL string?
|
|
265
|
+
# ``URI`` always populates ``port`` (with the default), so we have to
|
|
266
|
+
# look at the raw string.
|
|
267
|
+
def self._explicit_port?(url, _scheme)
|
|
268
|
+
# Look for ``:NNN`` between the host and the path / query / end.
|
|
269
|
+
# Avoid false positives in ``://``, userinfo, IPv6 brackets, etc.
|
|
270
|
+
no_scheme = url.sub(%r{\A[^:]+://}, '')
|
|
271
|
+
no_userinfo = no_scheme.sub(/\A[^@\/?#]*@/, '')
|
|
272
|
+
# Strip IPv6 zone if any: "[..]" then look after ]
|
|
273
|
+
if no_userinfo.start_with?('[')
|
|
274
|
+
after_bracket = no_userinfo.sub(/\A\[[^\]]*\]/, '')
|
|
275
|
+
!!(after_bracket =~ /\A:\d+/)
|
|
276
|
+
else
|
|
277
|
+
host_and_rest = no_userinfo
|
|
278
|
+
host_part, _sep, _rest = host_and_rest.partition(%r{[/?#]})
|
|
279
|
+
!!(host_part =~ /:\d+\z/)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# @api private
|
|
284
|
+
def self._build_url_with_port(parsed, port)
|
|
285
|
+
netloc_host = parsed.host.include?(':') ? "[#{parsed.host}]" : parsed.host
|
|
286
|
+
prefix = "#{parsed.scheme}://"
|
|
287
|
+
prefix += "#{parsed.userinfo}@" if parsed.userinfo
|
|
288
|
+
rest = +''
|
|
289
|
+
rest << (parsed.path || '')
|
|
290
|
+
rest << "?#{parsed.query}" if parsed.query
|
|
291
|
+
rest << "##{parsed.fragment}" if parsed.fragment
|
|
292
|
+
"#{prefix}#{netloc_host}:#{port}#{rest}"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# @api private
|
|
296
|
+
def self._build_url_without_port(parsed)
|
|
297
|
+
netloc_host = parsed.host.include?(':') ? "[#{parsed.host}]" : parsed.host
|
|
298
|
+
prefix = "#{parsed.scheme}://"
|
|
299
|
+
prefix += "#{parsed.userinfo}@" if parsed.userinfo
|
|
300
|
+
rest = +''
|
|
301
|
+
rest << (parsed.path || '')
|
|
302
|
+
rest << "?#{parsed.query}" if parsed.query
|
|
303
|
+
rest << "##{parsed.fragment}" if parsed.fragment
|
|
304
|
+
"#{prefix}#{netloc_host}#{rest}"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# @api private
|
|
308
|
+
# If URL has ``?bodySHA256=<hex>``, verify ``sha256_hex(raw_body)`` matches.
|
|
309
|
+
# Returns true if the param is absent (no constraint), or present and
|
|
310
|
+
# matches. Returns false only when the param is present and mismatches.
|
|
311
|
+
def self._check_body_sha256(url, raw_body)
|
|
312
|
+
parsed = URI.parse(url)
|
|
313
|
+
return true if parsed.query.nil? || parsed.query.empty?
|
|
314
|
+
|
|
315
|
+
# Use _parse_form_body for consistency (handles repeated keys etc).
|
|
316
|
+
qparams = _parse_form_body(parsed.query)
|
|
317
|
+
body_hash = qparams.find { |(k, _)| k == 'bodySHA256' }
|
|
318
|
+
return true if body_hash.nil?
|
|
319
|
+
|
|
320
|
+
actual = Digest::SHA256.hexdigest(raw_body.to_s)
|
|
321
|
+
_safe_eq(actual, body_hash[1])
|
|
322
|
+
rescue URI::InvalidURIError
|
|
323
|
+
true
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|