flow_chat 0.4.2 → 0.5.1

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.
@@ -2,6 +2,9 @@ module FlowChat
2
2
  module Simulator
3
3
  module Controller
4
4
  def flowchat_simulator
5
+ # Set simulator cookie for authentication
6
+ set_simulator_cookie
7
+
5
8
  respond_to do |format|
6
9
  format.html do
7
10
  render inline: simulator_view_template, layout: false, locals: simulator_locals
@@ -27,7 +30,7 @@ module FlowChat
27
30
  {
28
31
  "ussd" => {
29
32
  name: "USSD (Nalo)",
30
- description: "Local development USSD testing",
33
+ description: "USSD integration using Nalo",
31
34
  processor_type: "ussd",
32
35
  provider: "nalo",
33
36
  endpoint: "/ussd",
@@ -39,8 +42,8 @@ module FlowChat
39
42
  }
40
43
  },
41
44
  "whatsapp" => {
42
- name: "WhatsApp",
43
- description: "Local development WhatsApp testing",
45
+ name: "WhatsApp (Cloud API)",
46
+ description: "WhatsApp integration using Cloud API",
44
47
  processor_type: "whatsapp",
45
48
  provider: "cloud_api",
46
49
  endpoint: "/whatsapp/webhook",
@@ -49,8 +52,6 @@ module FlowChat
49
52
  settings: {
50
53
  phone_number: default_phone_number,
51
54
  contact_name: default_contact_name,
52
- verify_token: "local_verify_token",
53
- webhook_url: "http://localhost:3000/whatsapp/webhook"
54
55
  }
55
56
  }
56
57
  }
@@ -73,6 +74,34 @@ module FlowChat
73
74
  configurations: simulator_configurations
74
75
  }
75
76
  end
77
+
78
+ def set_simulator_cookie
79
+ # Get global simulator secret
80
+ simulator_secret = FlowChat::Config.simulator_secret
81
+
82
+ unless simulator_secret && !simulator_secret.empty?
83
+ raise StandardError, "Simulator secret not configured. Please set FlowChat::Config.simulator_secret to enable simulator mode."
84
+ end
85
+
86
+ # Generate timestamp-based signed cookie
87
+ timestamp = Time.now.to_i
88
+ message = "simulator:#{timestamp}"
89
+ signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), simulator_secret, message)
90
+
91
+ cookie_value = "#{timestamp}:#{signature}"
92
+
93
+ # Set secure cookie (valid for 24 hours)
94
+ cookies[:flowchat_simulator] = {
95
+ value: cookie_value,
96
+ expires: 24.hours.from_now,
97
+ secure: request.ssl?, # Only send over HTTPS in production
98
+ httponly: true, # Prevent XSS access
99
+ same_site: :lax # CSRF protection while allowing normal navigation
100
+ }
101
+ rescue => e
102
+ Rails.logger.warn "Failed to set simulator cookie: #{e.message}"
103
+ raise e # Re-raise the exception so it's not silently ignored
104
+ end
76
105
  end
77
106
  end
78
107
  end
@@ -1,3 +1,3 @@
1
1
  module FlowChat
2
- VERSION = "0.4.2"
2
+ VERSION = "0.5.1"
3
3
  end
@@ -7,8 +7,6 @@ require "securerandom"
7
7
  module FlowChat
8
8
  module Whatsapp
9
9
  class Client
10
- WHATSAPP_API_URL = "https://graph.facebook.com/v18.0"
11
-
12
10
  def initialize(config)
13
11
  @config = config
14
12
  end
@@ -134,7 +132,7 @@ module FlowChat
134
132
  end
135
133
 
136
134
  # Upload directly via HTTP
137
- uri = URI("#{WHATSAPP_API_URL}/#{@config.phone_number_id}/media")
135
+ uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{@config.phone_number_id}/media")
138
136
  http = Net::HTTP.new(uri.host, uri.port)
139
137
  http.use_ssl = true
140
138
 
@@ -289,7 +287,7 @@ module FlowChat
289
287
  # @param media_id [String] Media ID from WhatsApp
290
288
  # @return [String] Media URL or nil on error
291
289
  def get_media_url(media_id)
292
- uri = URI("#{WHATSAPP_API_URL}/#{media_id}")
290
+ uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{media_id}")
293
291
  http = Net::HTTP.new(uri.host, uri.port)
