iostreams 1.11.0 → 2.1.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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -13
  3. data/Rakefile +52 -0
  4. data/docs/CLAUDE.md +9 -0
  5. data/docs/config.md +157 -0
  6. data/docs/copy_files.md +75 -0
  7. data/docs/extensions.md +111 -0
  8. data/docs/formats.md +188 -0
  9. data/docs/index.md +388 -0
  10. data/docs/path.md +652 -0
  11. data/docs/pgp.md +436 -0
  12. data/docs/streams.md +337 -0
  13. data/docs/tutorial.md +483 -0
  14. data/docs/upgrading.md +217 -0
  15. data/lib/io_streams/builder.rb +71 -11
  16. data/lib/io_streams/bzip2/reader.rb +25 -2
  17. data/lib/io_streams/bzip2/writer.rb +26 -2
  18. data/lib/io_streams/encode/reader.rb +6 -2
  19. data/lib/io_streams/encode/writer.rb +9 -5
  20. data/lib/io_streams/errors.rb +4 -0
  21. data/lib/io_streams/gzip/reader.rb +5 -1
  22. data/lib/io_streams/gzip/writer.rb +11 -2
  23. data/lib/io_streams/io_streams.rb +156 -20
  24. data/lib/io_streams/line/reader.rb +9 -4
  25. data/lib/io_streams/line/writer.rb +1 -1
  26. data/lib/io_streams/path.rb +117 -8
  27. data/lib/io_streams/paths/file.rb +57 -11
  28. data/lib/io_streams/paths/http.rb +123 -9
  29. data/lib/io_streams/paths/matcher.rb +3 -3
  30. data/lib/io_streams/paths/s3.rb +69 -18
  31. data/lib/io_streams/paths/sftp/net_ssh.rb +104 -0
  32. data/lib/io_streams/paths/sftp.rb +103 -64
  33. data/lib/io_streams/pgp/reader.rb +63 -10
  34. data/lib/io_streams/pgp/writer.rb +111 -30
  35. data/lib/io_streams/pgp.rb +256 -71
  36. data/lib/io_streams/reader.rb +14 -5
  37. data/lib/io_streams/record/reader.rb +75 -6
  38. data/lib/io_streams/record/writer.rb +3 -4
  39. data/lib/io_streams/row/reader.rb +1 -1
  40. data/lib/io_streams/row/writer.rb +1 -1
  41. data/lib/io_streams/stream.rb +48 -37
  42. data/lib/io_streams/symmetric_encryption/reader.rb +6 -2
  43. data/lib/io_streams/symmetric_encryption/writer.rb +8 -4
  44. data/lib/io_streams/tabular/header.rb +49 -10
  45. data/lib/io_streams/tabular/parser/array.rb +0 -10
  46. data/lib/io_streams/tabular/parser/base.rb +10 -0
  47. data/lib/io_streams/tabular/parser/csv.rb +9 -36
  48. data/lib/io_streams/tabular/parser/fixed.rb +8 -6
  49. data/lib/io_streams/tabular/parser/psv.rb +6 -14
  50. data/lib/io_streams/tabular.rb +5 -10
  51. data/lib/io_streams/utils.rb +34 -2
  52. data/lib/io_streams/version.rb +1 -1
  53. data/lib/io_streams/writer.rb +16 -7
  54. data/lib/io_streams/xlsx/reader.rb +6 -2
  55. data/lib/io_streams/zip/reader.rb +4 -0
  56. data/lib/io_streams/zip/writer.rb +26 -10
  57. data/lib/iostreams.rb +0 -1
  58. metadata +46 -112
  59. data/lib/io_streams/deprecated.rb +0 -216
  60. data/lib/io_streams/tabular/utility/csv_row.rb +0 -105
  61. data/test/builder_test.rb +0 -311
  62. data/test/bzip2_reader_test.rb +0 -27
  63. data/test/bzip2_writer_test.rb +0 -56
  64. data/test/deprecated_test.rb +0 -121
  65. data/test/encode_reader_test.rb +0 -51
  66. data/test/encode_writer_test.rb +0 -90
  67. data/test/files/embedded_lines_test.csv +0 -7
  68. data/test/files/multiple_files.zip +0 -0
  69. data/test/files/spreadsheet.xlsx +0 -0
  70. data/test/files/test.csv +0 -4
  71. data/test/files/test.json +0 -3
  72. data/test/files/test.psv +0 -4
  73. data/test/files/text file.txt +0 -3
  74. data/test/files/text.txt +0 -3
  75. data/test/files/text.txt.bz2 +0 -0
  76. data/test/files/text.txt.gz +0 -0
  77. data/test/files/text.txt.gz.zip +0 -0
  78. data/test/files/text.zip +0 -0
  79. data/test/files/text.zip.gz +0 -0
  80. data/test/files/unclosed_quote_large_test.csv +0 -1658
  81. data/test/files/unclosed_quote_test.csv +0 -4
  82. data/test/files/unclosed_quote_test2.csv +0 -3
  83. data/test/gzip_reader_test.rb +0 -27
  84. data/test/gzip_writer_test.rb +0 -52
  85. data/test/io_streams_test.rb +0 -132
  86. data/test/line_reader_test.rb +0 -325
  87. data/test/line_writer_test.rb +0 -59
  88. data/test/minimal_file_reader.rb +0 -25
  89. data/test/path_test.rb +0 -55
  90. data/test/paths/file_test.rb +0 -213
  91. data/test/paths/http_test.rb +0 -34
  92. data/test/paths/matcher_test.rb +0 -120
  93. data/test/paths/s3_test.rb +0 -220
  94. data/test/paths/sftp_test.rb +0 -106
  95. data/test/pgp_reader_test.rb +0 -46
  96. data/test/pgp_test.rb +0 -267
  97. data/test/pgp_writer_test.rb +0 -130
  98. data/test/record_reader_test.rb +0 -60
  99. data/test/record_writer_test.rb +0 -82
  100. data/test/row_reader_test.rb +0 -35
  101. data/test/row_writer_test.rb +0 -56
  102. data/test/stream_test.rb +0 -577
  103. data/test/tabular_test.rb +0 -338
  104. data/test/test_helper.rb +0 -40
  105. data/test/utils_test.rb +0 -20
  106. data/test/xlsx_reader_test.rb +0 -37
  107. data/test/zip_reader_test.rb +0 -53
  108. data/test/zip_writer_test.rb +0 -48
