Skip to content

Commit 3e17e01

Browse files
authored
Merge pull request #494 from ahx/dont-load-binary-file-uploads
Don't read uploaded files during request validation
2 parents ab1bdac + b31e094 commit 3e17e01

10 files changed

Lines changed: 203 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
## Unreleased
44

55
- Added: OpenAPI 3.2 documents are accepted, but not fully supported yet. They are handled using the OpenAPI 3.1 rules, so features introduced in 3.2 may be ignored. Loading such a document prints a warning. Operations defined under `additionalOperations` are routed. See #469.
6+
- **Breaking**: Uploaded files are no longer read during request validation. Before, the whole content of every `multipart/form-data` part that was sent as a file was read into memory, which allowed a single large upload to any documented multipart route to exhaust the memory of the server process. Such a field is now passed through as Rack parsed it (`{ filename:, type:, name:, tempfile:, head: }`), which is the same shape that Sinatra and Hanami hand to your application. Use `parsed_body['file'][:tempfile]` to read or stream the file.
7+
- The content of these fields is not validated anymore, so `minLength`, `maxLength` or `pattern` on a field that was sent as a file are ignored.
8+
- An `after_request_body_property_validation` hook sees an empty String instead of the file.
9+
- Fields that were not sent as a file, and fields with a JSON `contentType` in the `encoding` map, are read and validated as before.
610
- Changed: Don't hide covered endpoints in HTML coverage reporter
711
- Added: Filter un/covered endpoints in HTML coverage reporter
812
- Changed: Reduced memory retained by a loaded `Definition`. Response headers with a schema no longer keep the whole raw document node alive, and a couple of build-time-only hashes were replaced with more compact structures.

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,25 @@ use OpenapiFirst::Middlewares::RequestValidation, 'openapi.yaml', error_response
166166
You can build your own custom error response with `error_response: MyCustomClass` that implements `OpenapiFirst::ErrorResponse`.
167167
You can define custom error responses globally by including / implementing `OpenapiFirst::ErrorResponse` and register it via `OpenapiFirst.register_error_response(my_name, MyCustomErrorResponse)` and set `error_response: my_name`.
168168

169+
#### Multipart file uploads
170+
171+
Uploaded files are not read during request validation. A `multipart/form-data` field that was sent as a file is passed through as Rack parsed it – the same shape that Sinatra and Hanami hand to your application:
172+
173+
```ruby
174+
file = validated_request.parsed_body['file']
175+
file[:filename] # => "cat.jpg"
176+
file[:type] # => "image/jpeg"
177+
file[:tempfile] # => #<Tempfile …> Read or stream this in your application.
178+
```
179+
180+
The tempfile is only usable while the request is being handled, because Rack removes it afterwards.
181+
182+
This means the _content_ of these fields is not validated, so `minLength`, `maxLength` or `pattern` on a field that was sent as a file are ignored. Fields that were not sent as a file are read and validated as usual, and a field with `contentType: application/json` in the `encoding` map is still parsed as JSON.
183+
169184
### Response validation
170185

