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.
- checksums.yaml +4 -4
- data/README.md +14 -13
- data/Rakefile +52 -0
- data/docs/CLAUDE.md +9 -0
- data/docs/config.md +157 -0
- data/docs/copy_files.md +75 -0
- data/docs/extensions.md +111 -0
- data/docs/formats.md +188 -0
- data/docs/index.md +388 -0
- data/docs/path.md +652 -0
- data/docs/pgp.md +436 -0
- data/docs/streams.md +337 -0
- data/docs/tutorial.md +483 -0
- data/docs/upgrading.md +217 -0
- data/lib/io_streams/builder.rb +71 -11
- data/lib/io_streams/bzip2/reader.rb +25 -2
- data/lib/io_streams/bzip2/writer.rb +26 -2
- data/lib/io_streams/encode/reader.rb +6 -2
- data/lib/io_streams/encode/writer.rb +9 -5
- data/lib/io_streams/errors.rb +4 -0
- data/lib/io_streams/gzip/reader.rb +5 -1
- data/lib/io_streams/gzip/writer.rb +11 -2
- data/lib/io_streams/io_streams.rb +156 -20
- data/lib/io_streams/line/reader.rb +9 -4
- data/lib/io_streams/line/writer.rb +1 -1
- data/lib/io_streams/path.rb +117 -8
- data/lib/io_streams/paths/file.rb +57 -11
- data/lib/io_streams/paths/http.rb +123 -9
- data/lib/io_streams/paths/matcher.rb +3 -3
- data/lib/io_streams/paths/s3.rb +69 -18
- data/lib/io_streams/paths/sftp/net_ssh.rb +104 -0
- data/lib/io_streams/paths/sftp.rb +103 -64
- data/lib/io_streams/pgp/reader.rb +63 -10
- data/lib/io_streams/pgp/writer.rb +111 -30
- data/lib/io_streams/pgp.rb +256 -71
- data/lib/io_streams/reader.rb +14 -5
- data/lib/io_streams/record/reader.rb +75 -6
- data/lib/io_streams/record/writer.rb +3 -4
- data/lib/io_streams/row/reader.rb +1 -1
- data/lib/io_streams/row/writer.rb +1 -1
- data/lib/io_streams/stream.rb +48 -37
- data/lib/io_streams/symmetric_encryption/reader.rb +6 -2
- data/lib/io_streams/symmetric_encryption/writer.rb +8 -4
- data/lib/io_streams/tabular/header.rb +49 -10
- data/lib/io_streams/tabular/parser/array.rb +0 -10
- data/lib/io_streams/tabular/parser/base.rb +10 -0
- data/lib/io_streams/tabular/parser/csv.rb +9 -36
- data/lib/io_streams/tabular/parser/fixed.rb +8 -6
- data/lib/io_streams/tabular/parser/psv.rb +6 -14
- data/lib/io_streams/tabular.rb +5 -10
- data/lib/io_streams/utils.rb +34 -2
- data/lib/io_streams/version.rb +1 -1
- data/lib/io_streams/writer.rb +16 -7
- data/lib/io_streams/xlsx/reader.rb +6 -2
- data/lib/io_streams/zip/reader.rb +4 -0
- data/lib/io_streams/zip/writer.rb +26 -10
- data/lib/iostreams.rb +0 -1
- metadata +46 -112
- data/lib/io_streams/deprecated.rb +0 -216
- data/lib/io_streams/tabular/utility/csv_row.rb +0 -105
- data/test/builder_test.rb +0 -311
- data/test/bzip2_reader_test.rb +0 -27
- data/test/bzip2_writer_test.rb +0 -56
- data/test/deprecated_test.rb +0 -121
- data/test/encode_reader_test.rb +0 -51
- data/test/encode_writer_test.rb +0 -90
- data/test/files/embedded_lines_test.csv +0 -7
- data/test/files/multiple_files.zip +0 -0
- data/test/files/spreadsheet.xlsx +0 -0
- data/test/files/test.csv +0 -4
- data/test/files/test.json +0 -3
- data/test/files/test.psv +0 -4
- data/test/files/text file.txt +0 -3
- data/test/files/text.txt +0 -3
- data/test/files/text.txt.bz2 +0 -0
- data/test/files/text.txt.gz +0 -0
- data/test/files/text.txt.gz.zip +0 -0
- data/test/files/text.zip +0 -0
- data/test/files/text.zip.gz +0 -0
- data/test/files/unclosed_quote_large_test.csv +0 -1658
- data/test/files/unclosed_quote_test.csv +0 -4
- data/test/files/unclosed_quote_test2.csv +0 -3
- data/test/gzip_reader_test.rb +0 -27
- data/test/gzip_writer_test.rb +0 -52
- data/test/io_streams_test.rb +0 -132
- data/test/line_reader_test.rb +0 -325
- data/test/line_writer_test.rb +0 -59
- data/test/minimal_file_reader.rb +0 -25
- data/test/path_test.rb +0 -55
- data/test/paths/file_test.rb +0 -213
- data/test/paths/http_test.rb +0 -34
- data/test/paths/matcher_test.rb +0 -120
- data/test/paths/s3_test.rb +0 -220
- data/test/paths/sftp_test.rb +0 -106
- data/test/pgp_reader_test.rb +0 -46
- data/test/pgp_test.rb +0 -267
- data/test/pgp_writer_test.rb +0 -130
- data/test/record_reader_test.rb +0 -60
- data/test/record_writer_test.rb +0 -82
- data/test/row_reader_test.rb +0 -35
- data/test/row_writer_test.rb +0 -56
- data/test/stream_test.rb +0 -577
- data/test/tabular_test.rb +0 -338
- data/test/test_helper.rb +0 -40
- data/test/utils_test.rb +0 -20
- data/test/xlsx_reader_test.rb +0 -37
- data/test/zip_reader_test.rb +0 -53
- data/test/zip_writer_test.rb +0 -48
data/lib/io_streams/path.rb
CHANGED
|
@@ -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 =
|
|
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, &
|
|
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
|
|
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").
|
|
18
|
+
# IOStreams.path("ruby").each_child("r*.md") { |path| puts path }
|
|
19
19
|
#
|
|
20
20
|
# # Case Sensitive file name lookup:
|
|
21
|
-
# IOStreams.path("ruby").
|
|
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").
|
|
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").
|
|
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 #
|
|
82
|
-
# ".*" ".profile" 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
|
-
|
|
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)
|
|
145
|
+
FileUtils.mkdir_p(dir)
|
|
140
146
|
self
|
|
141
147
|
end
|
|
142
148
|
|
|
143
149
|
def mkdir
|
|
144
|
-
|
|
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
|
-
::
|
|
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
|
-
|
|
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(&
|
|
67
|
-
handle_redirects(url, http_redirect_count, &
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
96
|
-
|
|
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 = /[*?\[{]
|
|
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
|
|
55
|
+
@pattern = elements[index..].join("/")
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
end
|