3
0
mirror of https://github.com/ergochat/ergo.git synced 2025-06-24 23:57:28 +02:00

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.
This commit is contained in:
Shivaram Lingamneni 2025-06-22 13:57:46 -04:00 committed by GitHub
parent a5e435a26b
commit 73e51333ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 199 additions and 59 deletions

View File

@ -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
//

View File

@ -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,

View File

@ -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

View File

@ -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()

View File

@ -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 <target> <subcommand> [<and so on>...]
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) {

View File

@ -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 {

View File

@ -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 {

View File

@ -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"

View File

@ -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) {

@ -1 +1 @@
Subproject commit b32cd08f4ec18b846ea4d9fbad2fef932137d4c9
Subproject commit eff56655290b255f099c43d81f28f2d13e667e61