Skip to content

Commit 0b23c70

Browse files
authored
Merge pull request #301 from ruby/header-crlf-injection
Reject CR/LF in remaining request-generation paths
2 parents 0c045fa + 44c480b commit 0b23c70

4 files changed

Lines changed: 55 additions & 2 deletions

File tree

lib/net/http/generic_request.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,9 @@ def encode_multipart_form_data(out, params, opt)
336336
boundary = opt[:boundary]
337337
require 'securerandom' unless defined?(SecureRandom)
338338
boundary ||= SecureRandom.urlsafe_base64(40)
339+
if /[\r\n]/.match?(boundary.to_s)
340+
raise ArgumentError, "multipart boundary cannot include CR/LF"
341+
end
339342
chunked_p = chunked?
340343

341344
buf = +''
@@ -349,7 +352,10 @@ def encode_multipart_form_data(out, params, opt)
349352
buf << "--#{boundary}\r\n"
350353
if filename
351354
filename = quote_string(filename, charset)
352-
type = h[:content_type] || 'application/octet-stream'
355+
type = (h[:content_type] || 'application/octet-stream').to_s
356+
if /[\r\n]/.match?(type)
357+
raise ArgumentError, "field content type cannot include CR/LF"
358+
end
353359
buf << "Content-Disposition: form-data; " \
354360
"name=\"#{key}\"; filename=\"#{filename}\"\r\n" \
355361
"Content-Type: #{type}\r\n\r\n"
@@ -384,6 +390,9 @@ def encode_multipart_form_data(out, params, opt)
384390

385391
def quote_string(str, charset)
386392
str = str.encode(charset, fallback:->(c){'&#%d;'%c.encode("UTF-8").ord}) if charset
393+
if /[\r\n]/.match?(str)
394+
raise ArgumentError, "multipart field name or filename cannot include CR/LF"
395+
end
387396
str.gsub(/[\\"]/, '\\\\\&')
388397
end
389398

lib/net/http/header.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ def initialize_http_header(initheader) #:nodoc:
196196
if key.to_s.bytesize > MAX_KEY_LENGTH
197197
raise ArgumentError, "too long (#{key.bytesize} bytes) header: #{key[0, 30].inspect}..."
198198
end
199+
validate_field_name(key)
199200
if value.to_s.bytesize > MAX_FIELD_LENGTH
200201
raise ArgumentError, "header #{key} has too long field value: #{value.bytesize}"
201202
end
@@ -270,7 +271,14 @@ def add_field(key, val)
270271
end
271272

272273
# :stopdoc:
274+
private def validate_field_name(key)
275+
if /[\x00-\x1f\x7f:]/n.match?(key.to_s.b)
276+
raise ArgumentError, "header field name cannot include control characters or colon: #{key.to_s[0, 30].inspect}"
277+
end
278+
end
279+
273280
private def set_field(key, val)
281+
validate_field_name(key)
274282
case val
275283
when Enumerable
276284
ary = []
@@ -774,7 +782,7 @@ def type_params
774782
#
775783
# Net::HTTPHeader#content_type= is an alias for Net::HTTPHeader#set_content_type.
776784
def set_content_type(type, params = {})
777-
@header['content-type'] = [type + params.map{|k,v|"; #{k}=#{v}"}.join('')]
785+
set_field('Content-Type', type + params.map{|k,v|"; #{k}=#{v}"}.join(''))
778786
end
779787

780788
alias content_type= set_content_type

test/net/http/test_http.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -931,6 +931,24 @@ def test_set_form_with_file
931931
}
932932
}
933933
end
934+
935+
def test_set_form_multipart_crlf_injection
936+
build = ->(data, opt = {}) {
937+
req = Net::HTTP::Post.new('/')
938+
req.set_form(data, 'multipart/form-data')
939+
out = +''
940+
req.send(:encode_multipart_form_data, out, req.instance_variable_get(:@body_data), opt)
941+
}
942+
assert_raise(ArgumentError) { build.call([["foo\r\nX-Injected: 1", 'v']]) }
943+
assert_raise(ArgumentError) { build.call([['f', 'v']], boundary: "abc\r\nX-Injected: 1") }
944+
assert_raise(ArgumentError) { build.call([['f', 'v', {filename: "a\r\nX-Injected: 1"}]]) }
945+
assert_raise(ArgumentError) do
946+
build.call([['f', 'v', {filename: 'a', content_type: "text/plain\r\nX-Injected: 1"}]])
947+
end
948+
assert_nothing_raised do
949+
build.call([['f', 'v', {filename: 'a', content_type: :"text/plain"}]])
950+
end
951+
end
934952
end
935953

936954
class TestNetHTTP_v1_2 < Test::Unit::TestCase

test/net/http/test_httpheader.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@ def test_initialize
3030
assert_raise(ArgumentError){ @c.initialize_http_header("foo"=>"a\rb") }
3131
end
3232

33+
def test_invalid_field_name
34+
assert_raise(ArgumentError){ @c.initialize_http_header("foo\nbar"=>"abc") }
35+
assert_raise(ArgumentError){ @c.initialize_http_header("foo\rbar"=>"abc") }
36+
assert_raise(ArgumentError){ @c.initialize_http_header("foo:bar"=>"abc") }
37+
assert_raise(ArgumentError){ @c.initialize_http_header("foo\x00bar"=>"abc") }
38+
assert_raise(ArgumentError){ @c['foo'.b << 0x0a << 'bar'] = 'abc' }
39+
assert_raise(ArgumentError){ @c["foo\rbar"] = 'abc' }
40+
assert_raise(ArgumentError){ @c["foo:bar"] = 'abc' }
41+
assert_raise(ArgumentError){ @c["foo\x7fbar"] = 'abc' }
42+
assert_raise(ArgumentError){ @c.add_field "foo\nbar", 'abc' }
43+
assert_raise(ArgumentError){ @c.add_field "foo\nbar", ['abc'] }
44+
end
45+
3346
def test_initialize_with_broken_coderange
3447
error = RUBY_VERSION >= "3.2" ? Encoding::CompatibilityError : ArgumentError
3548
assert_raise(error){ @c.initialize_http_header("foo"=>"a\xff") }
@@ -438,6 +451,11 @@ def test_type_params
438451
end
439452

440453
def test_set_content_type
454+
@c.set_content_type 'text/html', {'charset' => 'utf-8'}
455+
assert_equal 'text/html; charset=utf-8', @c['content-type']
456+
assert_raise(ArgumentError){ @c.set_content_type "text/html\r\nFoo: bar" }
457+
assert_raise(ArgumentError){ @c.set_content_type 'text/html', {'charset' => "x\r\nFoo: bar"} }
458+
assert_raise(ArgumentError){ @c.set_content_type 'text/html', {"x\nFoo: bar" => 'utf-8'} }
441459
end
442460

443461
def test_form_data=

0 commit comments

Comments
 (0)