@@ -1,5 +1,9 @@
1
1
  module IOStreams
2
2
  class Path < IOStreams::Stream
3
+ # Stream option names whose values must not be displayed, such as `passphrase` or `signer_passphrase`.
4
+ SENSITIVE_OPTION = /pass(phrase|word)|secret/i
5
+ private_constant :SENSITIVE_OPTION
6
+
3
7
  attr_accessor :path
4
8
 
5
9
  def initialize(path)
@@ -21,7 +25,7 @@ module IOStreams
21
25
 
22
26
  new_path = dup
23
27
  new_path.builder = nil
24
- new_path.path = relative.start_with?(path) ? relative : ::File.join(path, relative)
28
+ new_path.path = contains?(relative) ? relative : ::File.join(path, relative)
25
29
  new_path
26
30
  end
27
31
 
@@ -41,7 +45,7 @@ module IOStreams
41
45
  # Runs the pattern from the current path, returning the complete path for located files.
42
46
  #
43
47
  # See IOStreams::Paths::File.each for arguments.
44
- def each_child(pattern = "*", **args, &block)
48
+ def each_child(pattern = "*", **args, &)
45
49
  raise NotImplementedError
46
50
  end
47
51
 
@@ -57,6 +61,24 @@ module IOStreams
57
61
  path
58
62
  end
59
63
 
64
+ # See Stream#reader.
65
+ #
66
+ # Raises [IOStreams::Errors::AccessDenied] when this path is not within any of the allowed paths,
67
+ # see `IOStreams.add_allowed_path`.
68
+ def reader(...)
69
+ authorize!
70
+ super
71
+ end
72
+
73
+ # See Stream#writer.
74
+ #
75
+ # Raises [IOStreams::Errors::AccessDenied] when this path is not within any of the allowed paths,
76
+ # see `IOStreams.add_allowed_path`.
77
+ def writer(...)
78
+ authorize!
79
+ super
80
+ end
81
+
60
82
  # Removes the last element of the path, the file name, before creating the entire path.
61
83
  # Returns self
62
84
  def mkpath
@@ -82,9 +104,9 @@ module IOStreams
82
104
  end
83
105
 
84
106
  # Cleanup an incomplete write to the target "file" if the copy fails.
85
- # rubocop:disable Lint/SuppressedException
107
+ # rubocop:disable-next Lint/SuppressedException
86
108
  def copy_from(source, **args)
