aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCaleb Maclennan <caleb@alerque.com>2026-08-31 11:04:37 +0300
committerGitHub <noreply@github.com>2026-08-31 11:04:37 +0300
commit01162f05408ac4206be15842ef91048c63c23869 (patch)
tree2b7c8a2916cf8083b557a897c7d258e00a9c4fa3
parent5afe174eeffc7f3e3b9ef80b5871237620c68415 (diff)
parentc84e79f6d1ae735bfa191694ec6a62eb8231166d (diff)
downloadluasocket-01162f05408ac4206be15842ef91048c63c23869.tar.gz
luasocket-01162f05408ac4206be15842ef91048c63c23869.tar.bz2
luasocket-01162f05408ac4206be15842ef91048c63c23869.zip
Merge pull request #467 from lunarmodules/fix/http-size-protect
Fix/http size protect
-rw-r--r--CHANGELOG.md1
-rw-r--r--src/http.lua27
-rw-r--r--src/tp.lua18
-rw-r--r--test/maxsize_http.lua110
-rw-r--r--test/maxsize_tp.lua90
5 files changed, 238 insertions, 8 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 03947c3..bbea37a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
3## Unreleased 3## Unreleased
4 4
5* Add `maxsize` argument to `receive` to bound the memory a single call may accumulate, returning `"oversized"` instead of growing without limit – @Tieske 5* Add `maxsize` argument to `receive` to bound the memory a single call may accumulate, returning `"oversized"` instead of growing without limit – @Tieske
6* 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
6 7
7## [v3.1.0](https://github.com/lunarmodules/luasocket/releases/v3.1.0) — 2022-07-27 8## [v3.1.0](https://github.com/lunarmodules/luasocket/releases/v3.1.0) — 2022-07-27
8 9
diff --git a/src/http.lua b/src/http.lua
index 259eb2b..f031c1d 100644
--- a/src/http.lua
+++ b/src/http.lua
@@ -25,6 +25,11 @@ local _M = socket.http
25_M.TIMEOUT = 60 25_M.TIMEOUT = 60
26-- user agent field sent in request 26-- user agent field sent in request
27_M.USERAGENT = socket._VERSION 27_M.USERAGENT = socket._VERSION
28-- maximum size of a single header line (also used for the status line and
29-- the chunk-size line, which carry the same shape of risk)
30_M.MAXHEADERLINE = 8192
31-- maximum total size of all header lines in a single header block
32_M.MAXHEADERSIZE = 65536
28 33
29-- supported schemes and their particulars 34-- supported schemes and their particulars
30local SCHEMES = { 35local SCHEMES = {
@@ -47,8 +52,18 @@ local SCHEMES = {
47local function receiveheaders(sock, headers) 52local function receiveheaders(sock, headers)
48 local line, name, value, err 53 local line, name, value, err
49 headers = headers or {} 54 headers = headers or {}
55 -- bounds total bytes read across all header lines, on top of the
56 -- per-line MAXHEADERLINE cap, so a peer can't exhaust memory by sending
57 -- many lines that each individually fit under MAXHEADERLINE
58 local budget = _M.MAXHEADERSIZE
59 local function recvline()
60 if budget <= 0 then return nil, "oversized" end
61 local line, err = sock:receive("*l", nil, math.min(budget, _M.MAXHEADERLINE))
62 if line then budget = budget - #line end
63 return line, err
64 end
50 -- get first line 65 -- get first line
51 line, err = sock:receive() 66 line, err = recvline()
52 if err then return nil, err end 67 if err then return nil, err end
53 -- headers go until a blank line is found 68 -- headers go until a blank line is found
54 while line ~= "" do 69 while line ~= "" do
@@ -57,12 +72,12 @@ local function receiveheaders(sock, headers)
57 if not (name and value) then return nil, "malformed response headers" end 72 if not (name and value) then return nil, "malformed response headers" end
58 name = string.lower(name) 73 name = string.lower(name)
59 -- get next line (value might be folded) 74 -- get next line (value might be folded)
60 line, err = sock:receive() 75 line, err = recvline()
61 if err then return nil, err end 76 if err then return nil, err end
62 -- unfold any folded values 77 -- unfold any folded values
63 while string.find(line, "^%s") do 78 while string.find(line, "^%s") do
64 value = value .. line 79 value = value .. line
65 line, err = sock:receive() 80 line, err = recvline()
66 if err then return nil, err end 81 if err then return nil, err end
67 end 82 end
68 -- save pair in table 83 -- save pair in table
@@ -82,7 +97,7 @@ socket.sourcet["http-chunked"] = function(sock, headers)
82 }, { 97 }, {
83 __call = function() 98 __call = function()
84 -- get chunk size, skip extension 99 -- get chunk size, skip extension
85 local line, err = sock:receive() 100 local line, err = sock:receive("*l", nil, _M.MAXHEADERLINE)
86 if err then return nil, err end 101 if err then return nil, err end
87 local size = base.tonumber(string.gsub(line, ";.*", ""), 16) 102 local size = base.tonumber(string.gsub(line, ";.*", ""), 16)
88 if not size then return nil, "invalid chunk size" end 103 if not size then return nil, "invalid chunk size" end
@@ -90,7 +105,7 @@ socket.sourcet["http-chunked"] = function(sock, headers)
90 if size > 0 then 105 if size > 0 then
91 -- if not, get chunk and skip terminating CRLF 106 -- if not, get chunk and skip terminating CRLF
92 local chunk, err, _ = sock:receive(size) 107 local chunk, err, _ = sock:receive(size)
93 if chunk then sock:receive() end 108 if chunk then sock:receive("*l", nil, _M.MAXHEADERLINE) end
94 return chunk, err 109 return chunk, err
95 else 110 else
96 -- if it was, read trailers into headers table 111 -- if it was, read trailers into headers table
@@ -167,7 +182,7 @@ function metat.__index:receivestatusline()
167 return nil, status 182 return nil, status
168 end 183 end
169 -- otherwise proceed reading a status line 184 -- otherwise proceed reading a status line
170 status = self.try(self.c:receive("*l", status)) 185 status = self.try(self.c:receive("*l", status, _M.MAXHEADERLINE))
171 local code = socket.skip(2, string.find(status, "HTTP/%d*%.%d* (%d%d%d)")) 186 local code = socket.skip(2, string.find(status, "HTTP/%d*%.%d* (%d%d%d)"))
172 return self.try(base.tonumber(code), status) 187 return self.try(base.tonumber(code), status)
173end 188end
diff --git a/src/tp.lua b/src/tp.lua
index b8ebc56..ff5a5cf 100644
--- a/src/tp.lua
+++ b/src/tp.lua
@@ -19,6 +19,10 @@ local _M = socket.tp
19-- Program constants 19-- Program constants
20----------------------------------------------------------------------------- 20-----------------------------------------------------------------------------
21_M.TIMEOUT = 60 21_M.TIMEOUT = 60
22-- maximum size of a single reply line
23_M.MAXLINE = 8192
24-- maximum total size of a (possibly multiline) reply
25_M.MAXREPLY = 65536
22 26
23----------------------------------------------------------------------------- 27-----------------------------------------------------------------------------
24-- Implementation 28-- Implementation
@@ -26,14 +30,24 @@ _M.TIMEOUT = 60
26-- gets server reply (works for SMTP and FTP) 30-- gets server reply (works for SMTP and FTP)
27local function get_reply(c) 31local function get_reply(c)
28 local code, current, sep 32 local code, current, sep
29 local line, err = c:receive() 33 -- bounds total bytes read across a multiline reply, on top of the
34 -- per-line MAXLINE cap, so a peer can't exhaust memory by sending many
35 -- lines that each individually fit under MAXLINE
36 local budget = _M.MAXREPLY
37 local function recvline()
38 if budget <= 0 then return nil, "oversized" end
39 local line, err = c:receive("*l", nil, math.min(budget, _M.MAXLINE))
40 if line then budget = budget - #line end
41 return line, err
42 end
43 local line, err = recvline()
30 local reply = line 44 local reply = line
31 if err then return nil, err end 45 if err then return nil, err end
32 code, sep = socket.skip(2, string.find(line, "^(%d%d%d)(.?)")) 46 code, sep = socket.skip(2, string.find(line, "^(%d%d%d)(.?)"))
33 if not code then return nil, "invalid server reply" end 47 if not code then return nil, "invalid server reply" end
34 if sep == "-" then -- reply is multiline 48 if sep == "-" then -- reply is multiline
35 repeat 49 repeat
36 line, err = c:receive() 50 line, err = recvline()
37 if err then return nil, err end 51 if err then return nil, err end
38 current, sep = socket.skip(2, string.find(line, "^(%d%d%d)(.?)")) 52 current, sep = socket.skip(2, string.find(line, "^(%d%d%d)(.?)"))
39 reply = reply .. "\n" .. line 53 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 @@
1-- Exercises the maxsize caps added to socket.http's line-based receive()
2-- calls (see PLAN-RECEIVE-MAXSIZE.md). Self-contained: uses a single
3-- process with a real TCP loopback connection, so it needs no paired
4-- server script.
5local socket = require "socket"
6local http = require "socket.http"
7local ltn12 = require "ltn12"
8
9local host = "127.0.0.1"
10
11-- connects `open_fn(host, port)` to a freshly bound loopback listener and
12-- returns the client-side object it produced plus the server-side raw
13-- socket accepted for that connection.
14local function new_pair(open_fn)
15 local server = assert(socket.bind(host, 0))
16 local ip, port = server:getsockname()
17 local client = assert(open_fn(ip, port))
18 local srv = assert(server:accept())
19 server:close()
20 return client, srv
21end
22
23local failures = 0
24
25local function check(ok, msg)
26 if ok then
27 print("PASS: " .. msg)
28 else
29 failures = failures + 1
30 print("FAIL: " .. msg)
31 end
32end
33
34local function http_open(ip, port)
35 return http.open(ip, port, socket.tcp)
36end
37
38do -- sanity: normal status line + headers still parse
39 http.MAXHEADERLINE, http.MAXHEADERSIZE = 8192, 65536
40 local h, srv = new_pair(http_open)
41 srv:send("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
42 local code = socket.protect(function() return h:receivestatusline() end)()
43 local headers = socket.protect(function() return h:receiveheaders() end)()
44 check(code == 200 and headers and headers["content-length"] == "0",
45 "http: normal status line + headers parse")
46 h:close(); srv:close()
47end
48
49do -- status line over MAXHEADERLINE is rejected
50 http.MAXHEADERLINE, http.MAXHEADERSIZE = 16, 1024
51 local h, srv = new_pair(http_open)
52 srv:send("HTTP/1.1 200 " .. string.rep("x", 40) .. "\r\n")
53 local code, err = socket.protect(function() return h:receivestatusline() end)()
54 check(code == nil and err == "oversized",
55 "http: status line over MAXHEADERLINE -> oversized")
56 h:close(); srv:close()
57end
58
59do -- a single header line over MAXHEADERLINE is rejected
60 http.MAXHEADERLINE, http.MAXHEADERSIZE = 32, 1024
61 local h, srv = new_pair(http_open)
62 srv:send("HTTP/1.1 200 OK\r\n")
63 assert(socket.protect(function() return h:receivestatusline() end)() == 200)
64 srv:send("X-Foo: " .. string.rep("y", 60) .. "\r\n\r\n")
65 local headers, err = socket.protect(function() return h:receiveheaders() end)()
66 check(headers == nil and err == "oversized",
67 "http: single header line over MAXHEADERLINE -> oversized")
68 h:close(); srv:close()
69end
70
71do -- each header line individually fits MAXHEADERLINE, but the total exceeds MAXHEADERSIZE
72 http.MAXHEADERLINE, http.MAXHEADERSIZE = 32, 40
73 local h, srv = new_pair(http_open)
74 srv:send("HTTP/1.1 200 OK\r\n")
75 assert(socket.protect(function() return h:receivestatusline() end)() == 200)
76 -- each header line is ~23 bytes, individually under MAXHEADERLINE(32)
77 srv:send("A: 111111111111111111\r\n")
78 srv:send("B: 222222222222222222\r\n")
79 local headers, err = socket.protect(function() return h:receiveheaders() end)()
80 check(headers == nil and err == "oversized",
81 "http: total headers over MAXHEADERSIZE -> oversized (no single line over MAXHEADERLINE)")
82 h:close(); srv:close()
83end
84
85do -- chunk-size line over MAXHEADERLINE is rejected
86 http.MAXHEADERLINE, http.MAXHEADERSIZE = 32, 1024
87 local h, srv = new_pair(http_open)
88 srv:send("HTTP/1.1 200 OK\r\n")
89 assert(socket.protect(function() return h:receivestatusline() end)() == 200)
90 srv:send("Transfer-Encoding: chunked\r\n\r\n")
91 local headers = assert(socket.protect(function() return h:receiveheaders() end)())
92 srv:send(string.rep("f", 40) .. "\r\n") -- oversized chunk-size line
93 local t = {}
94 local ok, err = socket.protect(function()
95 return h:receivebody(headers, (ltn12.sink.table(t)))
96 end)()
97 check(ok == nil and err == "oversized",
98 "http: chunk-size line over MAXHEADERLINE -> oversized")
99 h:close(); srv:close()
100end
101
102http.MAXHEADERLINE, http.MAXHEADERSIZE = 8192, 65536
103
104if failures == 0 then
105 print("All http maxsize tests passed")
106 os.exit(0)
107else
108 print(failures .. " http maxsize test(s) failed")
109 os.exit(1)
110end
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 @@
1-- Exercises the maxsize caps added to socket.tp's line-based receive()
2-- calls (see PLAN-RECEIVE-MAXSIZE.md). socket.tp is the shared control
3-- channel underneath both socket.ftp and socket.smtp, so this covers both.
4-- Self-contained: uses a single process with a real TCP loopback
5-- connection, so it needs no paired server script.
6local socket = require "socket"
7local tp = require "socket.tp"
8
9local host = "127.0.0.1"
10
11-- connects `open_fn(host, port)` to a freshly bound loopback listener and
12-- returns the client-side object it produced plus the server-side raw
13-- socket accepted for that connection.
14local function new_pair(open_fn)
15 local server = assert(socket.bind(host, 0))
16 local ip, port = server:getsockname()
17 local client = assert(open_fn(ip, port))
18 local srv = assert(server:accept())
19 server:close()
20 return client, srv
21end
22
23local failures = 0
24
25local function check(ok, msg)
26 if ok then
27 print("PASS: " .. msg)
28 else
29 failures = failures + 1
30 print("FAIL: " .. msg)
31 end
32end
33
34local function tp_open(ip, port)
35 return tp.connect(ip, port, 5)
36end
37
38do -- sanity: normal single-line reply still parses
39 tp.MAXLINE, tp.MAXREPLY = 8192, 65536
40 local c, srv = new_pair(tp_open)
41 srv:send("230 logged in\r\n")
42 local code, reply = c:check("2..")
43 check(code == 230 and reply == "230 logged in",
44 "tp: normal single-line reply parses")
45 c:close(); srv:close()
46end
47
48do -- sanity: normal multiline reply still parses
49 tp.MAXLINE, tp.MAXREPLY = 8192, 65536
50 local c, srv = new_pair(tp_open)
51 srv:send("214-first line\r\n214-second line\r\n214 done\r\n")
52 local code, reply = c:check("2..")
53 check(code == 214 and reply == "214-first line\n214-second line\n214 done",
54 "tp: normal multiline reply parses")
55 c:close(); srv:close()
56end
57
58do -- a single reply line over MAXLINE is rejected
59 tp.MAXLINE, tp.MAXREPLY = 8, 65536
60 local c, srv = new_pair(tp_open)
61 srv:send("230 this line is way over the line cap\r\n")
62 local code, err = c:check("2..")
63 check(code == nil and err == "oversized",
64 "tp: single line over MAXLINE -> oversized")
65 c:close(); srv:close()
66end
67
68do -- each line individually fits MAXLINE, but the reply total exceeds MAXREPLY
69 tp.MAXLINE, tp.MAXREPLY = 16, 20
70 local c, srv = new_pair(tp_open)
71 -- first line: 14 payload bytes, under both MAXLINE(16) and MAXREPLY(20)
72 srv:send("123-aaaaaaaaaa\r\n")
73 -- second line: another 14 payload bytes, individually under MAXLINE(16),
74 -- but only 6 bytes remain in the MAXREPLY(20) budget
75 srv:send("123-bbbbbbbbbb\r\n")
76 local code, err = c:check("2..")
77 check(code == nil and err == "oversized",
78 "tp: multiline reply over MAXREPLY -> oversized (no single line over MAXLINE)")
79 c:close(); srv:close()
80end
81
82tp.MAXLINE, tp.MAXREPLY = 8192, 65536
83
84if failures == 0 then
85 print("All tp maxsize tests passed")
86 os.exit(0)
87else
88 print(failures .. " tp maxsize test(s) failed")
89 os.exit(1)
90end