Skip to content

Commit 32a51cb

Browse files
kddnewtonkou
andcommitted
Fix up buffer treatment of stream parsing
A couple of things to fix, notably that we relied on the stream returning strings and relied on it not lying about how much was being written. Now we no longer trust either and we verify instead. Co-authored-by: Sutou Kouhei <kou@clear-code.com>
1 parent bf753a1 commit 32a51cb

6 files changed

Lines changed: 186 additions & 23 deletions

File tree

ext/prism/extension.c

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,26 +1067,64 @@ parse_stream_eof(void *stream) {
10671067
}
10681068

10691069
/**
1070-
* An implementation of fgets that is suitable for use with Ruby IO objects.
1070+
* The largest number of bytes a single character can occupy in any encoding
1071+
* that Ruby supports (CESU-8). `IO#gets(limit)` treats its argument as a soft
1072+
* limit: to avoid splitting a multi-byte character it may return up to
1073+
* `MAX_ENC_LEN - 1` more bytes than requested. Reserving `MAX_ENC_LEN` bytes of
1074+
* headroom therefore leaves room for both that overshoot and the terminating
1075+
* NUL byte.
1076+
*
1077+
* The value can be re-derived from the Ruby source tree with:
1078+
*
1079+
* grep -Erh "max (enc|byte) length" enc | grep -Eo '[0-9]+' | sort | tail -n1
1080+
*/
1081+
#define MAX_ENC_LEN 6
1082+
1083+
/**
1084+
* An implementation of fgets that is suitable for use with Ruby IO objects. As
1085+
* required by pm_source_stream_fgets_t, this never writes more than `size`
1086+
* bytes into `string` (including the terminating NUL byte).
10711087
*/
10721088
static char *
10731089
parse_stream_fgets(char *string, int size, void *stream) {
1074-
RUBY_ASSERT(size > 0);
1090+
RUBY_ASSERT(size > MAX_ENC_LEN);
1091+
1092+
/*
1093+
* Request fewer bytes than the buffer can hold. `gets` may return more
1094+
* bytes than requested when the limit falls in the middle of a multi-byte
1095+
* character, and the reserved headroom guarantees the result still fits.
1096+
*/
1097+
VALUE line = rb_funcall((VALUE) stream, rb_intern("gets"), 1, INT2FIX(size - MAX_ENC_LEN));
10751098

1076-
VALUE line = rb_funcall((VALUE) stream, rb_intern("gets"), 1, INT2FIX(size - 1));
1099+
/*
1100+
* A well-behaved stream returns a String, or nil at EOF. Coerce anything
1101+
* else to nil so that we never treat a non-String as a byte buffer.
1102+
*/
1103+
line = rb_check_string_type(line);
10771104
if (NIL_P(line)) {
10781105
return NULL;
10791106
}
10801107

10811108
const char *cstr = RSTRING_PTR(line);
10821109
long length = RSTRING_LEN(line);
10831110

1084-
memcpy(string, cstr, length);
1111+
/*
1112+
* Defensively clamp the copy. A misbehaving `gets` may ignore the limit
1113+
* entirely and return an arbitrarily long string; we must never write past
1114+
* the caller's buffer. One byte is reserved for the NUL terminator.
1115+
*/
1116+
if (length > (long) (size - 1)) {
1117+
length = (long) (size - 1);
1118+
}
1119+
1120+
memcpy(string, cstr, (size_t) length);
10851121
string[length] = '\0';
10861122

10871123
return string;
10881124
}
10891125

1126+
#undef MAX_ENC_LEN
1127+
10901128
/**
10911129
* :markup: markdown
10921130
* call-seq:

include/prism/source.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ typedef struct pm_source_t pm_source_t;
2424
* This function is used to retrieve a line of input from a stream. It closely
2525
* mirrors that of fgets so that fgets can be used as the default
2626
* implementation.
27+
*
28+
* As with fgets, implementations MUST write at most `size` bytes into
29+
* `string`, including the terminating NUL byte (i.e. at most `size - 1` bytes
30+
* of data followed by a '\0'). The caller relies on this bound for memory
31+
* safety, so an implementation that reads from a source which may return more
32+
* data than requested (for example a multi-byte-aware `gets`) is responsible
33+
* for clamping the amount it copies. Returns `string`, or NULL on EOF.
2734
*/
2835
typedef char * (pm_source_stream_fgets_t)(char *string, int size, void *stream);
2936