294
292
  http.use_ssl = true
295
293
 
@@ -389,7 +387,7 @@ module FlowChat
389
387
  # @param message_data [Hash] Message payload
390
388
  # @return [Hash] API response or nil on error
391
389
  def send_message_payload(message_data)
392
- uri = URI("#{WHATSAPP_API_URL}/#{@config.phone_number_id}/messages")
390
+ uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{@config.phone_number_id}/messages")
393
391
  http = Net::HTTP.new(uri.host, uri.port)
394
392
  http.use_ssl = true
395
393
 
@@ -2,7 +2,7 @@ module FlowChat
2
2
  module Whatsapp
3
3
  class Configuration
4
4
  attr_accessor :access_token, :phone_number_id, :verify_token, :app_id, :app_secret,
5
- :webhook_url, :webhook_verify_token, :business_account_id, :name
5
+ :webhook_verify_token, :business_account_id, :name, :skip_signature_validation
6
6
 
7
7
  # Class-level storage for named configurations
8
8
  @@configurations = {}
@@ -14,9 +14,9 @@ module FlowChat
14
14
  @verify_token = nil
15
15
  @app_id = nil
16
16
  @app_secret = nil
17
- @webhook_url = nil
18
17
  @webhook_verify_token = nil
19
18
  @business_account_id = nil
19
+ @skip_signature_validation = false
20
20
 
21
21
  register_as(name) if name.present?
22
22
  end
@@ -32,8 +32,8 @@ module FlowChat
32
32
  config.verify_token = credentials[:verify_token]
33
33
  config.app_id = credentials[:app_id]
34
34
  config.app_secret = credentials[:app_secret]
35
- config.webhook_url = credentials[:webhook_url]
36
35
  config.business_account_id = credentials[:business_account_id]
36
+ config.skip_signature_validation = credentials[:skip_signature_validation] || false
37
37
  else
38
38
  # Fallback to environment variables
39
39
  config.access_token = ENV["WHATSAPP_ACCESS_TOKEN"]
@@ -41,8 +41,8 @@ module FlowChat
41
41
  config.verify_token = ENV["WHATSAPP_VERIFY_TOKEN"]
42
42
  config.app_id = ENV["WHATSAPP_APP_ID"]
43
43
  config.app_secret = ENV["WHATSAPP_APP_SECRET"]
44
- config.webhook_url = ENV["WHATSAPP_WEBHOOK_URL"]
45
44
  config.business_account_id = ENV["WHATSAPP_BUSINESS_ACCOUNT_ID"]
45
+ config.skip_signature_validation = ENV["WHATSAPP_SKIP_SIGNATURE_VALIDATION"] == "true"
46
46
  end
47
47
 
48
48
  config
@@ -81,24 +81,25 @@ module FlowChat
81
81
  end
82
82
 
83
83
  def valid?
84
- access_token.present? && phone_number_id.present? && verify_token.present?
85
- end
86
-
87
- def webhook_configured?
88
- webhook_url.present? && verify_token.present?
84
+ access_token && !access_token.to_s.empty? && phone_number_id && !phone_number_id.to_s.empty? && verify_token && !verify_token.to_s.empty?
89
85
  end
90
86
 
91
87
  # API endpoints
92
88
  def messages_url
93
- "https://graph.facebook.com/v18.0/#{phone_number_id}/messages"
89
+ "#{FlowChat::Config.whatsapp.api_base_url}/#{phone_number_id}/messages"
94
90
  end
95
91
 
96
92
  def media_url(media_id)
97
- "https://graph.facebook.com/v18.0/#{media_id}"
93
+ "#{FlowChat::Config.whatsapp.api_base_url}/#{media_id}"
98
94
  end
99
95
 
100
96
  def phone_numbers_url
101
- "https://graph.facebook.com/v18.0/#{business_account_id}/phone_numbers"
97
+ "#{FlowChat::Config.whatsapp.api_base_url}/#{business_account_id}/phone_numbers"
98
+ end
99
+
100
+ # Get API base URL from global config
101
+ def api_base_url
102
+ FlowChat::Config.whatsapp.api_base_url
102
103
  end
103
104
 
104
105
  # Headers for API requests
@@ -1,13 +1,15 @@
1
1
  require "net/http"
2
2
  require "json"
