httparty 0.23.2 → 0.24.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20e6019fdfbb861c0ffe11f3cf52c76c9e404647e52007f3c7aa508d548c68fd
4
- data.tar.gz: 2f7fce01ab20c8d6c268e80a95fb69d5aa8f471addd3c7d53eb2fdcd9305fcf3
3
+ metadata.gz: d7d431b8f544d235a5d5c3521b230d418a94e98cb452f453f3a7890888a5c8d0
4
+ data.tar.gz: 6800438b8d0842331240337f1f088d05fee9d1a4aa7e0e155034aad76c6dd72c
5
5
  SHA512:
6
- metadata.gz: '08b4cf9fc625f8093c56ff34b2bbfc3c0ed5b5dd3e391112dc10244c0709a5dae10917d15c851ec6ad86882c07c3973556f1b45a7310aed9965c6b9e4bf45fe2'
7
- data.tar.gz: 1086fee06fe7fa50351b488d62247c34d9516c335c8defc545205c16b779e7a01578271f7a0b28ae495d35d2d6e50314d5d70e916cba84b4a8134ad14f26bdbe
6
+ metadata.gz: 00e476b359eb3ad3d03ac5665697d9591c3aefc81a58c9a74cb1c8ac6bad52627c1a8343b99c24ff8a82d01c128535fbaf660828031283ce29acb9ae81fb111b
7
+ data.tar.gz: 3c8c4a7c12c1c47036c9cc940cca0ec4c0ef48cc085b6ce68768aa37ee67ab93dc8f0e5d970bed0543fabe97470f9a21da9b1a2840b2ffc27176f588a81e74d6
@@ -59,4 +59,8 @@ module HTTParty
59
59
 
60
60
  # Exception that is raised when common network errors occur.
61
61
  class NetworkError < Foul; end
62
+
63
+ # Exception that is raised when an absolute URI is used that doesn't match
64
+ # the configured base_uri, which could indicate an SSRF attempt.
65
+ class UnsafeURIError < Foul; end
62
66
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'multipart_boundary'
4
+ require_relative 'streaming_multipart_body'
4
5
 
5
6
  module HTTParty
6
7
  class Request
@@ -30,6 +31,22 @@ module HTTParty
30
31
  params.respond_to?(:to_hash) && (force_multipart || has_file?(params))
31
32
  end
32
33
 
34
+ def streaming?
35
+ multipart? && has_file?(params)
36
+ end
37
+
38
+ def to_stream
39
+ return nil unless streaming?
40
+ StreamingMultipartBody.new(prepared_parts, boundary)
41
+ end
42
+
43
+ def prepared_parts
44
+ normalized_params = params.flat_map { |key, value| HashConversions.normalize_keys(key, value) }
45
+ normalized_params.map do |key, value|
46
+ [key, value, file?(value)]
47
+ end
48
+ end
49
+
33
50
  private
34
51
 
35
52
  # https://html.spec.whatwg.org/#multipart-form-data
@@ -42,20 +59,20 @@ module HTTParty
42
59
  def generate_multipart
43
60
  normalized_params = params.flat_map { |key, value| HashConversions.normalize_keys(key, value) }
44
61
 
45
- multipart = normalized_params.inject(''.dup) do |memo, (key, value)|
46
- memo << "--#{boundary}#{NEWLINE}"
47
- memo << %(Content-Disposition: form-data; name="#{key}")
62
+ multipart = normalized_params.inject(''.b) do |memo, (key, value)|
63
+ memo << "--#{boundary}#{NEWLINE}".b
64
+ memo << %(Content-Disposition: form-data; name="#{key}").b
48
65
  # value.path is used to support ActionDispatch::Http::UploadedFile
49
66
  # https://github.com/jnunemaker/httparty/pull/585
50
- memo << %(; filename="#{file_name(value).gsub(/["\r\n]/, MULTIPART_FORM_DATA_REPLACEMENT_TABLE)}") if file?(value)
51
- memo << NEWLINE
52
- memo << "Content-Type: #{content_type(value)}#{NEWLINE}" if file?(value)
53
- memo << NEWLINE
67
+ memo << %(; filename="#{file_name(value).gsub(/["\r\n]/, MULTIPART_FORM_DATA_REPLACEMENT_TABLE)}").b if file?(value)
68
+ memo << NEWLINE.b
69
+ memo << "Content-Type: #{content_type(value)}#{NEWLINE}".b if file?(value)
70
+ memo << NEWLINE.b
54
71
  memo << content_body(value)
55
- memo << NEWLINE
72
+ memo << NEWLINE.b
56
73
  end
57
74
 
58
- multipart << "--#{boundary}--#{NEWLINE}"
75
+ multipart << "--#{boundary}--#{NEWLINE}".b
59
76
  end