87
- super(source, **args)
109
+ super
88
110
  rescue StandardError => e
89
111
  begin
90
112
  delete
@@ -92,7 +114,6 @@ module IOStreams
92
114
  end
93
115
  raise(e)
94
116
  end
95
- # rubocop:enable Lint/SuppressedException
96
117
 
97
118
  # Moves the file by copying it to the new path and then deleting the current path.
98
119
  # Returns [IOStreams::Path] the target path.
@@ -190,15 +211,103 @@ module IOStreams
190
211
 
191
212
  def inspect
192
213
  str = "#<#{self.class.name}:#{path}"
193
- str << " @builder=#{builder.streams.inspect}" if builder.streams
194
- str << " @options=#{builder.options.inspect}" if builder.options
195
- str << " pipeline=#{pipeline.inspect}>"
214
+ str << " @builder=#{redact(builder.streams).inspect}" if builder.streams
215
+ str << " @options=#{redact(builder.options).inspect}" if builder.options
216
+ str << " pipeline=#{redact(pipeline).inspect}>"
217
+ end
218
+
219
+ protected
220
+
221
+ # Raises [IOStreams::Errors::AccessDenied] when allowed paths have been added, see `IOStreams.add_allowed_path`,
222
+ # and this path is not within any of them.
223
+ def authorize!
224
+ return if IOStreams.allowed_paths.empty? || (@permitted_path && @permitted_path == path)
225
+
226
+ authorize_location!(allowed_location)
227
+ end
228
+
229
+ # Returns [true|false] whether this path is within the allowed paths, see `#authorize!`.
230
+ def allowed?
231
+ authorize!
232
+ true
233
+ rescue Errors::AccessDenied
234
+ false
196
235
  end
197
236
 
198
237
  private
199
238
 
239
+ # Returns [String] the normalized location of this path, which is compared against the allowed paths.
240
+ #
241
+ # Each path class that can be used with allowed paths overrides this method. Without it every path
242
+ # of that class is denied once allowed paths have been added.
243
+ #
244
+ # Raises [IOStreams::Errors::AccessDenied] when the location cannot be determined.
245
+ def allowed_location
246
+ raise(Errors::AccessDenied, "Access denied: #{self.class.name} does not support allowed paths")
247
+ end
248
+
249
+ # Raises [IOStreams::Errors::AccessDenied] when the supplied location, see `#allowed_location`,
250
+ # is not within any of the allowed paths.
251
+ def authorize_location!(location)
252
+ return if IOStreams.allowed_paths.any? { |allowed_path| within?(location, allowed_path) }
253
+
254
+ raise(Errors::AccessDenied, "Access denied to #{location}: it is not within any of the allowed paths")
255
+ end
256
+
257
+ # Returns [String] the supplied path with `.`, `..` and repeated `/` resolved, the way a remote server
258
+ # resolves them, without accessing it. The path always starts with `/`, and `..` cannot go above it.
259
+ def normalize_path(name)
260
+ segments = []
261
+ name.split("/").each do |segment|
262
+ case segment
263
+ when "", "."
264
+ next
265
+ when ".."
266
+ segments.pop
267
+ else
268
+ segments << segment
269
+ end
270
+ end
271
+ "/#{segments.join('/')}"
272
+ end
273
+
274
+ # Returns [true|false] whether a child found by `#each_child` is within the allowed paths, logging it when it is not.
275
+ def allowed_child?(child)
276
+ return true if child.allowed?
277
+
278
+ IOStreams.logger&.warn("Skipping #{child} since it is not within any of the allowed paths")
279
+ false
280
+ end
281
+
282
+ # Allows this exact path regardless of the allowed paths, for paths created by IOStreams itself such as temp files.
283
+ # Returns self
284
+ def permit!
285
+ @permitted_path = path
286
+ self
287
+ end
288
+
289
+ # Returns [Hash<Symbol:Hash>] the streams with the values of sensitive options replaced.
290
+ def redact(streams)
291
+ streams.transform_values do |options|
292
+ next options unless options.is_a?(Hash)
293
+
294
+ options.to_h { |name, value| [name, name.to_s.match?(SENSITIVE_OPTION) ? "[FILTERED]" : value] }
295
+ end
296
+ end
297
+
200
298
  def builder
201
299
  @builder ||= IOStreams::Builder.new(path)
