Files
thrift/lib/lua/test/test_frame_and_string_limits.lua
Jens GeyerandClaude Opus 5 573ec91bb0 Apply the string length limit to the pre-versioned message name
Client: lua

readMessageBegin() reads the method name of a message without a version
header from a length the peer declares, and passed that length straight to
trans:readAll(), which reads and retains that many bytes. readString() puts
a declared length through checkStringSize() first; this one path did not, so
the configured maximum did not apply to the first field of a connection.
Route it through the same check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 02:04:17 +02:00

667 lines
24 KiB
Lua

--
-- Licensed to the Apache Software Foundation (ASF) under one
-- or more contributor license agreements. See the NOTICE file
-- distributed with this work for additional information
-- regarding copyright ownership. The ASF licenses this file
-- to you under the Apache License, Version 2.0 (the
-- "License"); you may not use this file except in compliance
-- with the License. You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing,
-- software distributed under the License is distributed on an
-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-- KIND, either express or implied. See the License for the
-- specific language governing permissions and limitations
-- under the License.
--
-- Frame lengths and string lengths arrive from the peer, and readAll() will
-- keep asking until it has that many bytes. These hold both to a maximum, and
-- check that readAll() builds its result in a way whose cost is linear in what
-- it reads rather than quadratic.
--
-- lua lib/lua/test/test_frame_and_string_limits.lua
local script_dir = arg[0]:match('(.*[/\\])') or './'
package.path = script_dir .. '../?.lua;' .. package.path
-- Pure-Lua stand-ins for the C extensions, as in test_recursion_depth.lua: the
-- code under test is pure Lua and the extensions only pack bytes.
package.preload['libluabitwise'] = function()
return {
bor = function(a, b) return a | b end,
band = function(a, b) return a & b end,
bxor = function(a, b) return a ~ b end,
shiftl = function(a, n) return (a << n) & 0xFFFFFFFF end,
shiftr = function(a, n) return a >> n end,
}
end
package.preload['libluabpack'] = function()
local fmt = {c = '>i1', C = '>I1', s = '>i2', S = '>I2',
i = '>i4', I = '>I4', l = '>i8', d = '>d'}
return {
bpack = function(code, val) return string.pack(fmt[code], val) end,
bunpack = function(code, data) return (string.unpack(fmt[code], data)) end,
}
end
package.preload['liblualongnumber'] = function()
return {new = function(_, v) return v or 0 end, tonumber = function(v) return v end}
end
require('Thrift')
require('TTransport')
require('TFramedTransport')
require('TBinaryProtocol')
require('TMemoryBuffer')
local failures = 0
local checks = 0
local function check(ok, what)
checks = checks + 1
if ok then
print('ok - ' .. what)
else
failures = failures + 1
print('FAIL - ' .. what)
end
end
-- A transport that serves a fixed string and records how much was asked of it.
local CountingTransport = TTransportBase:new{__type = 'CountingTransport'}
function CountingTransport:new(obj)
obj = obj or {}
obj.pos = 1
obj.bytesRequested = 0
return __TObject.new(self, obj)
end
function CountingTransport:isOpen() return true end
function CountingTransport:open() end
function CountingTransport:close() end
function CountingTransport:read(len)
self.bytesRequested = self.bytesRequested + len
local chunk = string.sub(self.data, self.pos, self.pos + len - 1)
self.pos = self.pos + string.len(chunk)
return chunk
end
function CountingTransport:write(buf) end
function CountingTransport:flush() end
local function framed_over(declared, payload)
local inner = CountingTransport:new{
data = string.pack('>i4', declared) .. (payload or '')
}
return TFramedTransport:new{trans = inner}, inner
end
--------------------------------------------------------------------------
-- Frame size
--------------------------------------------------------------------------
local trans, inner = framed_over(0x7FFFFFFF)
local ok, err = pcall(function() return trans:read(1) end)
check(not ok, 'a frame declaring 2 GB is refused')
check(inner.bytesRequested == 4,
'the declared size is never asked of the transport (asked for ' ..
inner.bytesRequested .. ')')
trans, inner = framed_over(-1)
ok = pcall(function() return trans:read(1) end)
check(not ok, 'a negative frame size is refused')
trans, inner = framed_over(33, string.rep('x', 33))
trans.maxFrameSize = 32
ok = pcall(function() return trans:read(1) end)
check(not ok, 'a frame over a lowered maximum is refused')
local payload = 'hallo world'
trans, inner = framed_over(string.len(payload), payload)
ok, err = pcall(function() return trans:read(string.len(payload)) end)
check(ok and err == payload, 'a frame within the maximum still reads')
--------------------------------------------------------------------------
-- String length
--------------------------------------------------------------------------
local strInner = CountingTransport:new{data = string.pack('>i4', 0x7FFFFFFF)}
local proto = TBinaryProtocol:new{trans = strInner}
ok = pcall(function() return proto:readString() end)
check(not ok, 'a string declaring 2 GB is refused')
check(strInner.bytesRequested == 4,
'the declared string length is never asked of the transport (asked for ' ..
strInner.bytesRequested .. ')')
local text = 'a readable string'
strInner = CountingTransport:new{
data = string.pack('>i4', string.len(text)) .. text
}
proto = TBinaryProtocol:new{trans = strInner}
ok, err = pcall(function() return proto:readString() end)
check(ok and err == text, 'a string within the maximum still reads')
--------------------------------------------------------------------------
-- Message name length
--------------------------------------------------------------------------
-- A message without a version header names its own length in the leading
-- int32, and that is the first thing read off a connection. readString() puts
-- a declared length through checkStringSize(); this path has to as well.
local function old_style_message(declared, name)
return CountingTransport:new{
data = string.pack('>i4', declared) .. (name or '') ..
string.pack('>i1', 1) .. string.pack('>i4', 0)
}
end
local msgInner = old_style_message(0x7FFFFFFF)
proto = TBinaryProtocol:new{trans = msgInner}
ok = pcall(function() return proto:readMessageBegin() end)
check(not ok, 'a message name declaring 2 GB is refused')
check(msgInner.bytesRequested == 4,
'the declared name length is never asked of the transport (asked for ' ..
msgInner.bytesRequested .. ')')
local longName = string.rep('a', 64)
msgInner = old_style_message(string.len(longName), longName)
proto = TBinaryProtocol:new{trans = msgInner, maxStringSize = 32}
ok = pcall(function() return proto:readMessageBegin() end)
check(not ok, 'a message name over a lowered maximum is refused')
check(msgInner.bytesRequested == 4,
'the lowered maximum is applied before the read (asked for ' ..
msgInner.bytesRequested .. ')')
msgInner = old_style_message(4, 'ping')
proto = TBinaryProtocol:new{trans = msgInner}
local mname, mtype, mseqid
ok = pcall(function()
mname, mtype, mseqid = proto:readMessageBegin()
return true
end)
check(ok and mname == 'ping' and mtype == 1 and mseqid == 0,
'a message name within the maximum still reads')
-- The versioned header takes the other branch and reads its name through
-- readString(); it must keep working unchanged.
local verInner = CountingTransport:new{
data = string.pack('>I4', 0x80010000 | 1) ..
string.pack('>i4', 4) .. 'ping' .. string.pack('>i4', 7)
}
proto = TBinaryProtocol:new{trans = verInner}
ok = pcall(function()
mname, mtype, mseqid = proto:readMessageBegin()
return true
end)
check(ok and mname == 'ping' and mtype == 1 and mseqid == 7,
'a versioned message header is unaffected')
--------------------------------------------------------------------------
-- readAll() cost
--------------------------------------------------------------------------
-- A transport that hands back one byte at a time, so readAll() takes n passes.
-- Concatenating onto an immutable string copies the whole accumulator each
-- pass, which is quadratic; collecting into a table and joining once is not.
-- Timed rather than asserted on implementation, so it holds for any linear one.
local DripTransport = TTransportBase:new{__type = 'DripTransport'}
function DripTransport:new(obj)
obj = obj or {}
obj.served = 0
return __TObject.new(self, obj)
end
function DripTransport:isOpen() return true end
function DripTransport:read(len)
if self.served >= self.total then return '' end
self.served = self.served + 1
return 'x'
end
local function time_read_all(n)
local t = DripTransport:new{total = n}
local started = os.clock()
t:readAll(n)
return os.clock() - started
end
time_read_all(2000) -- warm up
local small = time_read_all(20000)
local large = time_read_all(80000)
-- Four times the input. Linear would be ~4x, quadratic ~16x. Ten is a wide
-- margin either side of that, so this is not a flaky timing assertion.
local ratio = large / math.max(small, 1e-6)
check(ratio < 10,
'readAll cost grows about linearly with the bytes read (4x input took ' ..
string.format('%.1f', ratio) .. 'x the time)')
--------------------------------------------------------------------------
-- JSON string, number and base64 length
--------------------------------------------------------------------------
require('TJsonProtocol')
-- A JSON string or number carries no length: it ends at the closing quote,
-- or at the first character that cannot be part of the number. The string
-- maximum is applied while the value is read, so reading stops within a byte
-- or two of the maximum however long the value goes on.
local function check_refused(what, read, prefix, value, maxStringSize, limit)
local jinner = CountingTransport:new{data = prefix .. value}
local jproto = TJSONProtocol:new{trans = jinner, maxStringSize = maxStringSize}
local ok, err = pcall(read, jproto)
check(not ok and
tostring(err):find('exceeds maximum ' .. limit, 1, true) ~= nil,
what .. ' over the maximum is refused: ' .. tostring(err))
local allowed = string.len(prefix) + limit + 2
check(jinner.bytesRequested <= allowed,
what .. ' is refused by the time ' .. allowed ..
' bytes are asked of the transport (asked for ' ..
jinner.bytesRequested .. ')')
end
local long = string.rep('a', 64)
check_refused('a JSON string', TJSONProtocol.readString,
'"', long .. '"', 32, 32)
check_refused('a JSON string with no closing quote', TJSONProtocol.readString,
'"', string.rep('a', 100000), 32, 32)
check_refused('a JSON number', TJSONProtocol.readI32,
'', string.rep('1', 64) .. ']', 32, 32)
check_refused('a JSON double', TJSONProtocol.readDouble,
'', string.rep('1', 64) .. ']', 32, 32)
check_refused('a JSON base64 value', TJSONProtocol.readBinary,
'"', string.rep('QUJD', 16) .. '"', 32, 32)
check_refused('a JSON message name', TJSONProtocol.readMessageBegin,
'[1,"', long .. '",1,0]', 32, 32)
-- With no maxStringSize set, DEFAULT_MAX_STRING_SIZE applies, as it does for
-- the binary and compact protocols. Lowered for the one read, to keep it small.
check(DEFAULT_MAX_STRING_SIZE == 16384000,
'the default string maximum is 16384000 (' ..
tostring(DEFAULT_MAX_STRING_SIZE) .. ')')
local savedDefault = DEFAULT_MAX_STRING_SIZE
DEFAULT_MAX_STRING_SIZE = 32
check_refused('a JSON string under the default maximum',
TJSONProtocol.readString, '"', long .. '"', nil, 32)
DEFAULT_MAX_STRING_SIZE = savedDefault
-- Values up to the maximum still read.
do
local function read_with(read, data)
local jproto = TJSONProtocol:new{
trans = CountingTransport:new{data = data}, maxStringSize = 32
}
local ok, val = pcall(read, jproto)
return ok and val
end
check(read_with(TJSONProtocol.readString, '"' .. string.rep('s', 32) .. '"')
== string.rep('s', 32),
'a JSON string at the maximum still reads')
check(read_with(TJSONProtocol.readI32, string.rep('0', 30) .. '42]') == 42,
'a JSON number at the maximum still reads')
check(read_with(TJSONProtocol.readBinary, '"' .. string.rep('QUJD', 8) .. '"')
== string.rep('ABC', 8),
'a JSON base64 value at the maximum still reads')
end
-- A message written with TJSONProtocol reads back the same.
do
local texts = {'', 'plain', 'with "quotes"', 'new\nline\ttab',
string.rep('0123456789', 500), 'utf-8 \xc3\xa4\xe2\x82\xac'}
local buffer = TMemoryBuffer:new{}
local out = TJSONProtocol:new{trans = buffer}
out:writeMessageBegin('someMethod', TMessageType.CALL, 7)
out:writeStructBegin('args')
out:writeFieldBegin('texts', TType.LIST, 1)
out:writeListBegin(TType.STRING, #texts)
for _, t in ipairs(texts) do
out:writeString(t)
end
out:writeListEnd()
out:writeFieldEnd()
out:writeFieldBegin('number', TType.I32, 2)
out:writeI32(-123456)
out:writeFieldEnd()
out:writeFieldBegin('real', TType.DOUBLE, 3)
out:writeDouble(3.25)
out:writeFieldEnd()
out:writeFieldBegin('bytes', TType.STRING, 4)
out:writeBinary('a\0z')
out:writeFieldEnd()
out:writeFieldStop()
out:writeStructEnd()
out:writeMessageEnd()
local jproto = TJSONProtocol:new{trans = buffer}
local same = true
local function expect(got, want)
if got ~= want then
same = false
end
end
ok, err = pcall(function()
local name, mtype, seqid = jproto:readMessageBegin()
expect(name, 'someMethod')
expect(mtype, TMessageType.CALL)
expect(seqid, 7)
jproto:readStructBegin()
local _, ftype, fid = jproto:readFieldBegin()
expect(ftype, TType.LIST)
expect(fid, 1)
local etype, count = jproto:readListBegin()
expect(etype, TType.STRING)
expect(count, #texts)
for i = 1, count do
expect(jproto:readString(), texts[i])
end
jproto:readListEnd()
jproto:readFieldEnd()
_, ftype, fid = jproto:readFieldBegin()
expect(jproto:readI32(), -123456)
jproto:readFieldEnd()
_, ftype, fid = jproto:readFieldBegin()
expect(jproto:readDouble(), 3.25)
jproto:readFieldEnd()
_, ftype, fid = jproto:readFieldBegin()
expect(jproto:readBinary(), 'a\0z')
jproto:readFieldEnd()
_, ftype, fid = jproto:readFieldBegin()
expect(ftype, TType.STOP)
jproto:readStructEnd()
jproto:readMessageEnd()
end)
check(ok and same, 'a JSON message reads back as written: ' .. tostring(err))
end
-- Base64 as other implementations write it: RFC 4648's test vectors, and all
-- 256 byte values.
do
local allBytes = {}
for i = 0, 255 do
allBytes[#allBytes + 1] = string.char(i)
end
local vectors = {
{'', ''}, {'f', 'Zg=='}, {'fo', 'Zm8='}, {'foo', 'Zm9v'},
{'foob', 'Zm9vYg=='}, {'fooba', 'Zm9vYmE='}, {'foobar', 'Zm9vYmFy'},
{table.concat(allBytes),
'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEy' ..
'MzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2Rl' ..
'ZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeY' ..
'mZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrL' ..
'zM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+' ..
'/w=='}
}
local wrong = 0
for _, v in ipairs(vectors) do
local jproto = TJSONProtocol:new{
trans = CountingTransport:new{data = '"' .. v[2] .. '"'}
}
local ok2, got = pcall(jproto.readBinary, jproto)
if not ok2 or got ~= v[1] then
wrong = wrong + 1
end
end
check(wrong == 0, 'base64 values decode to the bytes they encode (' ..
wrong .. ' of ' .. #vectors .. ' wrong)')
end
-- Reading a string, number or base64 value costs about the same per byte
-- whatever its length. Timed rather than asserted on implementation, as with
-- readAll() above, and the best of three runs to ride out noise. The
-- transport here costs next to nothing per byte, so the time is the
-- protocol's own.
local function lean_transport(data)
local pos = 1
return {
readAll = function(_, n)
local bytes = string.sub(data, pos, pos + n - 1)
pos = pos + n
return bytes
end
}
end
local function time_json_read(read, data)
local jproto = TJSONProtocol:new{trans = lean_transport(data)}
local started = os.clock()
read(jproto)
return os.clock() - started
end
-- Decoding takes most of the time of a base64 read, and would hide how the
-- decoded bytes are put together. Here readJSONString() and base64_decode()
-- are stood in for by functions that cost next to nothing, which leaves the
-- putting together to be timed.
local function time_base64_assembly(n)
local text = string.rep('QUJD', n // 4)
local jproto = TJSONProtocol:new{trans = lean_transport('')}
jproto.readJSONString = function() return text end
local decode = base64_decode
base64_decode = function() return 'ABC' end
local started = os.clock()
local ok3, err3 = pcall(jproto.readBinary, jproto)
local elapsed = os.clock() - started
base64_decode = decode
if not ok3 then
error(err3, 0)
end
return elapsed
end
local function best_json_time(time_it, n)
local best
for _ = 1, 3 do
local t = time_it(n)
if not best or t < best then
best = t
end
end
return math.max(best, 1e-6)
end
-- Eight times the bytes. Linear is ~8x; twenty leaves wide room for noise.
for _, case in ipairs({
{'reading a JSON string', 25000, function(n)
return time_json_read(TJSONProtocol.readString,
'"' .. string.rep('a', n) .. '"')
end},
{'reading a JSON number', 25000, function(n)
return time_json_read(TJSONProtocol.readI32, string.rep('1', n) .. ']')
end},
{'putting a decoded base64 value together', 100000,
time_base64_assembly}}) do
local what, n, time_it = case[1], case[2], case[3]
time_it(1000) -- warm up
local jratio = best_json_time(time_it, 8 * n) / best_json_time(time_it, n)
check(jratio < 20,
what .. ' costs about linearly (8x the bytes took ' ..
string.format('%.1f', jratio) .. 'x the time)')
end
--------------------------------------------------------------------------
-- HTTP body length
--------------------------------------------------------------------------
require('THttpTransport')
local function http_over(content_length)
local head = 'POST / HTTP/1.1\r\nContent-Length: ' .. content_length ..
'\r\n\r\n'
local inner = CountingTransport:new{data = head}
return THttpTransport:new{trans = inner, isServer = true}, inner
end
local httpTrans, httpInner = http_over(0x7FFFFFFF)
ok = pcall(function() return httpTrans:read(1) end)
check(not ok, 'an HTTP body declaring 2 GB is refused')
check(httpInner.bytesRequested < 1000,
'the declared body length is never asked of the transport (asked for ' ..
httpInner.bytesRequested .. ')')
--------------------------------------------------------------------------
-- Frame and HTTP body reads
--------------------------------------------------------------------------
-- Bytes that differ from one position to the next, so a byte handed out
-- twice, skipped or out of order fails the comparison.
local function sample(n, seed)
local bytes = {}
for i = 1, n do
bytes[i] = string.char((i * 31 + seed * 7) % 256)
end
return table.concat(bytes)
end
local frameSizes = {1, 5, 300, 4096, 17, 70000, 3}
local frames, frameWire = {}, {}
for i, n in ipairs(frameSizes) do
frames[i] = sample(n, i)
frameWire[i] = string.pack('>i4', n) .. frames[i]
end
local bodySizes = {5000, 4, 70000, 777}
local bodies, httpWire = {}, {}
for i, n in ipairs(bodySizes) do
bodies[i] = sample(n, 100 + i)
httpWire[i] = 'POST / HTTP/1.1\r\nContent-Length: ' .. n .. '\r\n\r\n' ..
bodies[i]
end
local readSizes = {1, 2, 3, 7, 64, 1000, 5000, 13}
-- read() returns at most what is left of the current frame or body, and
-- starts on the next one once that is used up. Replays readSizes against that
-- rule until every piece is consumed, comparing each result with the bytes
-- the rule says it must return.
local function check_reads(trans, pieces, what)
local piece, pos, total, expectedTotal, reads, wrong = 1, 0, 0, 0, 0, 0
for _, p in ipairs(pieces) do
expectedTotal = expectedTotal + string.len(p)
end
while total < expectedTotal do
reads = reads + 1
local len = readSizes[(reads - 1) % #readSizes + 1]
if pos == string.len(pieces[piece]) then
piece, pos = piece + 1, 0
end
local want = string.sub(pieces[piece], pos + 1, pos + len)
local ok, got = pcall(function() return trans:read(len) end)
if not ok or got ~= want then
wrong = wrong + 1
end
pos = pos + string.len(want)
total = total + string.len(want)
end
check(wrong == 0,
what .. ': ' .. reads .. ' reads of assorted sizes return the ' ..
expectedTotal .. ' bytes in order (' .. wrong .. ' wrong)')
end
check_reads(TFramedTransport:new{
trans = CountingTransport:new{data = table.concat(frameWire)}
}, frames, 'frames')
check_reads(THttpTransport:new{
trans = CountingTransport:new{data = table.concat(httpWire)},
isServer = true
}, bodies, 'HTTP bodies')
-- readAll() carries on into the next frame until it has what it asked for.
do
local trans = TFramedTransport:new{
trans = CountingTransport:new{data = table.concat(frameWire)}
}
local expected = table.concat(frames)
local got, total, wrong, reads = {}, 0, 0, 0
while total < string.len(expected) do
reads = reads + 1
local len = math.min(readSizes[(reads - 1) % #readSizes + 1] * 3,
string.len(expected) - total)
local chunk = trans:readAll(len)
if string.len(chunk) ~= len then
wrong = wrong + 1
end
got[#got + 1] = chunk
total = total + len
end
check(wrong == 0 and table.concat(got) == expected,
'frames: ' .. reads .. ' readAll() calls across frame boundaries ' ..
'return the ' .. string.len(expected) .. ' bytes in order')
end
-- A list skipped inside a frame leaves the protocol at the field after it.
do
local buffer = TMemoryBuffer:new{}
local out = TFramedTransport:new{trans = buffer}
local proto = TBinaryProtocol:new{trans = out}
proto:writeListBegin(TType.BYTE, 3000)
for i = 1, 3000 do
proto:writeByte(i % 100)
end
proto:writeListEnd()
proto:writeI32(123456789)
proto:writeString('after the list')
out:flush()
proto = TBinaryProtocol:new{trans = TFramedTransport:new{trans = buffer}}
local marker, text
ok = pcall(function()
proto:skip(TType.LIST)
marker = proto:readI32()
text = proto:readString()
end)
check(ok and marker == 123456789 and text == 'after the list',
'a list skipped inside a frame leaves the protocol at the next field')
end
-- One-byte reads cost about the same per byte whatever the size of the frame
-- or body they come from. Timed rather than asserted on implementation, as
-- with readAll() above, and the best of three runs to ride out noise.
local function best_of_three(time_it, n)
local best
for _ = 1, 3 do
local t = time_it(n)
if not best or t < best then
best = t
end
end
return math.max(best, 1e-6)
end
local function time_frame_reads(n)
local trans = framed_over(n, string.rep('x', n))
local started = os.clock()
for _ = 1, n do
trans:read(1)
end
return os.clock() - started
end
local function time_body_reads(n)
local inner = CountingTransport:new{
data = 'POST / HTTP/1.1\r\nContent-Length: ' .. n .. '\r\n\r\n' ..
string.rep('x', n)
}
local trans = THttpTransport:new{trans = inner, isServer = true}
local started = os.clock()
for _ = 1, n do
trans:read(1)
end
return os.clock() - started
end
-- Eight times the bytes. Linear is ~8x; twenty leaves wide room for noise.
for _, case in ipairs({{'a frame', time_frame_reads},
{'an HTTP body', time_body_reads}}) do
case[2](2000) -- warm up
local small = best_of_three(case[2], 25000)
local large = best_of_three(case[2], 200000)
local ratio = large / small
check(ratio < 20,
'one-byte reads from ' .. case[1] .. ' cost about linearly (8x the ' ..
'bytes took ' .. string.format('%.1f', ratio) .. 'x the time)')
end
print(string.format('\n%d checks, %d failures', checks, failures))
os.exit(failures == 0 and 0 or 1)