lib/prism/ffi.rb

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,11 +294,23 @@ def parse_file(filepath, **options)
294294
# Mirror the Prism.parse_stream API by using the serialization API.
295295
def parse_stream(stream, **options)
296296
LibRubyParser::PrismBuffer.with do |buffer|
297+
# The largest number of bytes a single character can occupy in any
298+
# encoding Ruby supports. IO#gets(limit) may return up to
299+
# (max_enc_len - 1) bytes more than requested to avoid splitting a
300+
# multi-byte character, so we reserve that much headroom (plus the NUL
301+
# terminator) to guarantee the result fits in the caller's buffer. This
302+
# mirrors MAX_ENC_LEN in ext/prism/extension.c.
303+
max_enc_len = 6
304+
297305
source = +""
298306
callback = -> (string, size, _) {
299-
raise "Expected size to be >= 0, got: #{size}" if size <= 0
307+
raise "Expected size to be > #{max_enc_len}, got: #{size}" if size <= max_enc_len
300308

301-
if !(line = stream.gets(size - 1)).nil?
309+
line = String.try_convert(stream.gets(size - max_enc_len))
310+
if !line.nil?
311+
# A misbehaving `gets` may ignore the limit; never write past the
312+
# buffer (one byte is reserved for the NUL terminator).
313+
line = line.byteslice(0, size - 1) if line.bytesize > size - 1
302314
source << line
303315
string.write_string("#{line}\x00", line.bytesize + 1)
304316
end

src/source.c

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -368,25 +368,50 @@ pm_source_stream_read(pm_source_t *source) {
368368
#define LINE_SIZE 4096
369369
char line[LINE_SIZE];
370370

371-
while (memset(line, '\n', LINE_SIZE), source->stream.fgets(line, LINE_SIZE, source->stream.stream) != NULL) {
371+
/*
372+
* A chunk read from the stream may legitimately contain embedded NUL bytes,
373+
* so the NUL terminator written by fgets cannot be located with strlen.
374+
* Instead we prefill the buffer with a non-NUL sentinel before each read;
375+
* the terminator is then the only NUL at or after the data, so it can be
376+
* found by scanning from the end. The value only needs to differ from '\0'.
377+
*/
378+
#define LINE_FILL 0xff
379+
380+
while (memset(line, LINE_FILL, LINE_SIZE), source->stream.fgets(line, LINE_SIZE, source->stream.stream) != NULL) {
381+
/*
382+
* Locate the NUL terminator written by fgets (the last NUL in the
383+
* buffer) to recover the length of the chunk, then drop the terminator.
384+
*/
372385
size_t length = LINE_SIZE;
373-
while (length > 0 && line[length - 1] == '\n') length--;
374-
375-
if (length == LINE_SIZE) {
376-
/*
377-
* If we read a line that is the maximum size and it doesn't end
378-
* with a newline, then we'll just append it to the buffer and
379-
* continue reading.
380-
*/
381-
length--;
382-
pm_buffer_append_string(buffer, line, length);
383-
continue;
386+
while (length > 0 && line[length - 1] != '\0') length--;
387+
if (length > 0) length--;
388+
389+
/*
390+
* An empty chunk means the stream returned an empty string instead of
391+
* signaling EOF (a well-behaved stream never does this). We can't make
392+
* progress on it, so stop reading rather than spinning forever.
393+
*/
394+
if (length == 0) {
395+
break;
384396
}
385397

386-
/* Append the line to the buffer. */
387-
length--;
398+
/* Append the chunk to the buffer. */
388399
pm_buffer_append_string(buffer, line, length);
389400

401+
bool newline = (line[length - 1] == '\n');
402+
403+
/*
404+
* A chunk with no trailing newline is either a line longer than the
405+
* read buffer (keep reading the rest of it) or the final line at EOF.
406+
* This is the only place the stream's EOF state changes what we do
407+
* next, so it is the only place we ask the stream whether it has hit
408+
* EOF. A newline-terminated line needs no such check: if it happens to
409+
* be the last line, the next fgets returns NULL and ends the loop.
410+
*/
411+
if (!newline && !source->stream.feof(source->stream.stream)) {
412+
continue;
413+
}
414+
390415
/*
391416
* Check if the line matches the __END__ marker. If it does, then stop
392417
* reading and return false. In most circumstances, this means we should
@@ -418,14 +443,16 @@ pm_source_stream_read(pm_source_t *source) {
418443
}
419444

420445
/*
421-
* All data should be read via gets. If the string returned by gets
422-
* _doesn't_ end with a newline, then we assume we hit EOF condition.
446+
* A chunk that reached here without a trailing newline is the final
447+
* line at EOF (the check above would have continued otherwise), so
448+
* there is nothing more to read.
423449
*/
424-
if (source->stream.feof(source->stream.stream)) {
450+
if (!newline) {
425451
break;
426452
}
427453
}
428454

455+
#undef LINE_FILL
429456
#undef LINE_SIZE
430457

431458
source->stream.eof = true;

test/prism/api/parse_stream_test.rb

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require_relative "../test_helper"
4+
require "timeout"
45

56
module Prism
67
class ParseStreamTest < TestCase
@@ -43,6 +44,18 @@ def test___END__
4344
assert_equal "5 + 6\n", io.read
4445
end
4546

47+
# __END__ as the final line with no trailing newline must still be detected
48+
# as the terminator. This exercises the EOF check for a chunk that does not
49+
# end in a newline, distinct from a line longer than the internal read
50+
# buffer (which also has no trailing newline but is not the end of input).
51+
def test___END___without_trailing_newline
52+
io = StringIO.new("1 + 2\n__END__")
53+
result = Prism.parse_stream(io)
54+
55+
assert result.success?
56+
assert_equal 1, result.value.statements.body.length
57+
end
58+
4659
def test_false___END___in_string
4760
io = StringIO.new(<<~RUBY)
4861
1 + 2
@@ -114,5 +127,70 @@ def test_nul_bytes
114127
assert result.success?
115128
assert_equal 3, result.value.statements.body.length
116129
end
130+
131+
# `IO#gets(limit)` will not split a multi-byte character, so it can return
132+
# more bytes than requested when the limit falls in the middle of one. When
133+
# that character straddles the internal read buffer boundary, the old code
134+
# wrote past the buffer and dropped the overflowing bytes, corrupting the
135+
# content (and overflowing the stack). Sweep offsets around the 4096-byte
136+
# boundary with both 3- and 4-byte characters so the straddle is hit
137+
# regardless of the exact internal read size.
138+
def test_multibyte_on_read_boundary
139+
["あ", "\u{1F600}"].each do |char|
140+
(4080..4100).each do |prefix|
141+
body = ("a" * prefix) + char
142+
result = Prism.parse_stream(StringIO.new("\"#{body}\""))
143+
144+
assert result.success?, "parse failed at prefix=#{prefix} char=#{char.dump}"
145+
assert_equal body, result.value.statements.body[0].content, "content mismatch at prefix=#{prefix} char=#{char.dump}"
146+
end
147+
end
148+
end
149+
150+
# A misbehaving stream whose `gets` ignores its limit and returns an
151+
# arbitrarily long string must not overflow the internal buffer. The old
152+
# code copied the full returned length into a fixed 4096-byte buffer.
153+
def test_gets_exceeding_limit
154+
stream = Object.new
155+
def stream.gets(limit = nil)
156+
return nil if defined?(@done) && @done
157+
@done = true
158+
("x" * 100_000) + "\n"
159+
end
160+
def stream.eof?; defined?(@done) && @done; end
161+
162+
result = Prism.parse_stream(stream)
163+
assert result.success?
164+
end
165+
166+
# A misbehaving stream whose `gets` returns a non-String must not be read as
167+
# a byte buffer. The old code called RSTRING_PTR on whatever was returned.
168+
def test_gets_returning_non_string
169+
stream = Object.new
170+
def stream.gets(limit = nil)
171+
return nil if defined?(@done) && @done
172+
@done = true
173+
1234
174+
end
175+
def stream.eof?; defined?(@done) && @done; end
176+
177+
assert_nothing_raised do
178+
Prism.parse_stream(stream)
179+
end
180+
end
181+
182+
# A misbehaving stream whose `gets` returns an empty string instead of nil
183+
# while never reporting EOF must not loop forever. A well-behaved stream
184+
# returns nil at EOF, so an empty chunk is treated as the end of input.
185+
def test_gets_returning_empty_string
186+
stream = Object.new
187+
def stream.gets(limit = nil); ""; end
188+
def stream.eof?; false; end
189+
190+
result = Timeout.timeout(10) { Prism.parse_stream(stream) }
191+
assert result.success?
192+
rescue Timeout::Error
193+
flunk "Prism.parse_stream looped forever on a stream returning empty strings"
194+
end
117195
end
118196
end

test/prism/newline_test.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class NewlineTest < TestCase
1212
regexp_test.rb
1313
test_helper.rb
1414
unescape_test.rb
15+
api/parse_stream_test.rb
1516
encoding/regular_expression_encoding_test.rb
1617
encoding/string_encoding_test.rb
1718
result/breadth_first_search_test.rb

0 commit comments

Comments
 (0)