202
300
  end
301
+
302
+ # Returns [true|false] whether the supplied path is this path, or is within this path.
303
+ # For example "a/b" contains "a/b/c.csv", but not "a/bc.csv".
304
+ def contains?(other)
305
+ path.empty? || within?(other, path)
306
+ end
307
+
308
+ # Returns [true|false] whether the path `child` is `parent`, or is within `parent`.
309
+ def within?(child, parent)
310
+ child == parent || child.start_with?(parent.end_with?("/") ? parent : "#{parent}/")
311
+ end
203
312
  end
204
313
  end
@@ -15,16 +15,16 @@ module IOStreams
15
15
  # Examples:
16
16
  #
17
17
  # # Case Insensitive file name lookup:
18
- # IOStreams.path("ruby").glob("r*.md") { |name| puts name }
18
+ # IOStreams.path("ruby").each_child("r*.md") { |path| puts path }
19
19
  #
20
20
  # # Case Sensitive file name lookup:
21
- # IOStreams.path("ruby").each("R*.md", case_sensitive: true) { |name| puts name }
21
+ # IOStreams.path("ruby").each_child("R*.md", case_sensitive: true) { |path| puts path }
22
22
  #
23
23
  # # Also return the names of directories found during the search:
24
- # IOStreams.path("ruby").each("R*.md", directories: true) { |name| puts name }
24
+ # IOStreams.path("ruby").each_child("R*.md", directories: true) { |path| puts path }
25
25
  #
26
26
  # # Case Insensitive recursive file name lookup:
27
- # IOStreams.path("ruby").glob("**/*.md") { |name| puts name }
27
+ # IOStreams.path("ruby").each_child("**/*.md") { |path| puts path }
28
28
  #
29
29
  # Parameters:
30
30
  # pattern [String]
@@ -77,9 +77,9 @@ module IOStreams
77
77
  # "\a" "a" true # escaped ordinary remains ordinary
78
78
  # "[\?]" "?" true # can escape inside bracket expression
79
79
  #
80
- # "*" ".profile" false # wildcard doesn't match leading
81
- # "*" ".profile" true # period by default.
82
- # ".*" ".profile" true {hidden: true}
80
+ # "*" ".profile" false # wildcard doesn't match leading period by default
81
+ # "*" ".profile" true # unless hidden is enabled {hidden: true}
82
+ # ".*" ".profile" true # leading period is explicit
83
83
  #
84
84
  # "**/*.rb" "main.rb" false
85
85
  # "**/*.rb" "./main.rb" false
@@ -94,6 +94,8 @@ module IOStreams
94
94
  case_sensitive: case_sensitive, directories: directories, hidden: hidden)
95
95
  end
96
96
 
97
+ authorize!
98
+
97
99
  flags = 0
98
100
  flags |= ::File::FNM_CASEFOLD unless case_sensitive
99
101
  flags |= ::File::FNM_DOTMATCH if hidden
@@ -116,7 +118,8 @@ module IOStreams
116
118
  results.each do |full_path|
117
119
  next if !directories && ::File.directory?(full_path)
118
120
 
119
- yield(self.class.new(full_path))
121
+ child = self.class.new(full_path)
122
+ yield(child) if allowed_child?(child)
120
123
  end
121
124
  end
122
125
 
@@ -128,6 +131,8 @@ module IOStreams
128
131
  target = IOStreams.new(target_path)
129
132
  return super(target) unless target.is_a?(self.class)
130
133
 
134
+ authorize!
135
+ target.authorize!
131
136
  target.mkpath
132
137
  # In case the file is being moved across partitions
133
138
  FileUtils.move(path, target.to_s)
@@ -135,25 +140,30 @@ module IOStreams
135
140
  end
136
141
 
137
142
  def mkpath
143
+ authorize!
138
144
  dir = ::File.dirname(path)
139
- FileUtils.mkdir_p(dir) unless ::File.exist?(dir)
145
+ FileUtils.mkdir_p(dir)
140
146
  self
141
147
  end
142
148
 
143
149
  def mkdir
144
- FileUtils.mkdir_p(path) unless ::File.exist?(path)
150
+ authorize!
151
+ FileUtils.mkdir_p(path)
145
152
  self
146
153
  end
147
154
 
148
155
  def exist?
156
+ authorize!
149
157
  ::File.exist?(path)
150
158
  end
151
159
 
152
160
  def size
