Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
10 changes: 8 additions & 2 deletions irc/client_lookup_set.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
}
Expand Down
21 changes: 11 additions & 10 deletions irc/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
}

Expand Down
2 changes: 1 addition & 1 deletion irc/i18n/strings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions irc/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
41 changes: 40 additions & 1 deletion irc/strings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
88 changes: 88 additions & 0 deletions irc/strings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
8 changes: 8 additions & 0 deletions traditional.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down