60
77
 
61
78
  def has_file?(value)
@@ -83,11 +100,12 @@ module HTTParty
83
100
  def content_body(object)
84
101
  if file?(object)
85
102
  object = (file = object).read
86
- object.force_encoding(Encoding::UTF_8) if object.respond_to?(:force_encoding)
103
+ object.force_encoding(Encoding::BINARY) if object.respond_to?(:force_encoding)
87
104
  file.rewind if file.respond_to?(:rewind)
105
+ object.to_s
106
+ else
107
+ object.to_s.b
88
108
  end
89
-
90
- object.to_s
91
109
  end
92
110
 
93
111
  def content_type(object)
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTParty
4
+ class Request
5
+ class StreamingMultipartBody
6
+ NEWLINE = "\r\n"
7
+ CHUNK_SIZE = 64 * 1024 # 64 KB chunks
8
+
9
+ def initialize(parts, boundary)
10
+ @parts = parts
11
+ @boundary = boundary
12
+ @part_index = 0
13
+ @state = :header
14
+ @current_file = nil
15
+ @header_buffer = nil
16
+ @header_offset = 0
17
+ @footer_sent = false
18
+ end
19
+
20
+ def size
21
+ @size ||= calculate_size
22
+ end
23
+
24
+ def read(length = nil, outbuf = nil)
25
+ outbuf = outbuf ? outbuf.replace(''.b) : ''.b
26
+
27
+ return read_all(outbuf) if length.nil?
28
+
29
+ while outbuf.bytesize < length
30
+ chunk = read_chunk(length - outbuf.bytesize)
31
+ break if chunk.nil?
32
+ outbuf << chunk
33
+ end
34
+
35
+ outbuf.empty? ? nil : outbuf
36
+ end
37
+
38
+ def rewind
39
+ @part_index = 0
40
+ @state = :header
41
+ @current_file = nil
42
+ @header_buffer = nil
43
+ @header_offset = 0
44
+ @footer_sent = false
45
+ @parts.each do |_key, value, _is_file|
46
+ value.rewind if value.respond_to?(:rewind)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def read_all(outbuf)
53
+ while (chunk = read_chunk(CHUNK_SIZE))
54
+ outbuf << chunk
55
+ end
56
+ outbuf.empty? ? nil : outbuf
57
+ end
58
+
59
+ def read_chunk(max_length)
60
+ loop do
61
+ return nil if @part_index >= @parts.size && @footer_sent
62
+
63
+ if @part_index >= @parts.size
64
+ @footer_sent = true
65
+ return "--#{@boundary}--#{NEWLINE}".b
66
+ end
67
+
68
+ key, value, is_file = @parts[@part_index]
69
+
70
+ case @state
71
+ when :header
72
+ chunk = read_header_chunk(key, value, is_file, max_length)
73
+ return chunk if chunk
74
+
75
+ when :body
76
+ chunk = read_body_chunk(value, is_file, max_length)
77
+ return chunk if chunk
78
+
79
+ when :newline
80
+ @state = :header
81
+ @part_index += 1
82
+ return NEWLINE.b
83
+ end
84
+ end
85
+ end
86
+
87
+ def read_header_chunk(key, value, is_file, max_length)
88
+ if @header_buffer.nil?
89
+ @header_buffer = build_part_header(key, value, is_file)
90
+ @header_offset = 0
91
+ end
92
+
93
+ remaining = @header_buffer.bytesize - @header_offset
94
+ if remaining > 0
95
+ chunk_size = [remaining, max_length].min
96
+ chunk = @header_buffer.byteslice(@header_offset, chunk_size)
97
+ @header_offset += chunk_size
98
+ return chunk
99
+ end
100
+
101
+ @header_buffer = nil
102
+ @header_offset = 0
103
+ @state = :body
104
+ nil
105
+ end
106
+
107
+ def read_body_chunk(value, is_file, max_length)
108
+ if is_file
109
+ chunk = read_file_chunk(value, max_length)
110
+ if chunk
111
+ return chunk
112
+ else
113
+ @current_file = nil
114
+ @state = :newline
115
+ return nil
116
+ end
117
+ else
118
+ @state = :newline
119
+ return value.to_s.b
120
+ end
121
+ end
122
+
123
+ def read_file_chunk(file, max_length)
124
+ chunk_size = [max_length, CHUNK_SIZE].min
125
+ chunk = file.read(chunk_size)
126
+ return nil if chunk.nil?
127
+ chunk.force_encoding(Encoding::BINARY) if chunk.respond_to?(:force_encoding)
128
+ chunk
129
+ end
130
+
131
+ def build_part_header(key, value, is_file)
132
+ header = "--#{@boundary}#{NEWLINE}".b
133
+ header << %(Content-Disposition: form-data; name="#{key}").b
134
+ if is_file
135
+ header << %(; filename="#{file_name(value).gsub(/["\r\n]/, replacement_table)}").b
136
+ header << NEWLINE.b
137
+ header << "Content-Type: #{content_type(value)}#{NEWLINE}".b
138
+ end
139
+ header << NEWLINE.b
140
+ header
141
+ end
142
+
143
+ def calculate_size
144
+ total = 0
145
+ @parts.each do |key, value, is_file|
146
+ total += build_part_header(key, value, is_file).bytesize
147
+ total += content_size(value, is_file)
148
+ total += NEWLINE.bytesize
149
+ end
150
+ total += "--#{@boundary}--#{NEWLINE}".bytesize
151
+ total
152
+ end
153
+
154
+ def content_size(value, is_file)
155
+ if is_file
156
+ if value.respond_to?(:size)
157
+ value.size
158
+ elsif value.respond_to?(:stat)
159
+ value.stat.size
160
+ else
161
+ value.read.bytesize.tap { value.rewind }
162
+ end
163
+ else
164
+ value.to_s.b.bytesize
165
+ end
166
+ end
167
+
168
+ def content_type(object)
169
+ return object.content_type if object.respond_to?(:content_type)
170
+ require 'mini_mime'
171
+ mime = MiniMime.lookup_by_filename(object.path)
172
+ mime ? mime.content_type : 'application/octet-stream'
173
+ end
174
+
175
+ def file_name(object)
176
+ object.respond_to?(:original_filename) ? object.original_filename : File.basename(object.path)
177
+ end
178
+
179
+ def replacement_table
180
+ @replacement_table ||= {
181
+ '"' => '%22',
182
+ "\r" => '%0D',
183
+ "\n" => '%0A'
184
+ }.freeze
185
+ end
186
+ end
187
+ end
188
+ end
@@ -113,6 +113,8 @@ module HTTParty
113
113
  new_uri = path.clone