161
+ authorize!
153
162
  ::File.size(path)
154
163
  end
155
164
 
156
165
  def delete
166
+ authorize!
157
167
  return self unless exist?
158
168
 
159
169
  ::File.directory?(path) ? Dir.delete(path) : ::File.unlink(path)
@@ -161,6 +171,7 @@ module IOStreams
161
171
  end
162
172
 
163
173
  def delete_all
174
+ authorize!
164
175
  return self unless exist?
165
176
 
166
177
  ::File.directory?(path) ? FileUtils.remove_dir(path) : ::File.unlink(path)
@@ -169,11 +180,46 @@ module IOStreams
169
180
 
170
181
  # Returns the real path by stripping `.`, `..` and expands any symlinks.
171
182
  def realpath
183
+ authorize!
172
184
  self.class.new(::File.realpath(path))
173
185
  end
174
186
 
175
187
  private
176
188
 
189
+ # Returns [String] the real path of this file, following any symbolic links, which is compared
190
+ # against the allowed paths.
191
+ #
192
+ # The part of the path that does not exist yet, for example a file that is about to be written,
193
+ # is appended to the real path of the part that does exist. It cannot contain `.` or `..`, since
194
+ # what they refer to depends on directories that have not been created yet.
195
+ def allowed_location
196
+ existing = ::File.absolute_path?(path) ? path : ::File.join(Dir.pwd, path)
197
+ missing = []
198
+ until present?(existing)
199
+ parent = ::File.dirname(existing)
200
+ break if parent == existing
201
+
202
+ missing.unshift(::File.basename(existing))
203
+ existing = parent
204
+ end
205
+
206
+ if missing.intersect?([".", ".."])
207
+ raise(Errors::AccessDenied, "Access denied to #{path}: '.' and '..' are not allowed after a missing directory")
208
+ end
209
+
210
+ ::File.join(::File.realpath(existing), *missing)
211
+ rescue SystemCallError => e
212
+ raise(Errors::AccessDenied, "Access denied to #{path}: #{e.message}")
213
+ end
214
+
215
+ # Returns [true|false] whether the file, directory or symbolic link exists, without following the link.
216
+ def present?(file_name)
217
+ ::File.lstat(file_name)
218
+ true
219
+ rescue SystemCallError
220
+ false
221
+ end
222
+
177
223
  # Read from file
178
224
  def stream_reader(&block)
179
225
  ::File.open(path, "rb") { |io| builder.reader(io, &block) }
@@ -189,7 +235,7 @@ module IOStreams
189
235
  begin
190
236
  ::File.open(path, "wb") { |io| builder.writer(io, &block) }
191
237
  rescue StandardError => e
192
- ::File.unlink(path) if ::File.exist?(path)
238
+ ::FileUtils.rm_f(path)
193
239
  raise(e)
194
240
  end
195
241
  end
@@ -18,6 +18,11 @@ module IOStreams
18
18
  # Full url showing all the optional elements that can be set via the url:
19
19
  # https://username:password@hostname/path/file_name
20
20
  #
21
+ # SECURITY WARNING:
22
+ # A username and password supplied in the url remain part of it, so `#to_s` and `#url`
23
+ # return them, as does any log or error message that includes the path.
24
+ # Supply them with the `username:` and `password:` arguments instead.
25
+ #
21
26
  # username: [String]
22
27
  # When supplied, basic authentication is used with the username and password.
23
28
  #
@@ -26,7 +31,32 @@ module IOStreams
26
31
  #
27
32
  # http_redirect_count: [Integer]
28
33
  # Maximum number of http redirects to follow.
