aboutsummaryrefslogtreecommitdiffstats
path: root/src/modules/m_ircv3_utf8only.cpp
diff options
context:
space:
mode:
authorGravatar Sadie Powell2026-03-18 21:41:14 +0000
committerGravatar Sadie Powell2026-03-19 19:04:36 +0000
commitd7034da293004d365c5d84abbcbf46f8234b4605 (patch)
tree9141a37446cf22679a6f90498016daa0c4804c1c /src/modules/m_ircv3_utf8only.cpp
parentAllow overriding the oper restrictions on the fingerprint extban. (diff)
Add support for the IRCv3 UTF8ONLY specification.
Diffstat (limited to 'src/modules/m_ircv3_utf8only.cpp')
-rw-r--r--src/modules/m_ircv3_utf8only.cpp306
1 files changed, 306 insertions, 0 deletions
diff --git a/src/modules/m_ircv3_utf8only.cpp b/src/modules/m_ircv3_utf8only.cpp
new file mode 100644
index 000000000..31c169dd1
--- /dev/null
+++ b/src/modules/m_ircv3_utf8only.cpp
@@ -0,0 +1,306 @@
+/*
+ * InspIRCd -- Internet Relay Chat Daemon
+ *
+ * Copyright (C) 2026 Sadie Powell <sadie@witchery.services>
+ *
+ * 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/>.
+ */
+
+/// $CompilerFlags: require_environment("SYSTEM_UTFCPP" "1") -DUSE_SYSTEM_UTFCPP
+
+
+#ifdef USE_SYSTEM_UTFCPP
+# include <utf8cpp/utf8/unchecked.h>
+#else
+# include <utfcpp/unchecked.h>
+#endif
+
+#include "inspircd.h"
+#include "modules/isupport.h"
+#include "modules/ircv3_replies.h"
+
+enum
+{
+ // From ircu.
+ ERR_INPUTTOOLONG = 417
+};
+
+class UTF8Serializer final
+ : public ClientProtocol::Serializer
+{
+private:
+ // The maximum size of client-originated message tags in an incoming message including the `@`.
+ static constexpr std::string::size_type MAX_CLIENT_MESSAGE_TAG_LENGTH = 4095;
+
+ // The maximum size of server-originated message tags in an outgoing message including the `@`.
+ static constexpr std::string::size_type MAX_SERVER_MESSAGE_TAG_LENGTH = 4095;
+
+ static void SerializeTags(const ClientProtocol::TagMap& tags, const ClientProtocol::TagSelection& tagwl, std::string& line);
+
+ IRCv3::Replies::Fail failrpl;
+
+ inline static void AppendUTF8(const std::string& in, std::string& out)
+ {
+ utf8::unchecked::replace_invalid(in.begin(), in.end(), std::back_inserter(out));
+ }
+
+ inline static size_t TruncateUTF8(const std::string& str, size_t len)
+ {
+ if (str.length() < len)
+ return str.length();
+
+ // Skip back to the start of the previous codepoint.
+ while (len && (static_cast<unsigned char>(str[len]) & 0xC0) == 0x80)
+ len--;
+ return len;
+ }
+
+public:
+ UTF8Serializer(Module* mod)
+ : ClientProtocol::Serializer(mod, "utf8")
+ , failrpl(mod)
+ {
+ }
+
+ bool Parse(LocalUser* user, const std::string& line, ClientProtocol::ParseOutput& parseoutput) override;
+ ClientProtocol::SerializedMessage Serialize(const ClientProtocol::Message& msg, const ClientProtocol::TagSelection& tagwl) const override;
+};
+
+bool UTF8Serializer::Parse(LocalUser* user, const std::string& line, ClientProtocol::ParseOutput& parseoutput)
+{
+ // Work out how long the message can actually be.
+ auto maxline = ServerInstance->Config->Limits.MaxLine - 2;
+ if (line[0] == '@')
+ maxline += MAX_CLIENT_MESSAGE_TAG_LENGTH + 1;
+
+ irc::tokenstream tokens(line, 0, TruncateUTF8(line, maxline));
+ if (!utf8::is_valid(line))
+ {
+ failrpl.Send(user, nullptr, "INVALID_UTF8", "Message rejected, your IRC software MUST use UTF-8 encoding on this network");
+ user->CommandFloodPenalty += 2000;
+ return false;
+ }
+
+ // Try to read either the tags or the command name. Unlike with the RFC
+ // serializer we do not allow for broken legacy clients to send preceding
+ // whitespace.
+ std::string token;
+ tokens.GetMiddle(token);
+ if (token.empty())
+ {
+ // Discourage the user from flooding the server.
+ user->CommandFloodPenalty += 2000;
+ return false;
+ }
+
+ ServerInstance->Logs.RawIO("USERINPUT", "C[{}] I {}", user->uuid, tokens.GetMessage());
+ if (token[0] == '@')
+ {
+ // Check that the client tags fit within the client tag space.
+ if (token.length() > MAX_CLIENT_MESSAGE_TAG_LENGTH)
+ {
+ user->WriteNumeric(ERR_INPUTTOOLONG, "Input line was too long");
+ user->CommandFloodPenalty += 2000;
+ return false;
+ }
+
+ // Truncate the RFC part of the message if it is too long.
+ size_t maxrfcline = token.length() + ServerInstance->Config->Limits.MaxLine - 1;
+ if (tokens.GetMessage().length() > maxrfcline)
+ tokens.GetMessage().erase(TruncateUTF8(tokens.GetMessage(), maxrfcline));
+
+ // Line begins with message tags, parse them.
+ std::string tagval;
+ irc::sepstream ss(token.substr(1), ';');
+ while (ss.GetToken(token))
+ {
+ // Two or more tags with the same key must not be sent, but if a client violates that we accept
+ // the first occurrence of duplicate tags and ignore all later occurrences.
+ //
+ // Another option is to reject the message entirely but there is no standard way of doing that.
+ const std::string::size_type p = token.find('=');
+ if (p != std::string::npos)
+ {
+ // Tag has a value
+ tagval.assign(token, p+1, std::string::npos);
+ token.erase(p);
+ }
+ else
+ tagval.clear();
+
+ HandleTag(user, token, tagval, parseoutput.tags);
+ }
+
+ // Try to read the prefix or command name.
+ if (!tokens.GetMiddle(token))
+ {
+ // Discourage the user from flooding the server.
+ user->CommandFloodPenalty += 2000;
+ return false;
+ }
+ }
+
+ if (token[0] == ':')
+ {
+ // If this exists then the client sent a prefix as part of their
+ // message. Section 2.3 of RFC 1459 technically says we should only
+ // allow the nick of the client here but in practise everyone just
+ // ignores it so we will copy them.
+
+ // Try to read the command name.
+ if (!tokens.GetMiddle(token))
+ {
+ // Discourage the user from flooding the server.
+ user->CommandFloodPenalty += 2000;
+ return false;
+ }
+ }
+
+ parseoutput.cmd.assign(token);
+
+ // Build the parameter map. We intentionally do not respect the RFC 1459
+ // thirteen parameter limit here.
+ while (tokens.GetTrailing(token))
+ parseoutput.params.push_back(token);
+
+ return true;
+}
+
+namespace
+{
+ void CheckTagLength(std::string& line, size_t prevsize, size_t& length, size_t maxlength)
+ {
+ const std::string::size_type diffsize = line.size() - prevsize;
+ if (length + diffsize > maxlength)
+ line.erase(prevsize);
+ else
+ length += diffsize;
+ }
+}
+
+void UTF8Serializer::SerializeTags(const ClientProtocol::TagMap& tags, const ClientProtocol::TagSelection& tagwl, std::string& line)
+{
+ size_t client_tag_length = 0;
+ size_t server_tag_length = 0;
+ for (ClientProtocol::TagMap::const_iterator i = tags.begin(); i != tags.end(); ++i)
+ {
+ if (!tagwl.IsSelected(tags, i))
+ continue;
+
+ const auto prevsize = line.size();
+ line.push_back(prevsize ? ';' : '@');
+ AppendUTF8(i->first, line);
+ const auto& val = i->second.value;
+ if (!val.empty())
+ {
+ line.push_back('=');
+ AppendUTF8(val, line);
+ }
+
+ // The tags part of the message must not contain more client and server tags than allowed by the
+ // message tags specification. This is complicated by the tag space having separate limits for
+ // both server-originated and client-originated tags. If either of the tag limits is exceeded then
+ // the most recently added tag is removed.
+ if (i->first[0] == '+')
+ CheckTagLength(line, prevsize, client_tag_length, MAX_CLIENT_MESSAGE_TAG_LENGTH);
+ else
+ CheckTagLength(line, prevsize, server_tag_length, MAX_SERVER_MESSAGE_TAG_LENGTH);
+ }
+
+ if (!line.empty())
+ line.push_back(' ');
+}
+
+ClientProtocol::SerializedMessage UTF8Serializer::Serialize(const ClientProtocol::Message& msg, const ClientProtocol::TagSelection& tagwl) const
+{
+ std::string line;
+ SerializeTags(msg.GetTags(), tagwl, line);
+
+ // Save position for length calculation later
+ const auto rfcmsg_begin = line.size();
+
+ if (msg.GetSource())
+ {
+ line.push_back(':');
+ AppendUTF8(*msg.GetSource(), line);
+ line.push_back(' ');
+ }
+
+ AppendUTF8(msg.GetCommand(), line);
+
+ const auto& params = msg.GetParams();
+ if (!params.empty())
+ {
+ for (auto param = params.begin(); param != params.end() - 1; ++param)
+ {
+ line.push_back(' ');
+ AppendUTF8(*param, line);
+ }
+
+ line.append(" :", 2);
+ AppendUTF8(params.back(), line);
+ }
+
+ // Truncate if too long
+ auto maxline = ServerInstance->Config->Limits.MaxLine - 2;
+ if (line.length() - rfcmsg_begin > maxline)
+ line.erase(TruncateUTF8(line, rfcmsg_begin + maxline));
+
+ line.append("\r\n", 2);
+ return line;
+}
+
+class IRCv3UTF8Only final
+ : public Module
+ , public ISupport::EventListener
+{
+private:
+ UTF8Serializer utf8serializer;
+
+public:
+ IRCv3UTF8Only()
+ : Module(VF_CORE | VF_VENDOR, "RFC client protocol serializer and unserializer")
+ , ISupport::EventListener(this)
+ , utf8serializer(this)
+ {
+ }
+
+ void Prioritize() override
+ {
+ ServerInstance->Modules.SetPriority(this, I_OnUserInit, PRIORITY_BEFORE, "core_serialize_rfc");
+ }
+
+ void OnBuildISupport(ISupport::TokenMap& tokens) override
+ {
+ tokens["CHARSET"] = "utf8";
+ tokens["UTF8ONLY"];
+ }
+
+ void OnCleanup(ExtensionType type, Extensible* item) override
+ {
+ if (type != ExtensionType::USER)
+ return;
+
+ auto* const user = IS_LOCAL(static_cast<User*>(item));
+ if (user && user->serializer == &utf8serializer)
+ ServerInstance->Users.QuitUser(user, "Protocol serializer module unloading");
+ }
+
+ void OnUserInit(LocalUser* user) override
+ {
+ if (!user->serializer)
+ user->serializer = &utf8serializer;
+ }
+};
+
+MODULE_INIT(IRCv3UTF8Only)