114
114
  end
115
115
 
116
+ validate_uri_safety!(new_uri) unless redirect
117
+
116
118
  # avoid double query string on redirects [#12]
117
119
  unless redirect
118
120
  new_uri.query = query_string(new_uri)
@@ -245,8 +247,17 @@ module HTTParty
245
247
  if body.multipart?
246
248
  content_type = "multipart/form-data; boundary=#{body.boundary}"
247
249
  @raw_request['Content-Type'] = content_type
250
+ elsif options[:body].respond_to?(:to_hash) && !@raw_request['Content-Type']
251
+ @raw_request['Content-Type'] = 'application/x-www-form-urlencoded'
252
+ end
253
+
254
+ if body.streaming? && options[:stream_body] != false
255
+ stream = body.to_stream
256
+ @raw_request.body_stream = stream
257
+ @raw_request['Content-Length'] = stream.size.to_s
258
+ else
259
+ @raw_request.body = body.call
248
260
  end
249
- @raw_request.body = body.call
250
261
  end
251
262
 
252
263
  @raw_request.instance_variable_set(:@decode_content, decompress_content?)
@@ -433,5 +444,23 @@ module HTTParty
433
444
  assume_utf16_is_big_endian: assume_utf16_is_big_endian
434
445
  ).call
435
446
  end
447
+
448
+ def validate_uri_safety!(new_uri)
449
+ return if options[:skip_uri_validation]
450
+
451
+ configured_base_uri = options[:base_uri]
452
+ return unless configured_base_uri
453
+
454
+ normalized_base = options[:uri_adapter].parse(
455
+ HTTParty.normalize_base_uri(configured_base_uri)
456
+ )
457
+
458
+ return if new_uri.host == normalized_base.host
459
+
460
+ raise UnsafeURIError,
461
+ "Requested URI '#{new_uri}' has host '#{new_uri.host}' but the " \
462
+ "configured base_uri '#{normalized_base}' has host '#{normalized_base.host}'. " \
463
+ "This request could send credentials to an unintended server."
464
+ end
436
465
  end
437
466
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HTTParty
4
- VERSION = '0.23.2'
4
+ VERSION = '0.24.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: httparty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.23.2
4
+ version: 0.24.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Nunemaker
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2025-10-07 00:00:00.000000000 Z
12
+ date: 2025-12-28 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: csv
@@ -118,6 +118,7 @@ files:
118
118
  - lib/httparty/request.rb
119
119
  - lib/httparty/request/body.rb
120
120
  - lib/httparty/request/multipart_boundary.rb
121
+ - lib/httparty/request/streaming_multipart_body.rb
121
122
  - lib/httparty/response.rb
122
123
  - lib/httparty/response/headers.rb
123
124
  - lib/httparty/response_fragment.rb