29
- def initialize(url, username: nil, password: nil, http_redirect_count: 10, parameters: nil)
34
+ # Set to 0 to disable following redirects entirely.
35
+ # Default: 10
36
+ #
37
+ # allow_hosts: [String | Array<String>]
38
+ # Optional allow-list of host names that may be contacted, applied to the
39
+ # supplied url and to every redirect that is followed.
40
+ # When supplied, a request to any other host raises CommunicationsFailure.
41
+ # Use this to limit Server Side Request Forgery (SSRF) exposure when the url
42
+ # can be influenced by untrusted input.
43
+ # Default: nil (any host is allowed).
44
+ #
45
+ # maximum_file_size: [Integer]
46
+ # Optional maximum number of bytes to download.
47
+ # When the response body exceeds this size the download is aborted with a
48
+ # CommunicationsFailure, protecting against unbounded (denial of service) responses.
49
+ # Default: nil (no limit).
50
+ #
51
+ # Security notes:
52
+ # - Redirect targets are supplied by the remote server. Validating only the url that is
53
+ # passed in is therefore not sufficient to prevent SSRF: use `allow_hosts` (or disable
54
+ # redirects with `http_redirect_count: 0`) when the url is not fully trusted.
55
+ # - Basic authentication credentials are only sent to the original host. They are not
56
+ # resent when a redirect points at a different scheme, host, or port, so that a
57
+ # redirect cannot leak the credentials to another server.
58
+ def initialize(url, username: nil, password: nil, http_redirect_count: 10, parameters: nil,
59
+ allow_hosts: nil, maximum_file_size: nil)
30
60
  uri = URI.parse(url)
31
61
  unless %w[http https].include?(uri.scheme)
