-
Notifications
You must be signed in to change notification settings - Fork 0
bugfix: client: verify the handshake response headers #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,15 +17,18 @@ local re_match = ngx.re.match | |
| local re_find = ngx.re.find | ||
| local re_gmatch = ngx.re.gmatch | ||
| local encode_base64 = ngx.encode_base64 | ||
| local sha1_bin = ngx.sha1_bin | ||
| local concat = table.concat | ||
| local insert = table.insert | ||
| local char = string.char | ||
| local str_find = string.find | ||
| local str_lower = string.lower | ||
| local str_sub = string.sub | ||
| local rand = math.random | ||
| local rshift = bit.rshift | ||
| local band = bit.band | ||
| local setmetatable = setmetatable | ||
| local ipairs = ipairs | ||
| local type = type | ||
| local debug = ngx.config.debug | ||
| local ngx_log = ngx.log | ||
|
|
@@ -48,6 +51,71 @@ _M._VERSION = '0.13' | |
| local mt = { __index = _M } | ||
|
|
||
|
|
||
| local WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" | ||
|
|
||
|
|
||
| -- true if a comma-separated header value carries the given token | ||
| local function has_token(value, token) | ||
| local iter, err = re_gmatch(value, [[[^,\s]+]], "jo") | ||
| if not iter then | ||
| ngx_log(ngx_DEBUG, "failed to parse header value: ", err) | ||
| return false | ||
| end | ||
|
|
||
| while true do | ||
| local m = iter() | ||
| if not m then | ||
| return false | ||
| end | ||
|
|
||
| if str_lower(m[0]) == token then | ||
| return true | ||
| end | ||
| end | ||
| end | ||
|
|
||
|
|
||
| -- RFC 6455 section 4.1: the client must fail the connection unless the server | ||
| -- proves it understood the handshake. Without these checks anything that | ||
| -- answers 101 passes for a websocket server, and a duplicated header (parsed | ||
| -- into a table) is a protocol error in its own right. | ||
| local function verify_handshake(resp_headers, key, protocols) | ||
| local upgrade = resp_headers.upgrade | ||
| if type(upgrade) ~= "string" or str_lower(upgrade) ~= "websocket" then | ||
| return nil, "invalid \"Upgrade\" response header" | ||
| end | ||
|
|
||
| local connection = resp_headers.connection | ||
| if type(connection) ~= "string" or not has_token(connection, "upgrade") then | ||
| return nil, "invalid \"Connection\" response header" | ||
| end | ||
|
|
||
| local accept = resp_headers.sec_websocket_accept | ||
| if type(accept) ~= "string" then | ||
| return nil, "missing \"Sec-WebSocket-Accept\" response header" | ||
| end | ||
|
|
||
| if accept ~= encode_base64(sha1_bin(key .. WS_GUID)) then | ||
| return nil, "invalid \"Sec-WebSocket-Accept\" response header" | ||
| end | ||
|
|
||
| -- the server may decline the subprotocol, but it may not invent one | ||
| local proto = resp_headers.sec_websocket_protocol | ||
| if proto ~= nil | ||
| and (type(proto) ~= "string" or not protocols[str_lower(proto)]) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Compare subprotocol names case-sensitively. The lowercase set accepts a server-selected protocol that the client did not offer exactly. For example, an offer of Store and compare the original protocol strings. Proposed fix- and (type(proto) ~= "string" or not protocols[str_lower(proto)])
+ and (type(proto) ~= "string" or not protocols[proto])- offered_protocols[str_lower(proto)] = true
+ offered_protocols[proto] = true
...
- offered_protocols[str_lower(protos)] = true
+ offered_protocols[protos] = trueAlso applies to: 219-225 🤖 Prompt for AI Agents |
||
| then | ||
| return nil, "invalid \"Sec-WebSocket-Protocol\" response header" | ||
|
Comment on lines
+103
to
+107
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: rg -n 'Sec-WebSocket-Protocol|sec_websocket_protocol|protocols' lib/resty/websocket t
sed -n '1,260p' lib/resty/websocket/server.luaRepository: api7/lua-resty-websocket Length of output: 9553 Fix the repository server before enforcing single-protocol selection.
Update the server to select exactly one offered protocol. Add an end-to-end test with multiple offered protocols. 🤖 Prompt for AI Agents |
||
| end | ||
|
|
||
| -- no extension is ever offered, so none may be accepted | ||
| if resp_headers.sec_websocket_extensions ~= nil then | ||
| return nil, "unexpected \"Sec-WebSocket-Extensions\" response header" | ||
| end | ||
|
|
||
| return true | ||
| end | ||
|
|
||
|
|
||
| function _M.new(self, opts) | ||
| local sock, err = tcp() | ||
| if not sock then | ||
|
|
@@ -135,6 +203,7 @@ function _M.connect(self, uri, opts) | |
| end | ||
|
|
||
| local ssl_verify, server_name, headers, proto_header, origin_header | ||
| local offered_protocols = {} | ||
| local sock_opts = {} | ||
| local client_cert, client_priv_key | ||
| local header_host | ||
|
|
@@ -147,8 +216,13 @@ function _M.connect(self, uri, opts) | |
| proto_header = "\r\nSec-WebSocket-Protocol: " | ||
| .. concat(protos, ",") | ||
|
|
||
| for _, proto in ipairs(protos) do | ||
| offered_protocols[str_lower(proto)] = true | ||
| end | ||
|
|
||
| else | ||
| proto_header = "\r\nSec-WebSocket-Protocol: " .. protos | ||
| offered_protocols[str_lower(protos)] = true | ||
| end | ||
| end | ||
|
|
||
|
|
@@ -369,8 +443,6 @@ function _M.connect(self, uri, opts) | |
|
|
||
| -- error("header: " .. header) | ||
|
|
||
| -- FIXME: verify the response headers | ||
|
|
||
| m, err = re_match(header, [[^\s*HTTP/1\.1\s+(\d+)]], "jo") | ||
| if not m then | ||
| return nil, "bad HTTP response status line: " .. header | ||
|
|
@@ -395,6 +467,26 @@ function _M.connect(self, uri, opts) | |
| return nil, "unexpected HTTP response code: " .. m[1], header | ||
| end | ||
|
|
||
| local resp_headers | ||
| resp_headers, err = self:get_resp_headers() | ||
| if not resp_headers then | ||
| err = "failed to parse response headers: " .. err | ||
|
|
||
| else | ||
| ok, err = verify_handshake(resp_headers, key, offered_protocols) | ||
| end | ||
|
|
||
| if err then | ||
| local closing_ok, closing_err = sock:close() | ||
| if not closing_ok then | ||
| ngx_log(ngx_DEBUG, "failed to close the underlying socket: ", | ||
| closing_err, " when handling a failed handshake") | ||
| end | ||
|
|
||
| self.fatal = true | ||
| return nil, "failed websocket handshake: " .. err, header | ||
| end | ||
|
|
||
| return 1, nil, header | ||
| end | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: api7/lua-resty-websocket
Length of output: 9651
🏁 Script executed:
Repository: api7/lua-resty-websocket
Length of output: 6087
🏁 Script executed:
Repository: api7/lua-resty-websocket
Length of output: 5779
Trim trailing SP/HTAB from response header values.
get_resp_headersremoves leading whitespace after the colon, but its capture ends directly at\r\n. Trailing SP/HTAB therefore remains inUpgrade,Sec-WebSocket-Accept, andSec-WebSocket-Protocol. These values can fail the exact comparisons inverify_handshake.Suggested fix
🤖 Prompt for AI Agents