diff options
| author | Caleb Maclennan <caleb@alerque.com> | 2026-08-31 11:04:37 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-31 11:04:37 +0300 |
| commit | 01162f05408ac4206be15842ef91048c63c23869 (patch) | |
| tree | 2b7c8a2916cf8083b557a897c7d258e00a9c4fa3 | |
| parent | 5afe174eeffc7f3e3b9ef80b5871237620c68415 (diff) | |
| parent | c84e79f6d1ae735bfa191694ec6a62eb8231166d (diff) | |
| download | luasocket-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.md | 1 | ||||
| -rw-r--r-- | src/http.lua | 27 | ||||
| -rw-r--r-- | src/tp.lua | 18 | ||||
| -rw-r--r-- | test/maxsize_http.lua | 110 | ||||
| -rw-r--r-- | test/maxsize_tp.lua | 90 |
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 |
| 30 | local SCHEMES = { | 35 | local SCHEMES = { |
| @@ -47,8 +52,18 @@ local SCHEMES = { | |||
| 47 | local function receiveheaders(sock, headers) | 52 | local 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) |
| 173 | end | 188 | end |
| @@ -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) |
| 27 | local function get_reply(c) | 31 | local 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. | ||
| 5 | local socket = require "socket" | ||
| 6 | local http = require "socket.http" | ||
| 7 | local ltn12 = require "ltn12" | ||
| 8 | |||
| 9 | local 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. | ||
| 14 | local 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 | ||
| 21 | end | ||
| 22 | |||
| 23 | local failures = 0 | ||
| 24 | |||
| 25 | local function check(ok, msg) | ||
| 26 | if ok then | ||
| 27 | print("PASS: " .. msg) | ||
| 28 | else | ||
| 29 | failures = failures + 1 | ||
| 30 | print("FAIL: " .. msg) | ||
| 31 | end | ||
| 32 | end | ||
| 33 | |||
| 34 | local function http_open(ip, port) | ||
| 35 | return http.open(ip, port, socket.tcp) | ||
| 36 | end | ||
| 37 | |||
| 38 | do -- 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() | ||
| 47 | end | ||
| 48 | |||
| 49 | do -- 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() | ||
| 57 | end | ||
| 58 | |||
| 59 | do -- 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() | ||
| 69 | end | ||
| 70 | |||
| 71 | do -- 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() | ||
| 83 | end | ||
| 84 | |||
| 85 | do -- 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() | ||
| 100 | end | ||
| 101 | |||
| 102 | http.MAXHEADERLINE, http.MAXHEADERSIZE = 8192, 65536 | ||
| 103 | |||
| 104 | if failures == 0 then | ||
| 105 | print("All http maxsize tests passed") | ||
| 106 | os.exit(0) | ||
| 107 | else | ||
| 108 | print(failures .. " http maxsize test(s) failed") | ||
| 109 | os.exit(1) | ||
| 110 | 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 @@ | |||
| 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. | ||
| 6 | local socket = require "socket" | ||
| 7 | local tp = require "socket.tp" | ||
| 8 | |||
| 9 | local 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. | ||
| 14 | local 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 | ||
| 21 | end | ||
| 22 | |||
| 23 | local failures = 0 | ||
| 24 | |||
| 25 | local function check(ok, msg) | ||
| 26 | if ok then | ||
| 27 | print("PASS: " .. msg) | ||
| 28 | else | ||
| 29 | failures = failures + 1 | ||
| 30 | print("FAIL: " .. msg) | ||
| 31 | end | ||
| 32 | end | ||
| 33 | |||
| 34 | local function tp_open(ip, port) | ||
| 35 | return tp.connect(ip, port, 5) | ||
| 36 | end | ||
| 37 | |||
| 38 | do -- 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() | ||
| 46 | end | ||
| 47 | |||
| 48 | do -- 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() | ||
| 56 | end | ||
| 57 | |||
| 58 | do -- 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() | ||
| 66 | end | ||
| 67 | |||
| 68 | do -- 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() | ||
| 80 | end | ||
| 81 | |||
| 82 | tp.MAXLINE, tp.MAXREPLY = 8192, 65536 | ||
| 83 | |||
| 84 | if failures == 0 then | ||
| 85 | print("All tp maxsize tests passed") | ||
| 86 | os.exit(0) | ||
| 87 | else | ||
| 88 | print(failures .. " tp maxsize test(s) failed") | ||
| 89 | os.exit(1) | ||
| 90 | end | ||