32
62
  raise(
@@ -38,6 +68,8 @@ module IOStreams
38
68
  @username = username || uri.user
39
69
  @password = password || uri.password
40
70
  @http_redirect_count = http_redirect_count
71
+ @allow_hosts = allow_hosts.nil? ? nil : Array(allow_hosts)
72
+ @maximum_file_size = maximum_file_size
41
73
  @url = parameters ? "#{url}?#{URI.encode_www_form(parameters)}" : url
42
74
  super(uri.path)
43
75
  end
@@ -53,6 +85,8 @@ module IOStreams
53
85
 
54
86
  private
55
87
 
88
+ attr_reader :allow_hosts, :maximum_file_size
89
+
56
90
  # Read a file using an http get.
57
91
  #
58
92
  # For example:
@@ -63,27 +97,40 @@ module IOStreams
63
97
  #
64
98
  # Notes:
65
99
  # * Since Net::HTTP download only supports a push stream, the data is streamed into a tempfile first.
66
- def stream_reader(&block)
67
- handle_redirects(url, http_redirect_count, &block)
100
+ def stream_reader(&)
101
+ handle_redirects(url, http_redirect_count, &)
68
102
  end
69
103
 
70
104
  def handle_redirects(uri, http_redirect_count, &block)
71
105
  uri = URI.parse(uri) unless uri.is_a?(URI)
72
106
  result = nil
73
- raise(IOStreams::Errors::CommunicationsFailure, "Too many redirects") if http_redirect_count < 1
107
+
108
+ validate_uri!(uri)
74
109
 
75
110
  Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
76
111
  request = Net::HTTP::Get.new(uri)
77
- request.basic_auth(username, password) if username
112
+ # Only send credentials to the original host to avoid leaking them via a redirect.
113
+ request.basic_auth(username, password) if username && same_origin?(uri)
78
114
 
79
115
  http.request(request) do |response|
80
- raise(IOStreams::Errors::CommunicationsFailure, "Invalid URL: #{uri}") if response.is_a?(Net::HTTPNotFound)
116
+ if response.is_a?(Net::HTTPNotFound)
117
+ raise(IOStreams::Errors::CommunicationsFailure, "Invalid URL: #{without_credentials(uri)}")
118
+ end
81
119
  if response.is_a?(Net::HTTPUnauthorized)
82
120
  raise(IOStreams::Errors::CommunicationsFailure, "Authorization Required: Invalid :username or :password.")
83
121
  end
84
122
 
85
123
  if response.is_a?(Net::HTTPRedirection)
86
- new_uri = response["location"]
124
+ raise(IOStreams::Errors::CommunicationsFailure, "Too many redirects") if http_redirect_count < 1
125
+
126
+ location = response["location"]
127
+ unless location
128
+ raise(IOStreams::Errors::CommunicationsFailure,
129
+ "Redirect missing location header: #{without_credentials(uri)}")
130
+ end
131
+
132
+ # Resolve relative redirects against the current uri.
133
+ new_uri = uri.merge(location)
87
134
  return handle_redirects(new_uri, http_redirect_count - 1, &block)
88
135
  end
89
136
 
@@ -92,8 +139,8 @@ module IOStreams
92
139
  end
93
140
 
94
141
  # Since Net::HTTP download only supports a push stream, write it to a tempfile first.
95
- Utils.temp_file_name("iostreams_http") do |file_name|
96
- ::File.open(file_name, "wb") { |io| response.read_body { |chunk| io.write(chunk) } }
142
+ Utils.private_temp_file("iostreams_http") do |file_name|
143
+ download_to_file(response, file_name)
97
144
  # Return a read stream
98
145
  result = ::File.open(file_name, "rb") { |io| builder.reader(io, &block) }
99
146
  end
@@ -101,6 +148,73 @@ module IOStreams
101
148
  end
102
149
  result
103
150
  end
151
+
152
+ # Validate that the host may be contacted, and that the scheme is still http(s)
153
+ # after following a redirect.
154
+ #
155
+ # A redirect must also be within the allowed paths, see `IOStreams.add_allowed_path`.
156
+ def validate_uri!(uri)
157
+ unless %w[http https].include?(uri.scheme)
158
+ raise(IOStreams::Errors::CommunicationsFailure,
159
+ "Invalid redirect, only http and https are supported: #{without_credentials(uri)}")
160
+ end
161
+ authorize_location!(http_location(uri)) unless IOStreams.allowed_paths.empty?
162
+ return if allow_hosts.nil? || allow_hosts.include?(uri.hostname)
163
+
164
+ raise(IOStreams::Errors::CommunicationsFailure, "Host not in the allowed list of hosts: #{uri.hostname}")
165
+ end
166
+
167
+ # Returns [String] the scheme, host, port and path of the url, which is compared against the allowed paths.
168
+ def allowed_location
169
+ http_location(original_uri)
170
+ end
171
+
172
+ # Returns [String] the scheme, host, port and path of the supplied uri, without any credentials or query.
173
+ #
174
+ # The path is decoded and `.` and `..` resolved, the way most web servers resolve them, so that for
175
+ # example `%2e%2e` cannot be used to leave an allowed path. A backslash is treated as a `/`.
176
+ def http_location(uri)
177
+ raise(Errors::AccessDenied, "Access denied: #{without_credentials(uri)} has no host") if uri.host.to_s.empty?
178
+
179
+ path = URI.decode_uri_component(uri.path).tr("\\", "/")
180
+ "#{uri.scheme}://#{uri.host.downcase}:#{uri.port}#{normalize_path(path)}".chomp("/")
181
+ rescue ArgumentError => e
182
+ raise(Errors::AccessDenied, "Access denied to #{without_credentials(uri)}: #{e.message}")
183
+ end
184
+
185
+ # Returns [String] the uri without any user name or password, for use in error messages.
186
+ def without_credentials(uri)
187
+ return uri.to_s unless uri.user
188
+
189
+ uri = uri.dup
190
+ uri.user = nil
191
+ uri.to_s
192
+ end
193
+
194
+ def same_origin?(uri)
195
+ original = original_uri
196
+ uri.scheme == original.scheme && uri.hostname == original.hostname && uri.port == original.port
197
+ end
198
+
199
+ def original_uri
200
+ @original_uri ||= URI.parse(url)
201
+ end
202
+
203
+ def download_to_file(response, file_name)
204
+ size = 0
205
+ ::File.open(file_name, "wb") do |io|
206
+ response.read_body do |chunk|
207
+ size += chunk.bytesize
208
+ if maximum_file_size && (size > maximum_file_size)
209
+ raise(
210
+ IOStreams::Errors::CommunicationsFailure,
211
+ "Exceeded maximum allowed download size of #{maximum_file_size} bytes"
212
+ )
213
+ end
214
+ io.write(chunk)
215
+ end
216
+ end
217
+ end
104
218
  end
105
219
  end
106
220
  end
@@ -3,7 +3,7 @@ module IOStreams
3
3
  # Implement fnmatch logic for any path iterator
4
4
  class Matcher
5
5
  # Characters indicating that pattern matching is required
6
- MATCH_START_CHARS = /[*?\[{]/.freeze
6
+ MATCH_START_CHARS = /[*?\[{]/
7
7
 
8
8
  attr_reader :path, :pattern, :flags
9
9
 
@@ -50,9 +50,9 @@ module IOStreams
50
50
  @path = path || IOStreams.path
51
51
  @pattern = pattern
52
52
  else
53
- new_path = elements[0..index - 1].join("/")
53
+ new_path = elements[0..(index - 1)].join("/")
54
54
  @path = path.nil? ? IOStreams.path(new_path) : path.join(new_path)
55
- @pattern = elements[index..-1].join("/")
55
+ @pattern = elements[index..].join("/")
56
56
  end
57
57
  end
58
58
  end