3
3
  require "phonelib"
4
+ require "openssl"
4
5
 
5
6
  module FlowChat
6
7
  module Whatsapp
8
+ # Configuration-related errors
9
+ class ConfigurationError < StandardError; end
10
+
7
11
  module Gateway
8
12
  class CloudApi
9
- WHATSAPP_API_URL = "https://graph.facebook.com/v18.0"
10
-
11
13
  def initialize(app, config = nil)
12
14
  @app = app
13
15
  @config = config || FlowChat::Whatsapp::Configuration.from_credentials
@@ -37,8 +39,8 @@ module FlowChat
37
39
  private
38
40
 
39
41
  def determine_message_handler(context)
40
- # Check for simulator parameter in request (highest priority)
41
- if context["simulator_mode"] || context.controller.request.params["simulator_mode"]
42
+ # Check if simulator mode was already detected and set in context
43
+ if context["simulator_mode"]
42
44
  return :simulator
43
45
  end
44
46
 
@@ -61,15 +63,30 @@ module FlowChat
61
63
 
62
64
  def handle_webhook(context)
63
65
  controller = context.controller
64
- body = JSON.parse(controller.request.body.read)
65
-
66
- # Check for simulator mode parameter in request
67
- if body.dig("simulator_mode") || controller.request.params["simulator_mode"]
66
+
67
+ # Parse body
68
+ begin
69
+ parse_request_body(controller.request)
70
+ rescue JSON::ParserError => e
71
+ Rails.logger.warn "Failed to parse webhook body: #{e.message}"
72
+ return controller.head :bad_request
73
+ end
74
+
75
+ # Check for simulator mode parameter in request (before validation)
76
+ # But only enable if valid simulator token is provided
77
+ is_simulator_mode = simulate?(context)
78
+ if is_simulator_mode
68
79
  context["simulator_mode"] = true
69
80
  end
70
81
 
82
+ # Validate webhook signature for security (skip for simulator mode)
83
+ unless is_simulator_mode || valid_webhook_signature?(controller.request)
84
+ Rails.logger.warn "Invalid webhook signature received"
85
+ return controller.head :unauthorized
86
+ end
87
+
71
88
  # Extract message data from WhatsApp webhook
72
- entry = body.dig("entry", 0)
89
+ entry = @body.dig("entry", 0)
73
90
  return controller.head :ok unless entry
74
91
 
75
92
  changes = entry.dig("changes", 0)
@@ -116,6 +133,57 @@ module FlowChat
116
133
  controller.head :ok
117
134
  end
118
135
 
136
+ # Validate webhook signature to ensure request comes from WhatsApp
137
+ def valid_webhook_signature?(request)
138
+ # Check if signature validation is explicitly disabled
139
+ if @config.skip_signature_validation
140
+ return true
141
+ end
142
+
143
+ # Require app_secret for signature validation
144
+ unless @config.app_secret && !@config.app_secret.empty?
145
+ raise FlowChat::Whatsapp::ConfigurationError,
146
+ "WhatsApp app_secret is required for webhook signature validation. " \
147
+ "Either configure app_secret or set skip_signature_validation=true to explicitly disable validation."
148
+ end
149
+
150
+ signature_header = request.headers["X-Hub-Signature-256"]
151
+ return false unless signature_header
152
+
153
+ # Extract signature from header (format: "sha256=<signature>")
154
+ expected_signature = signature_header.sub("sha256=", "")
155
+
156
+ # Get raw request body
157
+ request.body.rewind
158
+ body = request.body.read
159
+ request.body.rewind
160
+
161
+ # Calculate HMAC signature
162
+ calculated_signature = OpenSSL::HMAC.hexdigest(
163
+ OpenSSL::Digest.new("sha256"),
164
+ @config.app_secret,
165
+ body
166
+ )
167
+
168
+ # Compare signatures using secure comparison to prevent timing attacks
169
+ secure_compare(expected_signature, calculated_signature)
170
+ rescue FlowChat::Whatsapp::ConfigurationError
171
+ raise
172
+ rescue => e
173
+ Rails.logger.error "Error validating webhook signature: #{e.message}"
174
+ false
175
+ end
176
+
177
+ # Secure string comparison to prevent timing attacks
178
+ def secure_compare(a, b)
179
+ return false unless a.bytesize == b.bytesize
180
+
181
+ l = a.unpack("C*")
182
+ res = 0
183
+ b.each_byte { |byte| res |= byte ^ l.shift }
184
+ res == 0
185
+ end
186
+
119
187
  def extract_message_content(message, context)
