From 52f9f91d91aa33a1483334f69073f3e6250cd168 Mon Sep 17 00:00:00 2001 From: Thijs Schreijer Date: Mon, 10 Aug 2026 15:27:12 +0200 Subject: refactor(receive): make implicit line reads explicit --- src/http.lua | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) (limited to 'src/http.lua') diff --git a/src/http.lua b/src/http.lua index 259eb2b..2c296d4 100644 --- a/src/http.lua +++ b/src/http.lua @@ -48,7 +48,7 @@ local function receiveheaders(sock, headers) local line, name, value, err headers = headers or {} -- get first line - line, err = sock:receive() + line, err = sock:receive("*l") if err then return nil, err end -- headers go until a blank line is found while line ~= "" do @@ -57,12 +57,12 @@ local function receiveheaders(sock, headers) if not (name and value) then return nil, "malformed response headers" end name = string.lower(name) -- get next line (value might be folded) - line, err = sock:receive() + line, err = sock:receive("*l") if err then return nil, err end -- unfold any folded values while string.find(line, "^%s") do value = value .. line - line, err = sock:receive() + line, err = sock:receive("*l") if err then return nil, err end end -- save pair in table @@ -82,7 +82,7 @@ socket.sourcet["http-chunked"] = function(sock, headers) }, { __call = function() -- get chunk size, skip extension - local line, err = sock:receive() + local line, err = sock:receive("*l") if err then return nil, err end local size = base.tonumber(string.gsub(line, ";.*", ""), 16) if not size then return nil, "invalid chunk size" end @@ -90,7 +90,7 @@ socket.sourcet["http-chunked"] = function(sock, headers) if size > 0 then -- if not, get chunk and skip terminating CRLF local chunk, err, _ = sock:receive(size) - if chunk then sock:receive() end + if chunk then sock:receive("*l") end return chunk, err else -- if it was, read trailers into headers table -- cgit v1.2.3-55-g6feb 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. --- CHANGELOG.md | 1 + src/http.lua | 27 ++++++++++--- src/tp.lua | 18 ++++++++- test/maxsize_http.lua | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++ test/maxsize_tp.lua | 90 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 238 insertions(+), 8 deletions(-) create mode 100644 test/maxsize_http.lua create mode 100644 test/maxsize_tp.lua (limited to 'src/http.lua') diff --git a/CHANGELOG.md b/CHANGELOG.md index 03947c3..bbea37a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased * Add `maxsize` argument to `receive` to bound the memory a single call may accumulate, returning `"oversized"` instead of growing without limit – @Tieske +* Use the `maxsize` argument on `receive` internally so `socket.tp` (FTP/SMTP control replies) and `socket.http` (status line, headers, chunk-size lines) can no longer be made to buffer an unbounded amount of memory on a single line/reply/header block – @Tieske ## [v3.1.0](https://github.com/lunarmodules/luasocket/releases/v3.1.0) — 2022-07-27 diff --git a/src/http.lua b/src/http.lua index 2c296d4..f031c1d 100644 --- a/src/http.lua +++ b/src/http.lua @@ -25,6 +25,11 @@ local _M = socket.http _M.TIMEOUT = 60 -- user agent field sent in request _M.USERAGENT = socket._VERSION +-- maximum size of a single header line (also used for the status line and +-- the chunk-size line, which carry the same shape of risk) +_M.MAXHEADERLINE = 8192 +-- maximum total size of all header lines in a single header block +_M.MAXHEADERSIZE = 65536 -- supported schemes and their particulars local SCHEMES = { @@ -47,8 +52,18 @@ local SCHEMES = { local function receiveheaders(sock, headers) local line, name, value, err headers = headers or {} + -- bounds total bytes read across all header lines, on top of the + -- per-line MAXHEADERLINE cap, so a peer can't exhaust memory by sending + -- many lines that each individually fit under MAXHEADERLINE + local budget = _M.MAXHEADERSIZE + local function recvline() + if budget <= 0 then return nil, "oversized" end + local line, err = sock:receive("*l", nil, math.min(budget, _M.MAXHEADERLINE)) + if line then budget = budget - #line end + return line, err + end -- get first line - line, err = sock:receive("*l") + line, err = recvline() if err then return nil, err end -- headers go until a blank line is found while line ~= "" do @@ -57,12 +72,12 @@ local function receiveheaders(sock, headers) if not (name and value) then return nil, "malformed response headers" end name = string.lower(name) -- get next line (value might be folded) - line, err = sock:receive("*l") + line, err = recvline() if err then return nil, err end -- unfold any folded values while string.find(line, "^%s") do value = value .. line - line, err = sock:receive("*l") + line, err = recvline() if err then return nil, err end end -- save pair in table @@ -82,7 +97,7 @@ socket.sourcet["http-chunked"] = function(sock, headers) }, { __call = function() -- get chunk size, skip extension - local line, err = sock:receive("*l") + local line, err = sock:receive("*l", nil, _M.MAXHEADERLINE) if err then return nil, err end local size = base.tonumber(string.gsub(line, ";.*", ""), 16) if not size then return nil, "invalid chunk size" end @@ -90,7 +105,7 @@ socket.sourcet["http-chunked"] = function(sock, headers) if size > 0 then -- if not, get chunk and skip terminating CRLF local chunk, err, _ = sock:receive(size) - if chunk then sock:receive("*l") end + if chunk then sock:receive("*l", nil, _M.MAXHEADERLINE) end return chunk, err else -- if it was, read trailers into headers table @@ -167,7 +182,7 @@ function metat.__index:receivestatusline() return nil, status end -- otherwise proceed reading a status line - status = self.try(self.c:receive("*l", status)) + status = self.try(self.c:receive("*l", status, _M.MAXHEADERLINE)) local code = socket.skip(2, string.find(status, "HTTP/%d*%.%d* (%d%d%d)")) return self.try(base.tonumber(code), status) end diff --git a/src/tp.lua b/src/tp.lua index bf4bc14..ff5a5cf 100644 --- a/src/tp.lua +++ b/src/tp.lua @@ -19,6 +19,10 @@ local _M = socket.tp -- Program constants ----------------------------------------------------------------------------- _M.TIMEOUT = 60 +-- maximum size of a single reply line +_M.MAXLINE = 8192 +-- maximum total size of a (possibly multiline) reply +_M.MAXREPLY = 65536 ----------------------------------------------------------------------------- -- Implementation @@ -26,14 +30,24 @@ _M.TIMEOUT = 60 -- gets server reply (works for SMTP and FTP) local function get_reply(c) local code, current, sep - local line, err = c:receive("*l") + -- bounds total bytes read across a multiline reply, on top of the + -- per-line MAXLINE cap, so a peer can't exhaust memory by sending many + -- lines that each individually fit under MAXLINE + local budget = _M.MAXREPLY + local function recvline() + if budget <= 0 then return nil, "oversized" end + local line, err = c:receive("*l", nil, math.min(budget, _M.MAXLINE)) + if line then budget = budget - #line end + return line, err + end + local line, err = recvline() local reply = line if err then return nil, err end code, sep = socket.skip(2, string.find(line, "^(%d%d%d)(.?)")) if not code then return nil, "invalid server reply" end if sep == "-" then -- reply is multiline repeat - line, err = c:receive("*l") + line, err = recvline() if err then return nil, err end current, sep = socket.skip(2, string.find(line, "^(%d%d%d)(.?)")) reply = reply .. "\n" .. line 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