186+
You should use [Contract Testing](#contract-testing) instead of running the response validation middleware.
187+
171188
This middleware raises an error by default if the response is not valid.
172189
This can be useful in a test or staging environment, especially if you are adopting OpenAPI for an existing implementation.
173190

lib/openapi_first/request.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def initialize(path:, request_method:, operation_object:, # rubocop:disable Metr
2828
@body_parsers = build_body_parser(content_type, encoding) if content_type
2929
@validator = RequestValidator.new(
3030
content_schema:,
31+
content_type:,
3132
required_request_body: required_body == true,
3233
path_schema: parameters.path_schema,
3334
query_schema: parameters.query_schema,

lib/openapi_first/request_body_parsers.rb

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,6 @@ def self.read_body(request)
3939
Failure.new(:invalid_body, message: 'Failed to parse request body as JSON')
4040
end)
4141

42-
# Parses multipart/form-data requests and currently puts the contents of a file upload at the parsed hash values.
43-
# NOTE: This behavior will probably change in the next major version.
44-
# The uploaded file should not be read during request validation.
45-
#
4642
# Honors the OpenAPI `encoding` map: when a top-level field has
4743
# `contentType: application/json` (or any */json), the field's raw value
4844
# is JSON-parsed before schema validation.
@@ -65,9 +61,11 @@ def call(request)
6561
private
6662

6763
def decode_field(name, value)
68-
raw = unpack_value(value)
6964
content_type = @encoding.dig(name, 'contentType')
70-
return raw unless content_type && raw.is_a?(String) && json?(content_type)
65+
return unpack_value(value) unless content_type && json?(content_type)
66+
67+
raw = read_raw(value)
68+
return unpack_value(value) if raw.nil?
7169

7270
JSON.parse(raw)
7371
rescue JSON::ParserError => e
@@ -79,10 +77,16 @@ def json?(content_type)
7977
content_type.match?(%r{[/+]json\b}i)
8078
end
8179

80+
def read_raw(value)
81+
return value if value.is_a?(String)
82+
83+
value[:tempfile]&.read if value.is_a?(Hash) && value.key?(:tempfile)
84+
end
85+
8286
def unpack_value(value)
8387
return value.map { unpack_value(_1) } if value.is_a?(Array)
8488
return value unless value.is_a?(Hash)
85-
return value[:tempfile]&.read if value.key?(:tempfile)
89+
return value if value.key?(:tempfile)
8690

8791
value.transform_values { unpack_value(_1) }
8892
end

lib/openapi_first/request_validator.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@ module OpenapiFirst
99
class RequestValidator
1010
def initialize(
1111
content_schema:,
12+
content_type:,
1213
required_request_body:,
1314
path_schema:,
1415
query_schema:,
1516
header_schema:,
1617
cookie_schema:
1718
)
1819
@validators = []
19-
@validators << Validators::RequestBody.new(content_schema:, required_request_body:) if content_schema
20+
if content_schema
21+
@validators.concat Validators::RequestBody.for(content_schema:, required_request_body:, content_type:)
22+
end
2023
@validators.concat Validators::RequestParameters.for(
2124
path_schema:,
2225
query_schema:,
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# frozen_string_literal: true
2+
3+
require_relative '../schema/validation_result'
4+
5+
module OpenapiFirst
6+
module Validators
7+
class MultipartRequestBody
8+
FILE_UPLOAD_PLACEHOLDER = String.new('', encoding: Encoding::BINARY).freeze
9+
10+
def initialize(content_schema:)
11+
@schema = content_schema
12+
end
13+
14+
def call(parsed_request)
15+
body = parsed_request.body
16+
return if body.nil?
17+
18+
uploads = collect_file_uploads(body)
19+
uploads.each_key { write_at(body, _1, FILE_UPLOAD_PLACEHOLDER) }
20+
begin
21+
validate(body)
22+
ensure
23+
uploads.each { |path, upload| write_at(body, path, upload) }
24+
end
25+
end
26+
27+
private
28+
29+
def validate(body)
30+
validation = Schema::ValidationResult.new(
31+
@schema.validate(body, access_mode: 'write')
32+
)
33+
Failure.new(:invalid_body, errors: validation.errors) if validation.error?
34+
end
35+
36+
def collect_file_uploads(value, path = [], result = {})
37+
case value
38+
when ::Hash
39+
if value.key?(:tempfile)
40+
result[path] = value unless path.empty?
41+
else
42+
value.each { |key, item| collect_file_uploads(item, path + [key], result) }
43+
end
44+
when ::Array
45+
value.each_with_index { |item, index| collect_file_uploads(item, path + [index], result) }
46+
end
47+
result
48+
end
49+
50+
def write_at(root, path, value)
51+
*parents, key = path
52+
container = parents.empty? ? root : root.dig(*parents)
53+
container[key] = value if container
54+
end
55+
end
56+
end
57+
end

lib/openapi_first/validators/request_body.rb

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,29 @@
11
# frozen_string_literal: true
22

3+
require_relative 'multipart_request_body'
4+
require_relative 'required_request_body'
5+
36
module OpenapiFirst
47
module Validators
58
class RequestBody
6-
def initialize(content_schema:, required_request_body:)
9+
MULTIPART = %r{\Amultipart/}i
10+
private_constant :MULTIPART
11+
12+
def self.for(content_schema:, required_request_body:, content_type:)
13+
validators = []
14+
validators << RequiredRequestBody.new if required_request_body
15+
klass = MULTIPART.match?(content_type.to_s) ? MultipartRequestBody : self
16+
validators << klass.new(content_schema:)
17+
validators
18+
end
19+
20+
def initialize(content_schema:)
721
@schema = content_schema
8-
@required = required_request_body
922
end
1023

1124
def call(parsed_request)
1225
body = parsed_request.body
13-
if body.nil?
14-
return Failure.new(:invalid_body, message: 'Request body must not be empty') if @required
15-
16-
return
17-
end
26+
return if body.nil?
1827

1928
validation = Schema::ValidationResult.new(
2029
@schema.validate(body, access_mode: 'write')
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
module OpenapiFirst
4+
module Validators
5+
class RequiredRequestBody
6+
def call(parsed_request)
7+
Failure.new(:invalid_body, message: 'Request body must not be empty') if parsed_request.body.nil?
8+
end
9+
end
10+
end
11+
end

spec/middlewares/request_validation/request_body_validation_spec.rb

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,18 @@ def fixture_path(name)
6060
post '/multipart-with-file', 'file' => uploaded_file
6161
expect(last_response.status).to eq(200)
6262

63-
uploaded_file = last_request.env[OpenapiFirst::REQUEST].parsed_body['file']
64-
expect(uploaded_file).to eq File.read(fixture_path('foo.txt'))
63+
part = last_request.env[OpenapiFirst::REQUEST].parsed_body['file']
64+
expect(part[:filename]).to eq('foo.txt')
65+
expect(part[:tempfile].read).to eq File.read(fixture_path('foo.txt'))
66+
end
67+
68+
it 'does not read the uploaded file during request validation' do
69+
uploaded_file = Rack::Test::UploadedFile.new(fixture_path('foo.txt'))
70+
71+
expect_any_instance_of(Tempfile).not_to receive(:read)
72+
post '/multipart-with-file', 'file' => uploaded_file
73+
74+
expect(last_response.status).to eq(200), last_response.body
6575
end
6676

6777
it 'succeeds with nested multipart form data file binary upload' do
@@ -70,8 +80,8 @@ def fixture_path(name)
7080
post '/nested-multipart-with-file', 'user' => { 'avatar' => uploaded_file }
7181
expect(last_response.status).to eq(200), last_response.body
7282

73-
uploaded_file = last_request.env[OpenapiFirst::REQUEST].parsed_body.dig('user', 'avatar')
74-
expect(uploaded_file).to eq File.read(fixture_path('foo.txt'))
83+
part = last_request.env[OpenapiFirst::REQUEST].parsed_body.dig('user', 'avatar')
84+
expect(part[:tempfile].read).to eq File.read(fixture_path('foo.txt'))
7585
end
7686

7787
it 'succeeds list of binary fields in multipart/form-data' do
@@ -80,8 +90,31 @@ def fixture_path(name)
8090
post '/users-with-avatars', 'data' => [{ 'avatar' => uploaded_file, 'name' => 'Quentin' }]
8191
expect(last_response.status).to eq(200), last_response.body
8292

83-
names = last_request.env[OpenapiFirst::REQUEST].parsed_body.fetch('data').map { _1['name'] }
84-
expect(names).to eq(['Quentin'])
93+
data = last_request.env[OpenapiFirst::REQUEST].parsed_body.fetch('data')
94+
expect(data.map { _1['name'] }).to eq(['Quentin'])
95+
expect(data.first['avatar'][:tempfile].read).to eq File.read(fixture_path('foo.txt'))
96+
end
97+
98+
it 'fails when a required file part is missing' do
99+
data_part = Rack::Test::UploadedFile.new(
100+
StringIO.new(JSON.generate(name: 'Quentin', description: 'Cat')),
101+
'application/json', original_filename: 'data.json'
102+
)
103+
104+
post '/multipart-with-encoding', 'data' => data_part
105+
106+
expect(last_response.status).to eq(400), last_response.body
107+
end
108+
109+
it 'still validates non-file fields next to a file upload' do
110+
uploaded_file = Rack::Test::UploadedFile.new(fixture_path('foo.txt'))
111+
112+
post '/multipart-with-file', 'file' => uploaded_file, 'petId' => 'not-a-number'
113+
114+
expect(last_response.status).to eq(400), last_response.body
115+
116+
part = last_request.env[OpenapiFirst::REQUEST].parsed_body['file']
117+
expect(part[:tempfile].read).to eq File.read(fixture_path('foo.txt'))
85118
end
86119

87120
context 'when raise_error is true and a multipart JSON-encoded part is malformed' do
@@ -113,7 +146,37 @@ def fixture_path(name)
113146
expect(last_response.status).to eq(200), last_response.body
114147
parsed = last_request.env[OpenapiFirst::REQUEST].parsed_body
115148
expect(parsed['data']).to eq('name' => 'Quentin', 'description' => 'Cat')
116-
expect(parsed['file']).to eq(File.read(fixture_path('foo.txt')))
149+
expect(parsed['file'][:tempfile].read).to eq(File.read(fixture_path('foo.txt')))
150+
end
151+
152+
context 'with an after_request_body_property_validation hook' do
153+
let(:seen) { [] }
154+
155+
let(:app) do
156+
properties = seen
157+
definition = OpenapiFirst.load('./spec/data/request-body-validation.yaml') do |config|
158+
config.after_request_body_property_validation do |data, property, _property_schema|
159+
properties << [property, data[property]]
160+
end
161+
end
162+
Rack::Builder.new do
163+
use(OpenapiFirst::Middlewares::RequestValidation, spec: definition)
164+
run lambda { |_env|
165+
Rack::Response.new('hello', 200).finish
166+
}
167+
end
168+
end
169+
170+
it 'restores a nested file upload after validation and shows the hook a placeholder' do
171+
uploaded_file = Rack::Test::UploadedFile.new(fixture_path('foo.txt'))
172+
173+
post '/nested-multipart-with-file', 'user' => { 'avatar' => uploaded_file }
174+
expect(last_response.status).to eq(200), last_response.body
175+
176+
part = last_request.env[OpenapiFirst::REQUEST].parsed_body.dig('user', 'avatar')
177+
expect(part[:tempfile].read).to eq File.read(fixture_path('foo.txt'))
178+
expect(seen).to include(['avatar', ''])
179+
end
117180
end
118181

119182
it 'succeeds without optional file upload' do
@@ -336,6 +399,8 @@ def fixture_path(name)
336399
post path
337400

338401
expect(last_response.status).to be 400
402+
error = last_request.env[OpenapiFirst::REQUEST].error
403+
expect(error.message).to eq 'Request body must not be empty'
339404
end
340405

341406
it 'returns 415 if request content-type does not match' do

spec/request_body_parsers_spec.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,16 @@ def app = ->(_env) { Rack::Response.new.finish }
2424
post '/', 'file' => uploaded_file
2525

2626
body = parser.call(last_request)
27-
expect(body['file']).to eq(File.read('./spec/data/foo.txt'))
27+
expect(body['file'][:filename]).to eq('foo.txt')
28+
expect(body['file'][:tempfile].read).to eq(File.read('./spec/data/foo.txt'))
29+
end
30+
31+
it 'does not read uploaded files' do
32+
uploaded_file = Rack::Test::UploadedFile.new('./spec/data/foo.txt')
33+
post '/', 'file' => uploaded_file
34+
35+
expect_any_instance_of(Tempfile).not_to receive(:read)
36+
parser.call(last_request)
2837
end
2938

3039
context 'with an encoding map' do

0 commit comments

Comments
 (0)