From c84e79f6d1ae735bfa191694ec6a62eb8231166d Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Mon, 10 Aug 2026 17:11:39 +0200 Subject: feat(http/ftp/smtp): implement max size checks fixes unbounded reads when using the "*l" pattern. Typically on headers and other control lines. --- test/maxsize_http.lua | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++ test/maxsize_tp.lua | 90 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 test/maxsize_http.lua create mode 100644 test/maxsize_tp.lua (limited to 'test') diff --git a/test/maxsize_http.lua b/test/maxsize_http.lua new file mode 100644 index 0000000..3c0dc74 --- /dev/null +++ b/test/maxsize_http.lua @@ -0,0 +1,110 @@ +-- Exercises the maxsize caps added to socket.http's line-based receive() +-- calls (see PLAN-RECEIVE-MAXSIZE.md). Self-contained: uses a single +-- process with a real TCP loopback connection, so it needs no paired +-- server script. +local socket = require "socket" +local http = require "socket.http" +local ltn12 = require "ltn12" + +local host = "127.0.0.1" + +-- connects `open_fn(host, port)` to a freshly bound loopback listener and +-- returns the client-side object it produced plus the server-side raw +-- socket accepted for that connection. +local function new_pair(open_fn) + local server = assert(socket.bind(host, 0)) + local ip, port = server:getsockname() + local client = assert(open_fn(ip, port)) + local srv = assert(server:accept()) + server:close() + return client, srv +end + +local failures = 0 + +local function check(ok, msg) + if ok then + print("PASS: " .. msg) + else + failures = failures + 1 + print("FAIL: " .. msg) + end +end + +local function http_open(ip, port) + return http.open(ip, port, socket.tcp) +end + +do -- sanity: normal status line + headers still parse + http.MAXHEADERLINE, http.MAXHEADERSIZE = 8192, 65536 + local h, srv = new_pair(http_open) + srv:send("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + local code = socket.protect(function() return h:receivestatusline() end)() + local headers = socket.protect(function() return h:receiveheaders() end)() + check(code == 200 and headers and headers["content-length"] == "0", + "http: normal status line + headers parse") + h:close(); srv:close() +end + +do -- status line over MAXHEADERLINE is rejected + http.MAXHEADERLINE, http.MAXHEADERSIZE = 16, 1024 + local h, srv = new_pair(http_open) + srv:send("HTTP/1.1 200 " .. string.rep("x", 40) .. "\r\n") + local code, err = socket.protect(function() return h:receivestatusline() end)() + check(code == nil and err == "oversized", + "http: status line over MAXHEADERLINE -> oversized") + h:close(); srv:close() +end + +do -- a single header line over MAXHEADERLINE is rejected + http.MAXHEADERLINE, http.MAXHEADERSIZE = 32, 1024 + local h, srv = new_pair(http_open) + srv:send("HTTP/1.1 200 OK\r\n") + assert(socket.protect(function() return h:receivestatusline() end)() == 200) + srv:send("X-Foo: " .. string.rep("y", 60) .. "\r\n\r\n") + local headers, err = socket.protect(function() return h:receiveheaders() end)() + check(headers == nil and err == "oversized", + "http: single header line over MAXHEADERLINE -> oversized") + h:close(); srv:close() +end + +do -- each header line individually fits MAXHEADERLINE, but the total exceeds MAXHEADERSIZE + http.MAXHEADERLINE, http.MAXHEADERSIZE = 32, 40 + local h, srv = new_pair(http_open) + srv:send("HTTP/1.1 200 OK\r\n") + assert(socket.protect(function() return h:receivestatusline() end)() == 200) + -- each header line is ~23 bytes, individually under MAXHEADERLINE(32) + srv:send("A: 111111111111111111\r\n") + srv:send("B: 222222222222222222\r\n") + local headers, err = socket.protect(function() return h:receiveheaders() end)() + check(headers == nil and err == "oversized", + "http: total headers over MAXHEADERSIZE -> oversized (no single line over MAXHEADERLINE)") + h:close(); srv:close() +end + +do -- chunk-size line over MAXHEADERLINE is rejected + http.MAXHEADERLINE, http.MAXHEADERSIZE = 32, 1024 + local h, srv = new_pair(http_open) + srv:send("HTTP/1.1 200 OK\r\n") + assert(socket.protect(function() return h:receivestatusline() end)() == 200) + srv:send("Transfer-Encoding: chunked\r\n\r\n") + local headers = assert(socket.protect(function() return h:receiveheaders() end)()) + srv:send(string.rep("f", 40) .. "\r\n") -- oversized chunk-size line + local t = {} + local ok, err = socket.protect(function() + return h:receivebody(headers, (ltn12.sink.table(t))) + end)() + check(ok == nil and err == "oversized", + "http: chunk-size line over MAXHEADERLINE -> oversized") + h:close(); srv:close() +end + +http.MAXHEADERLINE, http.MAXHEADERSIZE = 8192, 65536 + +if failures == 0 then + print("All http maxsize tests passed") + os.exit(0) +else + print(failures .. " http maxsize test(s) failed") + os.exit(1) +end diff --git a/test/maxsize_tp.lua b/test/maxsize_tp.lua new file mode 100644 index 0000000..57ce9bc --- /dev/null +++ b/test/maxsize_tp.lua @@ -0,0 +1,90 @@ +-- Exercises the maxsize caps added to socket.tp's line-based receive() +-- calls (see PLAN-RECEIVE-MAXSIZE.md). socket.tp is the shared control +-- channel underneath both socket.ftp and socket.smtp, so this covers both. +-- Self-contained: uses a single process with a real TCP loopback +-- connection, so it needs no paired server script. +local socket = require "socket" +local tp = require "socket.tp" + +local host = "127.0.0.1" + +-- connects `open_fn(host, port)` to a freshly bound loopback listener and +-- returns the client-side object it produced plus the server-side raw +-- socket accepted for that connection. +local function new_pair(open_fn) + local server = assert(socket.bind(host, 0)) + local ip, port = server:getsockname() + local client = assert(open_fn(ip, port)) + local srv = assert(server:accept()) + server:close() + return client, srv +end + +local failures = 0 + +local function check(ok, msg) + if ok then + print("PASS: " .. msg) + else + failures = failures + 1 + print("FAIL: " .. msg) + end +end + +local function tp_open(ip, port) + return tp.connect(ip, port, 5) +end + +do -- sanity: normal single-line reply still parses + tp.MAXLINE, tp.MAXREPLY = 8192, 65536 + local c, srv = new_pair(tp_open) + srv:send("230 logged in\r\n") + local code, reply = c:check("2..") + check(code == 230 and reply == "230 logged in", + "tp: normal single-line reply parses") + c:close(); srv:close() +end + +do -- sanity: normal multiline reply still parses + tp.MAXLINE, tp.MAXREPLY = 8192, 65536 + local c, srv = new_pair(tp_open) + srv:send("214-first line\r\n214-second line\r\n214 done\r\n") + local code, reply = c:check("2..") + check(code == 214 and reply == "214-first line\n214-second line\n214 done", + "tp: normal multiline reply parses") + c:close(); srv:close() +end + +do -- a single reply line over MAXLINE is rejected + tp.MAXLINE, tp.MAXREPLY = 8, 65536 + local c, srv = new_pair(tp_open) + srv:send("230 this line is way over the line cap\r\n") + local code, err = c:check("2..") + check(code == nil and err == "oversized", + "tp: single line over MAXLINE -> oversized") + c:close(); srv:close() +end + +do -- each line individually fits MAXLINE, but the reply total exceeds MAXREPLY + tp.MAXLINE, tp.MAXREPLY = 16, 20 + local c, srv = new_pair(tp_open) + -- first line: 14 payload bytes, under both MAXLINE(16) and MAXREPLY(20) + srv:send("123-aaaaaaaaaa\r\n") + -- second line: another 14 payload bytes, individually under MAXLINE(16), + -- but only 6 bytes remain in the MAXREPLY(20) budget + srv:send("123-bbbbbbbbbb\r\n") + local code, err = c:check("2..") + check(code == nil and err == "oversized", + "tp: multiline reply over MAXREPLY -> oversized (no single line over MAXLINE)") + c:close(); srv:close() +end + +tp.MAXLINE, tp.MAXREPLY = 8192, 65536 + +if failures == 0 then + print("All tp maxsize tests passed") + os.exit(0) +else + print(failures .. " tp maxsize test(s) failed") + os.exit(1) +end -- cgit v1.2.3-55-g6feb