diff options
| author | 2024-07-17 00:06:50 +0100 | |
|---|---|---|
| committer | 2024-07-17 00:06:50 +0100 | |
| commit | 889d521e05f2e922683d48c74eb00290e7a2f548 (patch) | |
| tree | b4c56bd4cdf0881601c7e4d6ff571e491af10bf4 /modules/websocket.cpp | |
| parent | Use std::endian from C++20 in the sha1 module. (diff) | |
Shuffle the modules about a bit.
Diffstat (limited to 'modules/websocket.cpp')
| -rw-r--r-- | modules/websocket.cpp | 692 |
1 files changed, 692 insertions, 0 deletions
diff --git a/modules/websocket.cpp b/modules/websocket.cpp new file mode 100644 index 000000000..8119f30c6 --- /dev/null +++ b/modules/websocket.cpp @@ -0,0 +1,692 @@ +/* + * InspIRCd -- Internet Relay Chat Daemon + * + * Copyright (C) 2021 Dominic Hamon + * Copyright (C) 2019 iwalkalone <iwalkalone69@gmail.com> + * Copyright (C) 2018-2024 Sadie Powell <sadie@witchery.services> + * Copyright (C) 2016 Attila Molnar <attilamolnar@hush.com> + * + * This file is part of InspIRCd. InspIRCd is free software: you can + * redistribute it and/or modify it under the terms of the GNU General Public + * License as published by the Free Software Foundation, version 2. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + + +#include "inspircd.h" +#include "iohook.h" +#include "modules/hash.h" +#include "utility/string.h" + +#define UTF_CPP_CPLUSPLUS 199711L +#include <utfcpp/unchecked.h> + +static constexpr char MagicGUID[] = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +static constexpr char newline[] = "\r\n"; +static constexpr char whitespace[] = " \t"; +static dynamic_reference_nocheck<HashProvider>* sha1; + +struct WebSocketConfig final +{ + enum DefaultMode + { + // Reject connections if a subprotocol is not requested. + DM_REJECT, + + // Use binary frames if a subprotocol is not requested. + DM_BINARY, + + // Use UTF-8 text frames if a subprotocol is not requested. + DM_TEXT + }; + + typedef std::vector<std::string> OriginList; + typedef std::vector<std::string> ProxyRanges; + + // The HTTP origins that can connect to the server. + OriginList allowedorigins; + + // The method to use if a subprotocol is not negotiated. + DefaultMode defaultmode; + + // The IP ranges which send trustworthy X-Real-IP or X-Forwarded-For headers. + ProxyRanges proxyranges; + + // Whether to allow connections from clients that do not send an Origin header. + bool allowmissingorigin; + + // Whether to send WebSocket ping messages instead of IRC ping messages. + bool nativeping; +}; + +class WebSocketHookProvider final + : public IOHookProvider +{ +public: + WebSocketConfig config; + WebSocketHookProvider(Module* mod) + : IOHookProvider(mod, "websocket", IOHookProvider::IOH_UNKNOWN, true) + { + } + + void OnAccept(StreamSocket* sock, const irc::sockets::sockaddrs& client, const irc::sockets::sockaddrs& server) override; + + void OnConnect(StreamSocket* sock) override + { + } +}; + +class WebSocketHook final + : public IOHookMiddle +{ + class HTTPHeaderFinder final + { + private: + std::string::size_type bpos; + std::string::size_type len; + + public: + bool Find(const std::string& req, const char* header, std::string::size_type headerlen, std::string::size_type maxpos) + { + // Skip the GET /wibble HTTP/1.1 line. + size_t startpos = req.find(newline) + sizeof(newline) - 1; + while (startpos < maxpos) + { + size_t endpos = req.find(newline, startpos); + if (strncasecmp(req.c_str() + startpos, header, headerlen) != 0) + { + startpos = endpos + sizeof(newline) - 1; + continue; // Incorrect header. + } + + bpos = req.find_first_not_of(whitespace, startpos + headerlen); + if (bpos >= endpos) + return false; // Empty value. + + size_t epos = std::min(req.find_last_not_of(whitespace), endpos); + if (epos < bpos) + return false; // Should never happen. + + len = epos - bpos; + return true; + } + + // No header found. + return false; + } + + std::string ExtractValue(const std::string& req) const + { + return std::string(req, bpos, len); + } + }; + + enum CloseCode + { + CLOSE_PROTOCOL_ERROR = 1002, + CLOSE_POLICY_VIOLATION = 1008, + CLOSE_TOO_LARGE = 1009 + }; + + enum OpCode + { + OP_CONTINUATION = 0x00, + OP_TEXT = 0x01, + OP_BINARY = 0x02, + OP_CLOSE = 0x08, + OP_PING = 0x09, + OP_PONG = 0x0a + }; + + enum State + { + STATE_HTTPREQ, + STATE_ESTABLISHED + }; + + static constexpr unsigned char WS_MASKBIT = (1 << 7); + static constexpr unsigned char WS_FINBIT = (1 << 7); + static constexpr unsigned char WS_PAYLOAD_LENGTH_MAGIC_LARGE = 126; + static constexpr unsigned char WS_PAYLOAD_LENGTH_MAGIC_HUGE = 127; + static constexpr size_t WS_MAX_PAYLOAD_LENGTH_SMALL = 125; + static constexpr size_t WS_MAX_PAYLOAD_LENGTH_LARGE = 65535; + static constexpr size_t MAXHEADERSIZE = sizeof(uint64_t) + 2; + + // Clients sending ping or pong frames faster than this are killed + static constexpr time_t MINPINGPONGDELAY = 10; + + State state = STATE_HTTPREQ; + time_t lastpingpong = 0; + WebSocketConfig& config; + bool sendastext; + + static size_t FillHeader(unsigned char* outbuf, size_t sendlength, OpCode opcode) + { + size_t pos = 0; + outbuf[pos++] = WS_FINBIT | opcode; + + if (sendlength <= WS_MAX_PAYLOAD_LENGTH_SMALL) + { + outbuf[pos++] = sendlength; + } + else if (sendlength <= WS_MAX_PAYLOAD_LENGTH_LARGE) + { + outbuf[pos++] = WS_PAYLOAD_LENGTH_MAGIC_LARGE; + outbuf[pos++] = (sendlength >> 8) & 0xff; + outbuf[pos++] = sendlength & 0xff; + } + else + { + outbuf[pos++] = WS_PAYLOAD_LENGTH_MAGIC_HUGE; + const uint64_t len = sendlength; + for (int i = sizeof(uint64_t)-1; i >= 0; i--) + outbuf[pos++] = ((len >> i*8) & 0xff); + } + + return pos; + } + + static StreamSocket::SendQueue::Element PrepareSendQElem(size_t size, OpCode opcode) + { + unsigned char header[MAXHEADERSIZE]; + const size_t n = FillHeader(header, size, opcode); + + return StreamSocket::SendQueue::Element(reinterpret_cast<const char*>(header), n); + } + + int HandleAppData(StreamSocket* sock, std::string& appdataout, bool allowlarge) + { + std::string& myrecvq = GetRecvQ(); + // Need 1 byte opcode, minimum 1 byte len, 4 bytes masking key + if (myrecvq.length() < 6) + return 0; + + const std::string& cmyrecvq = myrecvq; + unsigned char len1 = (unsigned char)cmyrecvq[1]; + if (!(len1 & WS_MASKBIT)) + { + CloseConnection(sock, CLOSE_PROTOCOL_ERROR, "WebSocket protocol violation: unmasked client frame"); + return -1; + } + + len1 &= ~WS_MASKBIT; + + // Assume the length is a single byte, if not, update values later + unsigned int len = len1; + unsigned int payloadstartoffset = 6; + const unsigned char* maskkey = reinterpret_cast<const unsigned char*>(&cmyrecvq[2]); + + if (len1 == WS_PAYLOAD_LENGTH_MAGIC_LARGE) + { + // allowlarge is false for control frames according to the RFC meaning large pings, etc. are not allowed + if (!allowlarge) + { + CloseConnection(sock, CLOSE_PROTOCOL_ERROR, "WebSocket protocol violation: large control frame"); + return -1; + } + + // Large frame, has 2 bytes len after the magic byte indicating the length + // Need 1 byte opcode, 3 bytes len, 4 bytes masking key + if (myrecvq.length() < 8) + return 0; + + unsigned char len2 = (unsigned char)cmyrecvq[2]; + unsigned char len3 = (unsigned char)cmyrecvq[3]; + len = (len2 << 8) | len3; + + if (len <= WS_MAX_PAYLOAD_LENGTH_SMALL) + { + CloseConnection(sock, CLOSE_PROTOCOL_ERROR, "WebSocket protocol violation: non-minimal length encoding used"); + return -1; + } + + maskkey += 2; + payloadstartoffset += 2; + } + else if (len1 == WS_PAYLOAD_LENGTH_MAGIC_HUGE) + { + CloseConnection(sock, CLOSE_TOO_LARGE, "WebSocket: Huge frames are not supported"); + return -1; + } + + if (myrecvq.length() < payloadstartoffset + len) + return 0; + + unsigned int maskkeypos = 0; + const std::string::iterator endit = myrecvq.begin() + payloadstartoffset + len; + for (std::string::const_iterator i = myrecvq.begin() + payloadstartoffset; i != endit; ++i) + { + const unsigned char c = (unsigned char)*i; + appdataout.push_back(c ^ maskkey[maskkeypos++]); + maskkeypos %= 4; + } + + myrecvq.erase(myrecvq.begin(), endit); + return 1; + } + + int HandlePingPongFrame(StreamSocket* sock, bool isping) + { + if (lastpingpong + MINPINGPONGDELAY >= ServerInstance->Time()) + { + CloseConnection(sock, CLOSE_POLICY_VIOLATION, "WebSocket: Ping/pong flood"); + return -1; + } + + lastpingpong = ServerInstance->Time(); + + std::string appdata; + const int result = HandleAppData(sock, appdata, false); + if (result <= 0) + return result; + + if (isping) + { + StreamSocket::SendQueue::Element elem = PrepareSendQElem(appdata.length(), OP_PONG); + elem.append(appdata); + GetSendQ().push_back(elem); + + SocketEngine::ChangeEventMask(sock, FD_ADD_TRIAL_WRITE); + } + else if (sock->type == StreamSocket::SS_USER && config.nativeping) + { + // Pong reply on user socket; reset their idle time. + UserIOHandler* ioh = static_cast<UserIOHandler*>(sock); + ioh->user->lastping = 1; + } + return 1; + } + + int HandleWS(StreamSocket* sock, std::string& destrecvq) + { + if (GetRecvQ().empty()) + return 0; + + unsigned char opcode = (unsigned char)GetRecvQ()[0]; + switch (opcode & ~WS_FINBIT) + { + case OP_CONTINUATION: + case OP_TEXT: + case OP_BINARY: + { + std::string appdata; + const int result = HandleAppData(sock, appdata, true); + if (result != 1) + return result; + + // Strip out any CR+LF which may have been erroneously sent. + for (const auto chr : appdata) + { + if (chr != '\r' && chr != '\n') + destrecvq.push_back(chr); + } + + // If we are on the final message of this block append a line terminator. + if (opcode & WS_FINBIT) + destrecvq.append("\r\n"); + + return 1; + } + + case OP_PING: + { + return HandlePingPongFrame(sock, true); + } + + case OP_PONG: + { + // A pong frame may be sent unsolicited, so we have to handle it. + // It may carry application data which we need to remove from the recvq as well. + return HandlePingPongFrame(sock, false); + } + + case OP_CLOSE: + { + sock->SetError("Connection closed"); + return -1; + } + + default: + { + CloseConnection(sock, CLOSE_PROTOCOL_ERROR, "WebSocket: Invalid opcode"); + return -1; + } + } + } + + void CloseConnection(StreamSocket* sock, CloseCode closecode, const std::string& reason) + { + uint16_t netcode = htons(closecode); + std::string packedcode; + packedcode.push_back(netcode & 0x00FF); + packedcode.push_back(netcode >> 8); + + GetSendQ().push_back(PrepareSendQElem(reason.length() + 2, OP_CLOSE)); + GetSendQ().push_back(packedcode); + GetSendQ().push_back(reason); + sock->DoWrite(); + sock->SetError(reason); + } + + void FailHandshake(StreamSocket* sock, const char* httpreply, const char* sockerror) + { + GetSendQ().push_back(StreamSocket::SendQueue::Element(httpreply)); + GetSendQ().push_back(StreamSocket::SendQueue::Element(sockerror)); + sock->DoWrite(); + sock->SetError(sockerror); + } + + int HandleHTTPReq(StreamSocket* sock) + { + std::string& recvq = GetRecvQ(); + const std::string::size_type reqend = recvq.find("\r\n\r\n"); + if (reqend == std::string::npos) + return 0; + + bool allowedorigin = false; + HTTPHeaderFinder originheader; + if (originheader.Find(recvq, "Origin:", 7, reqend)) + { + const std::string origin = originheader.ExtractValue(recvq); + for (const auto& cfgorigin : config.allowedorigins) + { + if (InspIRCd::Match(origin, cfgorigin, ascii_case_insensitive_map)) + { + allowedorigin = true; + break; + } + } + } + else if (config.allowmissingorigin) + { + // This is a non-web WebSocket connection. + allowedorigin = true; + } + else + { + FailHandshake(sock, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n", "WebSocket: Received HTTP request that did not send the Origin header"); + return -1; + } + + if (!allowedorigin) + { + FailHandshake(sock, "HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n", "WebSocket: Received HTTP request from a non-whitelisted origin"); + return -1; + } + + if (!config.proxyranges.empty() && sock->type == StreamSocket::SS_USER) + { + LocalUser* luser = static_cast<UserIOHandler*>(sock)->user; + irc::sockets::sockaddrs realsa(luser->client_sa); + + HTTPHeaderFinder proxyheader; + if (proxyheader.Find(recvq, "X-Real-IP:", 10, reqend) || proxyheader.Find(recvq, "X-Forwarded-For:", 16, reqend)) + { + // Attempt to parse the proxy HTTP header. + if (!realsa.from_ip_port(proxyheader.ExtractValue(recvq), realsa.port())) + { + // The proxy header value contains a malformed value. + FailHandshake(sock, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n", "WebSocket: Received a proxied HTTP request that sent a malformed real IP address"); + return -1; + } + } + else + { + // The proxy header is missing. + FailHandshake(sock, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n", "WebSocket: Received a proxied HTTP request that did not send a real IP address header"); + return -1; + } + for (const auto& proxyrange : config.proxyranges) + { + if (InspIRCd::MatchCIDR(luser->GetAddress(), proxyrange, ascii_case_insensitive_map)) + { + // Give the user their real IP address. + if (realsa != luser->client_sa) + luser->ChangeRemoteAddress(realsa); + + // Error if changing their IP gets them banned. + if (luser->quitting) + return -1; + break; + } + } + } + + std::string selectedproto; + HTTPHeaderFinder protocolheader; + if (protocolheader.Find(recvq, "Sec-WebSocket-Protocol:", 23, reqend)) + { + irc::commasepstream protostream(protocolheader.ExtractValue(recvq)); + for (std::string proto; protostream.GetToken(proto); ) + { + std::erase_if(proto, ::isspace); + + bool is_binary = insp::equalsci(proto, "binary.inspircd.org"); + bool is_text = insp::equalsci(proto, "text.inspircd.org"); + + if (is_binary || is_text) + { + selectedproto = std::move(proto); + sendastext = is_text; + break; + } + } + } + + if (selectedproto.empty() && config.defaultmode == WebSocketConfig::DM_REJECT) + { + FailHandshake(sock, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n", "WebSocket: Received HTTP request that did not send the Sec-WebSocket-Protocol header"); + return -1; + } + + HTTPHeaderFinder keyheader; + if (!keyheader.Find(recvq, "Sec-WebSocket-Key:", 18, reqend)) + { + FailHandshake(sock, "HTTP/1.1 501 Not Implemented\r\nConnection: close\r\n\r\n", "WebSocket: Received HTTP request which is not a websocket upgrade"); + return -1; + } + + if (!*sha1) + { + FailHandshake(sock, "HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n", "WebSocket: SHA-1 provider missing"); + return -1; + } + + state = STATE_ESTABLISHED; + + std::string key = keyheader.ExtractValue(recvq); + key.append(MagicGUID); + + std::string reply = "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: "; + reply.append(Base64::Encode((*sha1)->GenerateRaw(key), nullptr, '=')).append(newline); + if (!selectedproto.empty()) + reply.append("Sec-WebSocket-Protocol: ").append(selectedproto).append(newline); + reply.append(newline); + GetSendQ().push_back(StreamSocket::SendQueue::Element(reply)); + + SocketEngine::ChangeEventMask(sock, FD_ADD_TRIAL_WRITE); + + recvq.erase(0, reqend + 4); + + return 1; + } + +public: + WebSocketHook(const std::shared_ptr<IOHookProvider>& Prov, StreamSocket* sock, WebSocketConfig& cfg) + : IOHookMiddle(Prov) + , config(cfg) + , sendastext(config.defaultmode != WebSocketConfig::DM_BINARY) + { + sock->AddIOHook(this); + } + + bool IsHookReady() const override + { + return state == STATE_ESTABLISHED; + } + + ssize_t OnStreamSocketWrite(StreamSocket* sock, StreamSocket::SendQueue& uppersendq) override + { + StreamSocket::SendQueue& mysendq = GetSendQ(); + + // Return 1 to allow sending back an error HTTP response + if (state != STATE_ESTABLISHED) + return (mysendq.empty() ? 0 : 1); + + std::string message; + for (const auto& elem : uppersendq) + { + for (const auto chr : elem) + { + if (chr == '\n') + { + // We have found an entire message. Send it in its own frame. + if (sendastext) + { + // If we send messages as text then we need to ensure they are valid UTF-8. + std::string encoded; + utf8::unchecked::replace_invalid(message.begin(), message.end(), std::back_inserter(encoded)); + + mysendq.push_back(PrepareSendQElem(encoded.length(), OP_TEXT)); + mysendq.push_back(encoded); + } + else + { + // Otherwise, send the raw message as a binary frame. + mysendq.push_back(PrepareSendQElem(message.length(), OP_BINARY)); + mysendq.push_back(message); + } + message.clear(); + } + else if (chr != '\r') + { + message.push_back(chr); + } + } + } + + // Empty the upper send queue and push whatever is left back onto it. + uppersendq.clear(); + if (!message.empty()) + { + uppersendq.push_back(message); + return 0; + } + + return 1; + } + + ssize_t OnStreamSocketRead(StreamSocket* sock, std::string& destrecvq) override + { + if (state == STATE_HTTPREQ) + { + int httpret = HandleHTTPReq(sock); + if (httpret <= 0) + return httpret; + } + + int wsret; + do + { + wsret = HandleWS(sock, destrecvq); + } + while ((!GetRecvQ().empty()) && (wsret > 0)); + + return wsret; + } + + bool Ping() override + { + if (!config.nativeping) + return false; + + StreamSocket::SendQueue& mysendq = GetSendQ(); + + const std::string& message = ServerInstance->Config->GetServerName(); + mysendq.push_back(PrepareSendQElem(message.length(), OP_PING)); + mysendq.push_back(message); + + return true; + } +}; + +void WebSocketHookProvider::OnAccept(StreamSocket* sock, const irc::sockets::sockaddrs& client, const irc::sockets::sockaddrs& server) +{ + new WebSocketHook(shared_from_this(), sock, config); +} + +class ModuleWebSocket final + : public Module +{ +private: + dynamic_reference_nocheck<HashProvider> hash; + std::shared_ptr<WebSocketHookProvider> hookprov; + +public: + ModuleWebSocket() + : Module(VF_VENDOR, "Allows WebSocket clients to connect to the IRC server.") + , hash(this, "hash/sha1") + , hookprov(std::make_shared<WebSocketHookProvider>(this)) + { + sha1 = &hash; + } + + void ReadConfig(ConfigStatus& status) override + { + auto tags = ServerInstance->Config->ConfTags("wsorigin"); + if (tags.empty()) + throw ModuleException(this, "You have loaded the websocket module but not configured any allowed origins!"); + + WebSocketConfig config; + for (const auto& [_, tag] : tags) + { + // Ensure that we have the <wsorigin:allow> parameter. + const std::string allow = tag->getString("allow"); + if (allow.empty()) + throw ModuleException(this, "<wsorigin:allow> is a mandatory field, at " + tag->source.str()); + + config.allowedorigins.push_back(allow); + } + + const auto& tag = ServerInstance->Config->ConfValue("websocket"); + + const std::string defaultmodestr = tag->getString("defaultmode", "text", 1); + if (insp::equalsci(defaultmodestr, "reject")) + config.defaultmode = WebSocketConfig::DM_REJECT; + else if (insp::equalsci(defaultmodestr, "binary")) + config.defaultmode = WebSocketConfig::DM_BINARY; + else if (insp::equalsci(defaultmodestr, "text")) + config.defaultmode = WebSocketConfig::DM_TEXT; + else + throw ModuleException(this, defaultmodestr + " is an invalid value for <websocket:defaultmode>; acceptable values are 'binary', 'text' and 'reject', at " + tag->source.str()); + + irc::spacesepstream proxyranges(tag->getString("proxyranges")); + for (std::string proxyrange; proxyranges.GetToken(proxyrange); ) + config.proxyranges.push_back(proxyrange); + + config.allowmissingorigin = tag->getBool("allowmissingorigin", true); + config.nativeping = tag->getBool("nativeping", true); + + // Everything is okay; apply the new config. + hookprov->config = config; + } + + void OnCleanup(ExtensionType type, Extensible* item) override + { + if (type != ExtensionType::USER) + return; + + LocalUser* user = IS_LOCAL(static_cast<User*>(item)); + if ((user) && (user->eh.GetModHook(this))) + ServerInstance->Users.QuitUser(user, "WebSocket module unloading"); + } +}; + +MODULE_INIT(ModuleWebSocket) |
