diff --git a/default.yaml b/default.yaml index ea1e5029..26d41611 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 6e028206..7007db6b 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 7a149491..c8d2e35d 100644 --- a/irc/config.go +++ b/irc/config.go @@ -626,14 +626,15 @@ type Config struct { supportedCapsWithoutSTS *caps.Set capValues caps.Values Casemapping i18n.Casemapping - 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"` + 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"` } API struct { @@ -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 400dc805..09c2c74b 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 823a98a8..a2f37573 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 e227cb8f..dc8451b7 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 537e55f5..7422847a 100644 --- a/irc/strings_test.go +++ b/irc/strings_test.go @@ -220,3 +220,91 @@ 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}, + // 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 8b9c396d..2eba6146 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).