From a097130fadeb16193d7d8845f824deaa345cb21c Mon Sep 17 00:00:00 2001 From: waveplate Date: Wed, 19 Aug 2026 10:49:41 -0700 Subject: [PATCH 1/2] irc: add configurable allowed-characters for IRC formatting and printable glyphs --- default.yaml | 8 ++++ irc/client_lookup_set.go | 10 ++++- irc/config.go | 5 ++- irc/i18n/strings.go | 2 +- irc/server.go | 3 ++ irc/strings.go | 41 +++++++++++++++++- irc/strings_test.go | 90 ++++++++++++++++++++++++++++++++++++++++ traditional.yaml | 8 ++++ 8 files changed, 161 insertions(+), 6 deletions(-) diff --git a/default.yaml b/default.yaml index ea1e50297..26d416111 100644 --- a/default.yaml +++ b/default.yaml @@ -143,6 +143,14 @@ server: # problematic. casemapping: "ascii" + # allowed-characters provides fine-grained control over allowable characters in identifiers + # (nicknames, account names, and channel names): + # allowed-characters: + # allow IRC formatting/control characters (bold, color, italics, underline, etc.) + # irc-formatting: false + # allow printable Unicode glyphs (emoji, symbols, combining marks, legacy computing, block elements) + # printable-glyphs: false + # enforce-utf8 controls whether the server will preemptively discard non-UTF8 # messages (since they cannot be relayed to websocket clients), or will allow # them and relay them to non-websocket clients (as in traditional IRC). diff --git a/irc/client_lookup_set.go b/irc/client_lookup_set.go index 6e028206c..7007db6b2 100644 --- a/irc/client_lookup_set.go +++ b/irc/client_lookup_set.go @@ -8,6 +8,8 @@ import ( "strings" "sync" + "github.com/ergochat/irc-go/ircfmt" + "github.com/ergochat/ergo/irc/caps" "github.com/ergochat/ergo/irc/modes" "github.com/ergochat/ergo/irc/utils" @@ -99,11 +101,15 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick // these restrictions have grandfather exceptions for nicknames registered // on previous versions of Ergo: if newNick != accountName { + plainNick := newNick + if globalAllowedCharacters.IRCFormatting { + plainNick = ircfmt.Strip(newNick) + } // can't contain "disfavored" characters like <, or start with a $ because // it collides with the massmessage mask syntax. '0' conflicts with the use of 0 // as a placeholder in WHOX (#1896): - if strings.ContainsAny(newNick, disfavoredNameCharacters) || strings.HasPrefix(newNick, "$") || - newNick == "0" { + if strings.ContainsAny(newNick, disfavoredNameCharacters) || strings.HasPrefix(plainNick, "$") || + plainNick == "0" { return "", errNicknameInvalid, false } } diff --git a/irc/config.go b/irc/config.go index 7a1494911..56d7280ed 100644 --- a/irc/config.go +++ b/irc/config.go @@ -626,6 +626,7 @@ type Config struct { supportedCapsWithoutSTS *caps.Set capValues caps.Values Casemapping i18n.Casemapping + AllowedCharacters AllowedCharactersConfig `yaml:"allowed-characters"` EnforceUtf8 bool `yaml:"enforce-utf8"` OutputPath string `yaml:"output-path"` IPCheckScript IPCheckScriptConfig `yaml:"ip-check-script"` @@ -1426,8 +1427,8 @@ func LoadConfig(filename string) (config *Config, err error) { } if !i18n.Enabled { - if config.Server.Casemapping != i18n.CasemappingASCII { - return nil, fmt.Errorf("i18n support was compiled out; set casemapping to 'ascii' or recompile") + if config.Server.Casemapping != i18n.CasemappingASCII || config.Server.AllowedCharacters.PrintableGlyphs { + return nil, fmt.Errorf("i18n support was compiled out; set casemapping to 'ascii' and printable-glyphs to false, or recompile") } } diff --git a/irc/i18n/strings.go b/irc/i18n/strings.go index 400dc805d..09c2c74b9 100644 --- a/irc/i18n/strings.go +++ b/irc/i18n/strings.go @@ -28,7 +28,7 @@ const ( var ( // reviving the old ergonomadic nickname regex: // in permissive mode, allow arbitrary letters, numbers, punctuation, and symbols - permissiveCharsRegex = regexp.MustCompile(`^[\pL\pN\pP\pS]*$`) + permissiveCharsRegex = regexp.MustCompile(`^[\pL\pN\pP\pS\pM]*$`) ) // String Errors diff --git a/irc/server.go b/irc/server.go index 823a98a8a..a2f37573e 100644 --- a/irc/server.go +++ b/irc/server.go @@ -719,6 +719,7 @@ func (server *Server) applyConfig(config *Config) (err error) { server.nameCasefolded = config.Server.nameCasefolded globalCasemappingSetting = config.Server.Casemapping globalUtf8EnforcementSetting = config.Server.EnforceUtf8 + globalAllowedCharacters = config.Server.AllowedCharacters MaxLineLen = config.Server.MaxLineLen RegisterTimeout = config.Server.IdleTimeouts.Registration PingTimeout = config.Server.IdleTimeouts.Ping @@ -733,6 +734,8 @@ func (server *Server) applyConfig(config *Config) (err error) { return fmt.Errorf("Casemapping cannot be changed after launching the server, rehash aborted") } else if globalUtf8EnforcementSetting != config.Server.EnforceUtf8 { return fmt.Errorf("UTF-8 enforcement cannot be changed after launching the server, rehash aborted") + } else if oldConfig.Server.AllowedCharacters != config.Server.AllowedCharacters { + return fmt.Errorf("Cannot change allowed-characters after launching the server, rehash aborted") } else if oldConfig.Accounts.Multiclient.AlwaysOn != config.Accounts.Multiclient.AlwaysOn { return fmt.Errorf("Default always-on setting cannot be changed after launching the server, rehash aborted") } else if oldConfig.Server.Relaymsg.Enabled != config.Server.Relaymsg.Enabled { diff --git a/irc/strings.go b/irc/strings.go index e227cb8f0..dc8451b72 100644 --- a/irc/strings.go +++ b/irc/strings.go @@ -9,6 +9,8 @@ import ( "fmt" "strings" + "github.com/ergochat/irc-go/ircfmt" + "github.com/ergochat/ergo/irc/i18n" "github.com/ergochat/ergo/irc/utils" ) @@ -32,6 +34,29 @@ const ( disfavoredNameCharacters = `<>'";#` ) +type AllowedCharactersConfig struct { + IRCFormatting bool `yaml:"irc-formatting"` + PrintableGlyphs bool `yaml:"printable-glyphs"` +} + +var globalAllowedCharacters AllowedCharactersConfig + +func containsDisallowedControlChars(str string, allowIRCFormatting bool) bool { + for i := 0; i < len(str); i++ { + b := str[i] + if b < 32 || b == 127 { + if allowIRCFormatting { + switch b { + case 0x02, 0x03, 0x04, 0x0F, 0x11, 0x16, 0x1D, 0x1E, 0x1F: + continue + } + } + return true + } + } + return false +} + // XXX this is a global variable without explicit synchronization. // it gets set during the initial Server.applyConfig and cannot be changed by rehash: // this happens-before all IRC connections and all casefolding operations. @@ -45,7 +70,14 @@ var globalUtf8EnforcementSetting bool // Casefold returns a casefolded string, without doing any name or channel character checks. func Casefold(str string) (string, error) { - return i18n.CasefoldWithSetting(str, globalCasemappingSetting) + if globalAllowedCharacters.IRCFormatting { + str = ircfmt.Strip(str) + } + res, err := i18n.CasefoldWithSetting(str, globalCasemappingSetting) + if err != nil && globalAllowedCharacters.PrintableGlyphs { + res, err = i18n.CasefoldWithSetting(str, i18n.CasemappingPermissive) + } + return res, err } // CasefoldChannel returns a casefolded version of a channel name. @@ -82,6 +114,10 @@ func CasefoldChannel(name string) (string, error) { // CasefoldName returns a casefolded version of a nick/user name. func CasefoldName(name string) (string, error) { + if containsDisallowedControlChars(name, globalAllowedCharacters.IRCFormatting) { + return "", errInvalidCharacter + } + lowered, err := Casefold(name) if err != nil { @@ -140,6 +176,9 @@ func isIdent(name string) bool { // Skeleton produces a canonicalized identifier that tries to catch // homoglyphic / confusable identifiers. func Skeleton(name string) (string, error) { + if globalAllowedCharacters.IRCFormatting { + name = ircfmt.Strip(name) + } switch globalCasemappingSetting { default: return i18n.Skeleton(name) diff --git a/irc/strings_test.go b/irc/strings_test.go index 537e55f56..5b068b4a4 100644 --- a/irc/strings_test.go +++ b/irc/strings_test.go @@ -220,3 +220,93 @@ func TestCanonicalizeMaskWildcard(t *testing.T) { tester("РОТАТО!Potato", "ротато!potato@*", nil) } } + +func TestAllowedCharactersIRCFormatting(t *testing.T) { + oldAllowed := globalAllowedCharacters + t.Cleanup(func() { + globalAllowedCharacters = oldAllowed + }) + + globalAllowedCharacters = AllowedCharactersConfig{ + IRCFormatting: true, + } + + testCases := []struct { + input string + folded string + err bool + }{ + {"\x02bold\x02", "bold", false}, + {"\x0304,01red\x0f", "red", false}, + {"\x1ditalic\x1d", "italic", false}, + {"\x1funderline\x1f", "underline", false}, + {"\x16reverse\x16", "reverse", false}, + {"\x11monospace\x11", "monospace", false}, + {"\x1estrike\x1e", "strike", false}, + {"\x02\x0304Mixed\x0f\x02", "mixed", false}, + // only formatting codes, no text -> empty string error + {"\x02\x02", "", true}, + {"\x0304\x0f", "", true}, + // disallowed control characters + {"\x07bell", "", true}, + {"\x1bescape", "", true}, + {"tab\tcharacter", "", true}, + {"null\x00byte", "", true}, + } + + for _, tt := range testCases { + res, err := CasefoldName(tt.input) + if tt.err && err == nil { + t.Errorf("expected error for [%q], got nil", tt.input) + } else if !tt.err && err != nil { + t.Errorf("unexpected error for [%q]: %v", tt.input, err) + } else if !tt.err && res != tt.folded { + t.Errorf("expected [%q] to fold to [%q], got [%q]", tt.input, tt.folded, res) + } + } +} + +func TestAllowedCharactersPrintableGlyphs(t *testing.T) { + if !i18n.Enabled { + t.Skip("i18n not enabled") + } + + oldAllowed := globalAllowedCharacters + t.Cleanup(func() { + globalAllowedCharacters = oldAllowed + }) + + globalAllowedCharacters = AllowedCharactersConfig{ + PrintableGlyphs: true, + } + + testCases := []struct { + input string + folded string + err bool + }{ + // Block elements + {"█Block█", "█block█", false}, + {"░Shade░", "░shade░", false}, + // Legacy computing (U+1FB00) + {"\U0001FB00Legacy\U0001FB00", "\U0001fb00legacy\U0001fb00", false}, + // Legacy computing supplement (U+1CC00) + {"\U0001CC00Supp\U0001CC00", "\U0001cc00supp\U0001cc00", false}, + // Emojis & Symbols + {"👾gamer👾", "👾gamer👾", false}, + {"🔥Fire🔥", "🔥fire🔥", false}, + // Combining marks + {"e\u0301clair", "éclair", false}, + } + + for _, tt := range testCases { + res, err := CasefoldName(tt.input) + if tt.err && err == nil { + t.Errorf("expected error for [%q], got nil", tt.input) + } else if !tt.err && err != nil { + t.Errorf("unexpected error for [%q]: %v", tt.input, err) + } else if !tt.err && res != tt.folded { + t.Errorf("expected [%q] to fold to [%q], got [%q]", tt.input, tt.folded, res) + } + } +} diff --git a/traditional.yaml b/traditional.yaml index 8b9c396d1..2eba61463 100644 --- a/traditional.yaml +++ b/traditional.yaml @@ -117,6 +117,14 @@ server: # problematic. casemapping: "ascii" + # allowed-characters provides fine-grained control over allowable characters in identifiers + # (nicknames, account names, and channel names): + # allowed-characters: + # allow IRC formatting/control characters (bold, color, italics, underline, etc.) + # irc-formatting: false + # allow printable Unicode glyphs (emoji, symbols, combining marks, legacy computing, block elements) + # printable-glyphs: false + # enforce-utf8 controls whether the server will preemptively discard non-UTF8 # messages (since they cannot be relayed to websocket clients), or will allow # them and relay them to non-websocket clients (as in traditional IRC). From 47101cb98dba487ba3cf775e15bf8fd1c56344eb Mon Sep 17 00:00:00 2001 From: waveplate Date: Wed, 19 Aug 2026 11:04:03 -0700 Subject: [PATCH 2/2] irc: fix gofmt in config.go and remove unsupported Unicode 16.0 test case --- irc/config.go | 16 ++++++++-------- irc/strings_test.go | 2 -- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/irc/config.go b/irc/config.go index 56d7280ed..c8d2e35d2 100644 --- a/irc/config.go +++ b/irc/config.go @@ -627,14 +627,14 @@ type Config struct { capValues caps.Values Casemapping i18n.Casemapping AllowedCharacters AllowedCharactersConfig `yaml:"allowed-characters"` - EnforceUtf8 bool `yaml:"enforce-utf8"` - OutputPath string `yaml:"output-path"` - IPCheckScript IPCheckScriptConfig `yaml:"ip-check-script"` - OverrideServicesHostname string `yaml:"override-services-hostname"` - MaxLineLen int `yaml:"max-line-len"` - SuppressLusers bool `yaml:"suppress-lusers"` - AdditionalISupport map[string]string `yaml:"additional-isupport"` - CommandAliases map[string]string `yaml:"command-aliases"` + EnforceUtf8 bool `yaml:"enforce-utf8"` + OutputPath string `yaml:"output-path"` + IPCheckScript IPCheckScriptConfig `yaml:"ip-check-script"` + OverrideServicesHostname string `yaml:"override-services-hostname"` + MaxLineLen int `yaml:"max-line-len"` + SuppressLusers bool `yaml:"suppress-lusers"` + AdditionalISupport map[string]string `yaml:"additional-isupport"` + CommandAliases map[string]string `yaml:"command-aliases"` } API struct { diff --git a/irc/strings_test.go b/irc/strings_test.go index 5b068b4a4..7422847af 100644 --- a/irc/strings_test.go +++ b/irc/strings_test.go @@ -290,8 +290,6 @@ func TestAllowedCharactersPrintableGlyphs(t *testing.T) { {"░Shade░", "░shade░", false}, // Legacy computing (U+1FB00) {"\U0001FB00Legacy\U0001FB00", "\U0001fb00legacy\U0001fb00", false}, - // Legacy computing supplement (U+1CC00) - {"\U0001CC00Supp\U0001CC00", "\U0001cc00supp\U0001cc00", false}, // Emojis & Symbols {"👾gamer👾", "👾gamer👾", false}, {"🔥Fire🔥", "🔥fire🔥", false},