From 73e51333adb41062b0bf853599e464430fb1bff5 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Sun, 22 Jun 2025 13:57:46 -0400 Subject: [PATCH] implement metadata before-connect (#2281) * metadata spec update: disallow colon entirely * refactor key validation * implement metadata before-connect * play the metadata in reg burst to all clients with the cap * bump irctest * remove all case normalization for keys From spec discussion, we will most likely either require keys to be lowercase, or else treat them as case-opaque, similar to message tag keys. --- irc/client.go | 35 +++++++++- irc/commands.go | 5 +- irc/config.go | 4 +- irc/getters.go | 28 +++++++- irc/handlers.go | 155 +++++++++++++++++++++++++++++++------------ irc/metadata.go | 21 ++++-- irc/metadata_test.go | 3 +- irc/numerics.go | 1 + irc/server.go | 4 +- irctest | 2 +- 10 files changed, 199 insertions(+), 59 deletions(-) diff --git a/irc/client.go b/irc/client.go index 25aa72cf..16837dec 100644 --- a/irc/client.go +++ b/irc/client.go @@ -217,6 +217,7 @@ type Session struct { webPushEndpoint string // goroutine-local: web push endpoint registered by the current session metadataSubscriptions utils.HashSet[string] + metadataPreregVals map[string]string } // MultilineBatch tracks the state of a client-to-server multiline batch. @@ -677,7 +678,7 @@ func (client *Client) run(session *Session) { isReattach := client.Registered() if isReattach { client.Touch(session) - client.playReattachMessages(session) + client.performReattach(session) } firstLine := !isReattach @@ -777,7 +778,9 @@ func (client *Client) run(session *Session) { } } -func (client *Client) playReattachMessages(session *Session) { +func (client *Client) performReattach(session *Session) { + client.applyPreregMetadata(session) + client.server.playRegistrationBurst(session) hasHistoryCaps := session.HasHistoryCaps() for _, channel := range session.client.Channels() { @@ -801,6 +804,34 @@ func (client *Client) playReattachMessages(session *Session) { session.autoreplayMissedSince = time.Time{} } +func (client *Client) applyPreregMetadata(session *Session) { + if session.metadataPreregVals == nil { + return + } + + defer func() { + session.metadataPreregVals = nil + }() + + updates := client.UpdateMetadataFromPrereg(session.metadataPreregVals, client.server.Config().Metadata.MaxKeys) + if len(updates) == 0 { + return + } + + // TODO this is expensive + friends := client.FriendsMonitors(caps.Metadata) + for _, s := range client.Sessions() { + if s != session { + friends.Add(s) + } + } + + target := client.Nick() + for k, v := range updates { + broadcastMetadataUpdate(client.server, maps.Keys(friends), session, target, k, v) + } +} + // // idle, quit, timers and timeouts // diff --git a/irc/commands.go b/irc/commands.go index d5387306..b8be684d 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -210,8 +210,9 @@ func init() { minParams: 0, // send FAIL instead of ERR_NEEDMOREPARAMS }, "METADATA": { - handler: metadataHandler, - minParams: 2, + handler: metadataHandler, + minParams: 2, + usablePreReg: true, }, "MODE": { handler: modeHandler, diff --git a/irc/config.go b/irc/config.go index 2ef14681..6939cf5c 100644 --- a/irc/config.go +++ b/irc/config.go @@ -724,7 +724,6 @@ type Config struct { } Metadata struct { - // BeforeConnect int `yaml:"before-connect"` todo: this Enabled bool MaxSubs int `yaml:"max-subs"` MaxKeys int `yaml:"max-keys"` @@ -1648,7 +1647,8 @@ func LoadConfig(filename string) (config *Config, err error) { if !config.Metadata.Enabled { config.Server.supportedCaps.Disable(caps.Metadata) } else { - var metadataValues []string + metadataValues := make([]string, 0, 4) + metadataValues = append(metadataValues, "before-connect") // these are required for normal operation, so set sane defaults: if config.Metadata.MaxSubs == 0 { config.Metadata.MaxSubs = 10 diff --git a/irc/getters.go b/irc/getters.go index 3bba6115..75896811 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -839,6 +839,8 @@ func (session *Session) isSubscribedTo(key string) bool { } func (session *Session) SubscribeTo(keys ...string) ([]string, error) { + maxSubs := session.client.server.Config().Metadata.MaxSubs + session.client.stateMutex.Lock() defer session.client.stateMutex.Unlock() @@ -848,8 +850,6 @@ func (session *Session) SubscribeTo(keys ...string) ([]string, error) { var added []string - maxSubs := session.client.server.Config().Metadata.MaxSubs - for _, k := range keys { if !session.metadataSubscriptions.Has(k) { if len(session.metadataSubscriptions) > maxSubs { @@ -980,6 +980,30 @@ func (client *Client) SetMetadata(key string, value string, limit int) (updated return updated, nil } +func (client *Client) UpdateMetadataFromPrereg(preregData map[string]string, limit int) (updates map[string]string) { + updates = make(map[string]string, len(preregData)) + + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + + if client.metadata == nil { + client.metadata = make(map[string]string) + } + + for k, v := range preregData { + // do not overwrite any existing keys + _, ok := client.metadata[k] + if ok { + continue + } + if len(client.metadata) >= limit { + return // we know this is a new key + } + client.metadata[k] = v + } + return +} + func (client *Client) ListMetadata() map[string]string { client.stateMutex.RLock() defer client.stateMutex.RUnlock() diff --git a/irc/handlers.go b/irc/handlers.go index e2cdf94d..e7c6f614 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -20,7 +20,6 @@ import ( "strconv" "strings" "time" - "unicode/utf8" "github.com/ergochat/irc-go/ircfmt" "github.com/ergochat/irc-go/ircmsg" @@ -3102,15 +3101,39 @@ func markReadHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res // METADATA [...] func metadataHandler(server *Server, client *Client, msg ircmsg.Message, rb *ResponseBuffer) (exiting bool) { - target := msg.Params[0] - config := server.Config() if !config.Metadata.Enabled { - rb.Add(nil, server.name, "FAIL", "METADATA", "FORBIDDEN", target, "Metadata is disabled on this server") + rb.Add(nil, server.name, "FAIL", "METADATA", "FORBIDDEN", utils.SafeErrorParam(msg.Params[0]), "Metadata is disabled on this server") return } subcommand := strings.ToLower(msg.Params[1]) + needsKey := subcommand == "set" || subcommand == "get" || subcommand == "sub" || subcommand == "unsub" + if needsKey && len(msg.Params) < 3 { + rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), msg.Command, client.t("Not enough parameters")) + return + } + + switch subcommand { + case "sub", "unsub", "subs": + // these are session-local and function the same whether or not the client is registered + return metadataSubsHandler(client, subcommand, msg.Params, rb) + case "get", "set", "list", "clear", "sync": + if client.registered { + return metadataRegisteredHandler(client, config, subcommand, msg.Params, rb) + } else { + return metadataUnregisteredHandler(client, config, subcommand, msg.Params, rb) + } + default: + rb.Add(nil, server.name, "FAIL", "METADATA", "SUBCOMMAND_INVALID", utils.SafeErrorParam(msg.Params[1]), client.t("Invalid subcommand")) + return + } +} + +// metadataRegisteredHandler handles metadata-modifying commands from registered clients +func metadataRegisteredHandler(client *Client, config *Config, subcommand string, params []string, rb *ResponseBuffer) (exiting bool) { + server := client.server + target := params[0] noKeyPerms := func(key string) { rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_NO_PERMISSION", target, key, client.t("You do not have permission to perform this action")) @@ -3141,15 +3164,9 @@ func metadataHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res return } - needsKey := subcommand == "set" || subcommand == "get" || subcommand == "sub" || subcommand == "unsub" - if needsKey && len(msg.Params) <= 2 { - rb.Add(nil, server.name, ERR_NEEDMOREPARAMS, client.Nick(), msg.Command, client.t("Not enough parameters")) - return - } - switch subcommand { case "set": - key := strings.ToLower(msg.Params[2]) + key := params[2] if metadataKeyIsEvil(key) { rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_INVALID", utils.SafeErrorParam(key), client.t("Invalid key name")) return @@ -3160,18 +3177,12 @@ func metadataHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res return } - if len(msg.Params) > 3 { - value := msg.Params[3] + if len(params) > 3 { + value := params[3] - if !globalUtf8EnforcementSetting && !utf8.ValidString(value) { - rb.Add(nil, server.name, "FAIL", "METADATA", "VALUE_INVALID", client.t("METADATA values must be UTF-8")) - return - } - - if len(key)+len(value) > maxCombinedMetadataLenBytes || - (config.Metadata.MaxValueBytes > 0 && len(value) > config.Metadata.MaxValueBytes) { - - rb.Add(nil, server.name, "FAIL", "METADATA", "VALUE_INVALID", client.t("Value is too long")) + config := client.server.Config() + if failMsg := metadataValueIsEvil(config, key, value); failMsg != "" { + rb.Add(nil, server.name, "FAIL", "METADATA", "VALUE_INVALID", client.t(failMsg)) return } @@ -3203,7 +3214,7 @@ func metadataHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res batchId := rb.StartNestedBatch("metadata") defer rb.EndNestedBatch(batchId) - for _, key := range msg.Params[2:] { + for _, key := range params[2:] { if metadataKeyIsEvil(key) { rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_INVALID", utils.SafeErrorParam(key), client.t("Invalid key name")) continue @@ -3230,16 +3241,84 @@ func metadataHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res values := targetObj.ClearMetadata() - batchId := rb.StartNestedBatch("metadata") - defer rb.EndNestedBatch(batchId) + playMetadataList(rb, client.Nick(), target, values) - for key, val := range values { - visibility := "*" - rb.Add(nil, server.name, RPL_KEYVALUE, client.Nick(), target, key, visibility, val) + case "sync": + if targetChannel != nil { + syncChannelMetadata(server, rb, targetChannel) } + if targetClient != nil { + syncClientMetadata(server, rb, targetClient) + } + } + return +} + +// metadataUnregisteredHandler handles metadata-modifying commands for pre-connection-registration +// clients. these operations act on a session-local buffer; if/when the client completes registration, +// they are applied to the final Client object (possibly a different client if there was a reattach) +// on a best-effort basis. +func metadataUnregisteredHandler(client *Client, config *Config, subcommand string, params []string, rb *ResponseBuffer) (exiting bool) { + server := client.server + if params[0] != "*" { + rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_NO_PERMISSION", utils.SafeErrorParam(params[0]), "*", client.t("You can only modify your own metadata before completing connection registration")) + return + } + + switch subcommand { + case "set": + if rb.session.metadataPreregVals == nil { + rb.session.metadataPreregVals = make(map[string]string) + } + key := params[2] + if metadataKeyIsEvil(key) { + rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_INVALID", utils.SafeErrorParam(key), client.t("Invalid key name")) + return + } + if len(params) >= 4 { + value := params[3] + // enforce a sane limit on prereg keys. we don't need to enforce the exact limit, + // that will be done when applying the buffer after registration + if len(rb.session.metadataPreregVals) > config.Metadata.MaxKeys { + rb.Add(nil, server.name, "FAIL", "METADATA", "LIMIT_REACHED", client.t("Too many metadata keys")) + return + } + if failMsg := metadataValueIsEvil(config, key, value); failMsg != "" { + rb.Add(nil, server.name, "FAIL", "METADATA", "VALUE_INVALID", client.t(failMsg)) + return + } + rb.session.metadataPreregVals[key] = value + rb.Add(nil, server.name, RPL_KEYVALUE, "*", "*", key, "*", value) + } else { + // unset + _, present := rb.session.metadataPreregVals[key] + if present { + delete(rb.session.metadataPreregVals, key) + rb.Add(nil, server.name, RPL_KEYNOTSET, "*", "*", key, client.t("Key deleted")) + } else { + rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_NOT_SET", utils.SafeErrorParam(key), client.t("Metadata key not set")) + } + } + case "list": + playMetadataList(rb, "*", "*", rb.session.metadataPreregVals) + case "clear": + oldMetadata := rb.session.metadataPreregVals + rb.session.metadataPreregVals = nil + playMetadataList(rb, "*", "*", oldMetadata) + case "sync": + rb.Add(nil, server.name, RPL_METADATASYNCLATER, "*", utils.SafeErrorParam(params[1]), "60") // lol + } + return false +} + +// metadataSubsHandler handles subscription-related commands; +// these are handled the same whether the client is registered or not +func metadataSubsHandler(client *Client, subcommand string, params []string, rb *ResponseBuffer) (exiting bool) { + server := client.server + switch subcommand { case "sub": - keys := msg.Params[2:] + keys := params[2:] for _, key := range keys { if metadataKeyIsEvil(key) { rb.Add(nil, server.name, "FAIL", "METADATA", "KEY_INVALID", utils.SafeErrorParam(key), client.t("Invalid key name")) @@ -3261,7 +3340,7 @@ func metadataHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res } case "unsub": - keys := msg.Params[2:] + keys := params[2:] removed := rb.session.UnsubscribeFrom(keys...) lineLength := MaxLineLen - len(server.name) - len(RPL_METADATAUNSUBOK) - len(client.Nick()) - 10 @@ -3272,7 +3351,7 @@ func metadataHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res } case "subs": - lineLength := MaxLineLen - len(server.name) - len(RPL_METADATASUBS) - len(client.Nick()) - 10 // for safety + lineLength := MaxLineLen - len(server.name) - len(RPL_METADATASUBS) - len(client.Nick()) - 10 subs := rb.session.MetadataSubscriptions() @@ -3281,20 +3360,8 @@ func metadataHandler(server *Server, client *Client, msg ircmsg.Message, rb *Res params := append([]string{client.Nick()}, line...) rb.Add(nil, server.name, RPL_METADATASUBS, params...) } - - case "sync": - if targetChannel != nil { - syncChannelMetadata(server, rb, targetChannel) - } - if targetClient != nil { - syncClientMetadata(server, rb, targetClient) - } - - default: - rb.Add(nil, server.name, "FAIL", "METADATA", "SUBCOMMAND_INVALID", utils.SafeErrorParam(msg.Params[1]), client.t("Invalid subcommand")) } - - return + return false } func playMetadataList(rb *ResponseBuffer, nick, target string, values map[string]string) { diff --git a/irc/metadata.go b/irc/metadata.go index b19962c0..a254f280 100644 --- a/irc/metadata.go +++ b/irc/metadata.go @@ -5,6 +5,7 @@ import ( "iter" "maps" "regexp" + "unicode/utf8" "github.com/ergochat/ergo/irc/caps" "github.com/ergochat/ergo/irc/modes" @@ -105,12 +106,24 @@ func syncChannelMetadata(server *Server, rb *ResponseBuffer, channel *Channel) { } } -var metadataEvilCharsRegexp = regexp.MustCompile("[^A-Za-z0-9_./:-]+") +var validMetadataKeyRegexp = regexp.MustCompile("^[A-Za-z0-9_./-]+$") func metadataKeyIsEvil(key string) bool { - return len(key) == 0 || // key needs to contain stuff - key[0] == ':' || // key can't start with a colon - metadataEvilCharsRegexp.MatchString(key) // key can't contain the stuff it can't contain + return !validMetadataKeyRegexp.MatchString(key) +} + +func metadataValueIsEvil(config *Config, key, value string) (failMsg string) { + if !globalUtf8EnforcementSetting && !utf8.ValidString(value) { + return `METADATA values must be UTF-8` + } + + if len(key)+len(value) > maxCombinedMetadataLenBytes || + (config.Metadata.MaxValueBytes > 0 && len(value) > config.Metadata.MaxValueBytes) { + + return `Value is too long` + } + + return "" // success } func metadataCanIEditThisKey(client *Client, targetObj MetadataHaver, key string) bool { diff --git a/irc/metadata_test.go b/irc/metadata_test.go index 93942abb..91de213a 100644 --- a/irc/metadata_test.go +++ b/irc/metadata_test.go @@ -8,9 +8,10 @@ func TestKeyCheck(t *testing.T) { isEvil bool }{ {"ImNormal", false}, + {"", true}, {":imevil", true}, {"key£with$not%allowed^chars", true}, - {"key.that:s_completely/normal-and.fine", false}, + {"key.thats_completely/normal-and.fine", false}, } for _, c := range cases { diff --git a/irc/numerics.go b/irc/numerics.go index bf5a6d60..339080e1 100644 --- a/irc/numerics.go +++ b/irc/numerics.go @@ -188,6 +188,7 @@ const ( RPL_METADATASUBOK = "770" RPL_METADATAUNSUBOK = "771" RPL_METADATASUBS = "772" + RPL_METADATASYNCLATER = "774" RPL_LOGGEDIN = "900" RPL_LOGGEDOUT = "901" ERR_NICKLOCKED = "902" diff --git a/irc/server.go b/irc/server.go index 4b23fe8a..7003a367 100644 --- a/irc/server.go +++ b/irc/server.go @@ -428,6 +428,8 @@ func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) { c.SetMode(defaultMode, true) } + c.applyPreregMetadata(session) + // this is not a reattach, so if the client is always-on, this is the first time // the Client object was created during the current server uptime. mark dirty in // order to persist the realname and the user modes: @@ -496,7 +498,7 @@ func (server *Server) playRegistrationBurst(session *Session) { if !(rb.session.capabilities.Has(caps.ExtendedISupport) && rb.session.isupportSentPrereg) { server.RplISupport(c, rb) } - if session.capabilities.Has(caps.Metadata) && c.AlwaysOn() { + if session.capabilities.Has(caps.Metadata) { playMetadataList(rb, d.nick, d.nick, c.ListMetadata()) } if d.account != "" && session.capabilities.Has(caps.Persistence) { diff --git a/irctest b/irctest index b32cd08f..eff56655 160000 --- a/irctest +++ b/irctest @@ -1 +1 @@ -Subproject commit b32cd08f4ec18b846ea4d9fbad2fef932137d4c9 +Subproject commit eff56655290b255f099c43d81f28f2d13e667e61