120
188
  case message["type"]
121
189
  when "text"
@@ -205,6 +273,50 @@ module FlowChat
205
273
  nil
206
274
  end
207
275
  end
276
+
277
+ def simulate?(context)
278
+ # Check if simulator mode is enabled for this processor
279
+ return false unless context["enable_simulator"]
280
+
281
+ # Then check if simulator mode is requested and valid
282
+ @body.dig("simulator_mode") && valid_simulator_cookie?(context)
283
+ end
284
+
285
+ def valid_simulator_cookie?(context)
286
+ simulator_secret = FlowChat::Config.simulator_secret
287
+ return false unless simulator_secret && !simulator_secret.empty?
288
+
289
+ # Check for simulator cookie
290
+ request = context.controller.request
291
+ simulator_cookie = request.cookies["flowchat_simulator"]
292
+ return false unless simulator_cookie
293
+
294
+ # Verify the cookie is a valid HMAC signature
295
+ # Cookie format: "timestamp:signature" where signature = HMAC(simulator_secret, "simulator:timestamp")
296
+ begin
297
+ timestamp_str, signature = simulator_cookie.split(":", 2)
298
+ return false unless timestamp_str && signature
299
+
300
+ # Check timestamp is recent (within 24 hours for reasonable session duration)
301
+ timestamp = timestamp_str.to_i
302
+ return false if timestamp <= 0
303
+ return false if (Time.now.to_i - timestamp).abs > 86400 # 24 hours
304
+
305
+ # Calculate expected signature
306
+ message = "simulator:#{timestamp_str}"
307
+ expected_signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), simulator_secret, message)
308
+
309
+ # Use secure comparison
310
+ secure_compare(signature, expected_signature)
311
+ rescue => e
312
+ Rails.logger.warn "Invalid simulator cookie format: #{e.message}"
313
+ false
314
+ end
315
+ end
316
+
317
+ def parse_request_body(request)
318
+ @body ||= JSON.parse(request.body.read)
319
+ end
208
320
  end
209
321
  end
210
322
  end
@@ -86,7 +86,7 @@ module FlowChat
86
86
  # Create a new template (requires approval from Meta)
87
87
  def create_template(name:, category:, language: "en_US", components: [])
88
88
  business_account_id = @config.business_account_id
89
- uri = URI("https://graph.facebook.com/v18.0/#{business_account_id}/message_templates")
89
+ uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{business_account_id}/message_templates")
90
90
 
91
91
  template_data = {
92
92
  name: name,
@@ -110,7 +110,7 @@ module FlowChat
110
110
  # List all templates
111
111
  def list_templates
112
112
  business_account_id = @config.business_account_id
113
- uri = URI("https://graph.facebook.com/v18.0/#{business_account_id}/message_templates")
113
+ uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{business_account_id}/message_templates")
114
114
 
115
115
  http = Net::HTTP.new(uri.host, uri.port)
116
116
  http.use_ssl = true
@@ -124,7 +124,7 @@ module FlowChat
124
124
 
125
125
  # Get template status
126
126
  def template_status(template_id)
127
- uri = URI("https://graph.facebook.com/v18.0/#{template_id}")
127
+ uri = URI("#{FlowChat::Config.whatsapp.api_base_url}/#{template_id}")
128
128
 
129
129
  http = Net::HTTP.new(uri.host, uri.port)
130
130
  http.use_ssl = true
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flow_chat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-06-04 00:00:00.000000000 Z
11
+ date: 2025-06-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: zeitwerk
@@ -95,11 +95,13 @@ files:
95
95
  - LICENSE.txt
96
96
  - README.md
97
97
  - Rakefile
98
+ - SECURITY.md
98
99
  - bin/console
99
100
  - bin/setup
100
101
  - examples/initializer.rb
101
102
  - examples/media_prompts_examples.rb
102
103
  - examples/multi_tenant_whatsapp_controller.rb
104
+ - examples/simulator_controller.rb
103
105
  - examples/ussd_controller.rb
104
106
  - examples/whatsapp_controller.rb
105
107
  - examples/whatsapp_media_examples.rb