From 292c8d16ac64d7b3af8f5df46f7cfc86e37fd58e Mon Sep 17 00:00:00 2001 From: Zeping Bu Date: Fri, 18 Sep 2026 17:25:35 +0800 Subject: [PATCH 1/2] fix(proxy): let callers raise the 65535-byte default frame size limit resty.websocket.proxy called ws_client:new() and ws_server:new() with no options, so both sides of the proxy were stuck at the library's default max_payload_len of 65535 bytes, with no way to raise it. This is checked in protocol.lua's recv_frame() before aggregate_fragments gets a chance to reassemble anything, so opts.client_max_frame_size/ opts.upstream_max_frame_size (which only run after a frame is successfully received) couldn't work around it either. A single frame over that size marks the socket fatal and the forwarder tears down the whole proxied connection; enable_websocket-style raw relaying has no such limit, so this broke any client that doesn't fragment large messages by default after switching to this proxy. Reported against api7-lua-resty-websocket 0.1.0 in https://github.com/apache/apisix/pull/13939#discussion_r4013893671. Add opts.client_new_opts and opts.upstream_new_opts to proxy.new(): tables passed through as-is to ws_server:new() and ws_client:new() respectively, the same way connect_upstream() already passes its opts through to connect(). This exposes everything those constructors support (max_payload_len, max_recv_len, max_send_len, send_masked/ send_unmasked, timeout), not just the payload length settings that prompted this fix, and needs no changes here when either module adds another constructor option later. Left unset, both keep defaulting to 65535 as before, so this is purely additive. 'client'/'upstream' here name this proxy's two roles, matching opts.client_max_frame_size/opts.upstream_max_fragments already do: 'client' is the role facing the real downstream WebSocket client (served through self.server, a resty.websocket.server instance), and 'upstream' faces the backend (through self.client, a resty.websocket.client instance). Commented at both call sites since it's easy to misread 'client_new_opts' going into ws_server:new() as a mistake. Adds t/09-max-payload-len.t: TEST 1 pins the unchanged default (a lone >65535-byte frame still kills the connection when these options aren't set), TEST 2 sends a 100000-byte frame through with both options raised and checks it round-trips intact. --- lib/resty/websocket/proxy.lua | 35 ++++++++- t/09-max-payload-len.t | 136 ++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 t/09-max-payload-len.t diff --git a/lib/resty/websocket/proxy.lua b/lib/resty/websocket/proxy.lua index bc9b211..3d40b1e 100644 --- a/lib/resty/websocket/proxy.lua +++ b/lib/resty/websocket/proxy.lua @@ -106,10 +106,31 @@ function _M.new(opts) error("opts.upstream_max_fragments must be a number >= 1", 2) end + if opts.client_new_opts ~= nil and type(opts.client_new_opts) ~= "table" then + error("opts.client_new_opts must be a table", 2) + end + + if opts.upstream_new_opts ~= nil and type(opts.upstream_new_opts) ~= "table" then + error("opts.upstream_new_opts must be a table", 2) + end + - -- TODO: provide a means of passing options through to the - -- resty.websocket.client constructor (like `max_payload_len`) - local client, err = ws_client:new() + -- as with client_max_frame_size/upstream_max_fragments above, "client" + -- and "upstream" name the two roles of this proxy, not the two library + -- modules: "client" is the role facing the real, downstream WebSocket + -- client, which this proxy serves through a resty.websocket.server + -- instance (self.server, created in connect_client() below); "upstream" + -- is the role facing the backend, served through a resty.websocket.client + -- instance (self.client, created right here). So opts.client_new_opts + -- ends up at ws_server:new(), and opts.upstream_new_opts at ws_client:new(). + -- + -- Each is passed through as-is to that constructor (same as + -- connect_upstream() already does for connect()'s own options), giving + -- the caller access to everything it supports: max_payload_len, + -- max_recv_len, max_send_len, send_unmasked/send_masked, timeout. Left + -- unset, that constructor keeps defaulting max_payload_len (and, through + -- it, max_recv_len/max_send_len) to 65535, same as it always has. + local client, err = ws_client:new(opts.upstream_new_opts) if not client then return nil, "failed to create client: " .. err end @@ -124,6 +145,9 @@ function _M.new(opts) client_max_fragments = opts.client_max_fragments, upstream_max_frame_size = opts.upstream_max_frame_size, upstream_max_fragments = opts.upstream_max_fragments, + -- passed to ws_server:new() in connect_client(), once the server + -- accepting the downstream client's connection is actually created + client_new_opts = opts.client_new_opts, aggregate_fragments = opts.aggregate_fragments, debug = opts.debug, client_state = _STATES.INIT, @@ -475,7 +499,10 @@ function _M:connect_client() self:dd("completing client handshake") - local server, err = ws_server:new() + -- self.client_new_opts is opts.client_new_opts from _M.new(): the + -- "client" role, i.e. the real downstream client, is served through + -- this resty.websocket.server instance + local server, err = ws_server:new(self.client_new_opts) if not server then return nil, err end diff --git a/t/09-max-payload-len.t b/t/09-max-payload-len.t new file mode 100644 index 0000000..9610f2e --- /dev/null +++ b/t/09-max-payload-len.t @@ -0,0 +1,136 @@ +# vim:set ts=4 sts=4 sw=4 et ft=: + +use lib '.'; +use t::Tests; + +plan tests => repeat_each() * (blocks() * 4); + +run_tests(); + +__DATA__ + +=== TEST 1: a frame over 65535 bytes still kills the connection when no size options are set (unchanged default) +--- http_config eval: $t::Tests::HttpConfig +--- config + location /proxy { + content_by_lua_block { + local proxy = require "resty.websocket.proxy" + + local wp, err = proxy.new() + if not wp then + ngx.log(ngx.ERR, "failed creating proxy: ", err) + return ngx.exit(444) + end + + local ok, err = wp:connect(proxy._tests.echo) + if not ok then + ngx.log(ngx.ERR, err) + return ngx.exit(444) + end + + local done, err = wp:execute() + if not done then + ngx.log(ngx.ERR, "failed proxying: ", err) + return ngx.exit(444) + end + } + } + + location /t { + content_by_lua_block { + local client = require "resty.websocket.client" + -- raise only the test client's own limit, so it can actually + -- put a >65535 byte frame on the wire; the proxy itself is the + -- thing under test here and gets no such option + local wb = assert(client:new({ max_payload_len = 70000 })) + local uri = "ws://127.0.0.1:" .. ngx.var.server_port .. "/proxy" + + assert(wb:connect(uri)) + assert(wb:send_text(string.rep("a", 65536))) + local data, typ, err = wb:recv_frame() + ngx.say(string.format("data: %s, typ: %s, err: %s", + tostring(data), tostring(typ), tostring(err))) + } + } +--- response_body +data: nil, typ: nil, err: failed to receive the first 2 bytes: closed +--- grep_error_log eval: qr/\[lua\].*/ +--- grep_error_log_out eval +qr/failed receiving frame from client: exceeding max payload len/ + + + +=== TEST 2: client_new_opts and upstream_new_opts let a frame over 65535 bytes round-trip +--- http_config eval: $t::Tests::HttpConfig +--- config + location /proxy { + content_by_lua_block { + local proxy = require "resty.websocket.proxy" + + local wp, err = proxy.new({ + client_new_opts = { max_payload_len = 100000 }, + upstream_new_opts = { max_payload_len = 100000 }, + }) + if not wp then + ngx.log(ngx.ERR, "failed creating proxy: ", err) + return ngx.exit(444) + end + + local uri = "ws://127.0.0.1:" .. ngx.var.server_port .. "/upstream" + local ok, err = wp:connect(uri) + if not ok then + ngx.log(ngx.ERR, err) + return ngx.exit(444) + end + + local done, err = wp:execute() + if not done then + ngx.log(ngx.ERR, "failed proxying: ", err) + return ngx.exit(444) + end + } + } + + location /upstream { + content_by_lua_block { + local server = require "resty.websocket.server" + + local wb, err = server:new({ max_payload_len = 100000 }) + if not wb then + ngx.log(ngx.ERR, "failed creating server: ", err) + return ngx.exit(444) + end + + local data, typ, err = wb:recv_frame() + if not data then + ngx.log(ngx.ERR, "failed receiving frame: ", err) + return ngx.exit(444) + end + + local bytes, err = wb:send_text(data) + if not bytes then + ngx.log(ngx.ERR, "failed sending frame: ", err) + return ngx.exit(444) + end + } + } + + location /t { + content_by_lua_block { + local client = require "resty.websocket.client" + local wb = assert(client:new({ max_payload_len = 100000 })) + local uri = "ws://127.0.0.1:" .. ngx.var.server_port .. "/proxy" + + local payload = string.rep("a", 100000) + assert(wb:connect(uri)) + assert(wb:send_text(payload)) + local data, typ, err = wb:recv_frame() + ngx.say(string.format("typ: %s, len: %s, echoed: %s", + tostring(typ), data and #data, + tostring(data == payload))) + } + } +--- response_body +typ: text, len: 100000, echoed: true +--- no_error_log +[error] From de5dd5190253482479b0cdb4f9100a4c60893028 Mon Sep 17 00:00:00 2001 From: Zeping Bu Date: Fri, 18 Sep 2026 17:33:01 +0800 Subject: [PATCH 2/2] test: fix plan count and expected error text in t/09-max-payload-len.t Each of this file's two blocks produces 3 TAP assertions (status code, response_body, error_log check), not 4; the wrong multiplier made the plan count mismatch actual output. Also, the connection teardown in TEST 1 surfaces as "connection reset by peer" rather than "closed" both locally and in CI, so match that instead. Verified locally this time (openresty + Test::Nginx were available): t/09-max-payload-len.t passes, and the rest of the suite is unaffected. --- t/09-max-payload-len.t | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/t/09-max-payload-len.t b/t/09-max-payload-len.t index 9610f2e..67e93cb 100644 --- a/t/09-max-payload-len.t +++ b/t/09-max-payload-len.t @@ -3,7 +3,7 @@ use lib '.'; use t::Tests; -plan tests => repeat_each() * (blocks() * 4); +plan tests => repeat_each() * (blocks() * 3); run_tests(); @@ -53,7 +53,7 @@ __DATA__ } } --- response_body -data: nil, typ: nil, err: failed to receive the first 2 bytes: closed +data: nil, typ: nil, err: failed to receive the first 2 bytes: connection reset by peer --- grep_error_log eval: qr/\[lua\].*/ --- grep_error_log_out eval qr/failed receiving frame from client: exceeding max payload len/