client-api-builder 0.5.6 → 0.6.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.
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class LoremIpsumClient
2
4
  include ClientApiBuilder::Router
3
5
 
@@ -12,5 +14,5 @@ class LoremIpsumClient
12
14
  header 'Accept', 'application/json'
13
15
 
14
16
  # this creates a method called create_lorem_ipsum with 2 named arguments amont and what
15
- route :create_lorem_ipsum, '/feed/json', body: {amount: :amount, what: :what, start: 'yes', generate: 'Generate Lorem Ipsum'}
17
+ route :create_lorem_ipsum, '/feed/json', body: { amount: :amount, what: :what, start: 'yes', generate: 'Generate Lorem Ipsum' }
16
18
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  module ClientApiBuilder
4
4
  class Error < StandardError; end
5
+
5
6
  class UnexpectedResponse < Error
6
7
  attr_reader :response
7
8
 
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require 'active_support'
3
4
 
4
5
  # Purpose is to log all requests
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require 'active_support'
3
4
 
4
5
  # Purpose is to change the instrument_request to use ActiveSupport::Notifications.instrument
@@ -9,14 +10,15 @@ module ClientApiBuilder
9
10
  error = nil
10
11
  result = nil
11
12
  ActiveSupport::Notifications.instrument('client_api_builder.request', client: self) do
12
- begin
13
- result = yield
14
- rescue Exception => e
15
- error = e
16
- end
13
+ result = yield
14
+ rescue StandardError => e
15
+ # Use StandardError instead of Exception to allow SystemExit, Interrupt, etc. to propagate
16
+ error = e
17
17
  end
18
18
 
19
- raise(error) if error
19
+ # Re-raise with original backtrace preserved
20
+ raise(error, error.message, error.backtrace) if error
21
+
20
22
  result
21
23
  ensure
22
24
  @total_request_time = Time.now - start_time
@@ -17,15 +17,15 @@ module ClientApiBuilder
17
17
  end
18
18
 
19
19
  def self.get_instance_method(var)
20
- "\#{root_router.#{var}\}"
20
+ "\#{root_router.#{var}}"
21
21
  end
22
22
 
23
23
  def base_url
24
24
  self.class.base_url || root_router.base_url
25
25
  end
26
26
 
27
- def handle_response(response, options, &block)
28
- root_router.handle_response(response, options, &block)
27
+ def handle_response(response, options, &)
28
+ root_router.handle_response(response, options, &)
29
29
  end
30
30
  end
31
31
  end
@@ -1,9 +1,21 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require 'net/http'
4
+ require 'openssl'
3
5
 
4
6
  module ClientApiBuilder
5
7
  module NetHTTP
6
8
  module Request
9
+ # Allowed file modes for stream_to_file to prevent arbitrary mode injection
10
+ ALLOWED_FILE_MODES = %w[w wb a ab w+ wb+ a+ ab+].freeze
11
+
12
+ # Default connection options with secure SSL settings
13
+ DEFAULT_SECURE_OPTIONS = {
14
+ verify_mode: OpenSSL::SSL::VERIFY_PEER,
15
+ open_timeout: 30,
16
+ read_timeout: 60
17
+ }.freeze
18
+
7
19
  # Copied from https://ruby-doc.org/stdlib-2.7.1/libdoc/net/http/rdoc/Net/HTTP.html
8
20
  METHOD_TO_NET_HTTP_CLASS = {
9
21
  copy: Net::HTTP::Copy,
@@ -21,13 +33,17 @@ module ClientApiBuilder
21
33
  put: Net::HTTP::Put,
22
34
  trace: Net::HTTP::Trace,
23
35
  unlock: Net::HTTP::Unlock
24
- }
36
+ }.freeze
25
37
 
26
38
  def request(method:, uri:, body:, headers:, connection_options:)
27
39
  request = METHOD_TO_NET_HTTP_CLASS[method].new(uri.request_uri, headers)
28
40
  request.body = body if body
29
41
 
30
- Net::HTTP.start(uri.hostname, uri.port, connection_options.merge(use_ssl: uri.scheme == 'https')) do |http|
42
+ # Merge secure defaults, then user options, ensuring SSL verification is enabled for HTTPS
43
+ ssl_options = uri.scheme == 'https' ? DEFAULT_SECURE_OPTIONS.merge(use_ssl: true) : {}
44
+ merged_options = ssl_options.merge(connection_options)
45
+
46
+ Net::HTTP.start(uri.hostname, uri.port, merged_options) do |http|
31
47
  http.request(request) do |response|
32
48
  yield response if block_given?
33
49
  end
@@ -49,9 +65,25 @@ module ClientApiBuilder
49
65
  end
50
66
 
51
67
  def stream_to_file(method:, uri:, body:, headers:, connection_options:, file:)
52
- mode = connection_options.delete(:file_mode) || 'wb'
53
- File.open(file, mode) do |io|
54
- stream_to_io(method: method, uri: uri, body: body, headers: headers, connection_options: connection_options, io: io)
68
+ # Use dup to avoid mutating the original hash
69
+ opts = connection_options.dup
70
+ mode = opts.delete(:file_mode)
71
+
72
+ # Validate file mode - use whitelist approach
73
+ mode = if mode.nil?
74
+ 'wb'
75
+ elsif ALLOWED_FILE_MODES.include?(mode.to_s)
76
+ mode.to_s
77
+ else
78
+ raise ArgumentError, "Invalid file mode: #{mode.inspect}. Allowed modes: #{ALLOWED_FILE_MODES.join(', ')}"
79
+ end
80
+
81
+ # Validate file path - expand to absolute path and check for path traversal
82
+ expanded_path = File.expand_path(file)
83
+ raise ArgumentError, 'Invalid file path: potential path traversal detected' if file.to_s.include?('..') || expanded_path.include?("\0")
84
+
85
+ File.open(expanded_path, mode) do |io|
86
+ stream_to_io(method: method, uri: uri, body: body, headers: headers, connection_options: opts, io: io)
55
87
  end
56
88
  end
57
89
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  require 'cgi'
3
4
 
4
5
  module ClientApiBuilder
@@ -38,10 +39,10 @@ module ClientApiBuilder
38
39
  array_namespace = namespace ? "#{namespace}[#{escape(key.to_s)}][]" : "#{escape(key.to_s)}[]"
39
40
  query_params += to_query_from_array(value, array_namespace)
40
41
  when Hash
41
- hash_namespace = namespace ? "#{namespace}[#{escape(key.to_s)}]" : "#{escape(key.to_s)}"
42
+ hash_namespace = namespace ? "#{namespace}[#{escape(key.to_s)}]" : escape(key.to_s).to_s
42
43
  query_params += to_query_from_hash(value, hash_namespace)
43
44
  else
44
- query_name = namespace ? "#{namespace}[#{escape(key.to_s)}]" : "#{escape(key.to_s)}"
45
+ query_name = namespace ? "#{namespace}[#{escape(key.to_s)}]" : escape(key.to_s).to_s
45
46
  query_params << "#{query_name}#{name_value_separator}#{escape(value.to_s)}"
46
47
  end
47
48
  end
@@ -67,7 +68,12 @@ module ClientApiBuilder
67
68
  end
68
69
 
69
70
  def escape(str)
70
- custom_escape_proc ? custom_escape_proc.call(str) : CGI.escape(str)
71
+ return CGI.escape(str) unless custom_escape_proc
72
+
73
+ result = custom_escape_proc.call(str)
74
+ raise TypeError, "custom_escape_proc must return a String, got #{result.class}" unless result.is_a?(String)
75
+
76
+ result
71
77
  end
72
78
  end
73
79
  end