diff --git a/.golangci.yml b/.golangci.yml index 16665c9..673ca55 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -83,6 +83,7 @@ issues: - path: _test\.go linters: - gocyclo + - gocognit - errcheck - dupl - gosec diff --git a/README.md b/README.md index b43ad0c..1a74193 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ env vars * **POSTMOOGLE_DB_DSN** - database connection string * **POSTMOOGLE_DB_DIALECT** - database dialect (postgres, sqlite3) * **POSTMOOGLE_MAXSIZE** - max email size (including attachments) in megabytes +* **POSTMOOGLE_USERS** - a space-separated list of whitelisted users allowed to use the bridge. If not defined, everyone is allowed. Example rule: `@someone:example.com @another:example.com @bot.*:example.com @*:another.com` You can find default values in [config/defaults.go](config/defaults.go) diff --git a/bot/bot.go b/bot/bot.go index 42db622..d731b7e 100644 --- a/bot/bot.go +++ b/bot/bot.go @@ -3,6 +3,7 @@ package bot import ( "context" "fmt" + "regexp" "sync" "github.com/getsentry/sentry-go" @@ -19,6 +20,7 @@ type Bot struct { federation bool prefix string domain string + allowedUsers []*regexp.Regexp rooms sync.Map log *logger.Logger lp *linkpearl.Linkpearl @@ -27,16 +29,17 @@ type Bot struct { } // New creates a new matrix bot -func New(lp *linkpearl.Linkpearl, log *logger.Logger, prefix, domain string, noowner, federation bool) *Bot { +func New(lp *linkpearl.Linkpearl, log *logger.Logger, prefix, domain string, noowner, federation bool, allowedUsers []*regexp.Regexp) *Bot { return &Bot{ - noowner: noowner, - federation: federation, - prefix: prefix, - domain: domain, - rooms: sync.Map{}, - log: log, - lp: lp, - mu: map[id.RoomID]*sync.Mutex{}, + noowner: noowner, + federation: federation, + prefix: prefix, + domain: domain, + allowedUsers: allowedUsers, + rooms: sync.Map{}, + log: log, + lp: lp, + mu: map[id.RoomID]*sync.Mutex{}, } } diff --git a/bot/command.go b/bot/command.go index 40438e0..0774aea 100644 --- a/bot/command.go +++ b/bot/command.go @@ -175,7 +175,7 @@ func (b *Bot) runStop(ctx context.Context, checkAllowed bool) { return } - if checkAllowed && !cfg.Allowed(b.noowner, evt.Sender) { + if checkAllowed && !b.Allowed(evt.Sender, cfg) { b.Notice(ctx, evt.RoomID, "you don't have permission to do that") return } @@ -251,7 +251,7 @@ func (b *Bot) setOption(ctx context.Context, name, value string) { return } - if !cfg.Allowed(b.noowner, evt.Sender) { + if !b.Allowed(evt.Sender, cfg) { b.Notice(ctx, evt.RoomID, "you don't have permission to do that, kupo") return } diff --git a/bot/settings.go b/bot/settings.go index bc933ed..6ee78eb 100644 --- a/bot/settings.go +++ b/bot/settings.go @@ -19,20 +19,6 @@ type settingsOld struct { NoSender bool } -// Allowed checks if change is allowed -func (s settings) Allowed(noowner bool, userID id.UserID) bool { - if noowner { - return true - } - - owner := s.Owner() - if owner == "" { - return true - } - - return owner == userID.String() -} - // Get option func (s settings) Get(key string) string { value := s[strings.ToLower(strings.TrimSpace(key))] @@ -118,3 +104,21 @@ func (b *Bot) getSettings(roomID id.RoomID) (settings, error) { func (b *Bot) setSettings(roomID id.RoomID, cfg settings) error { return utils.UnwrapError(b.lp.GetClient().SetRoomAccountData(roomID, settingskey, cfg)) } + +// Allowed checks if change is allowed +func (b *Bot) Allowed(userID id.UserID, cfg settings) bool { + if !utils.Match(userID.String(), b.allowedUsers) { + return false + } + + if b.noowner { + return true + } + + owner := cfg.Owner() + if owner == "" { + return true + } + + return owner == userID.String() +} diff --git a/cmd/cmd.go b/cmd/cmd.go index dcd3ec7..4ccf225 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -26,7 +26,13 @@ var ( func main() { quit := make(chan struct{}) - cfg := config.New() + + cfg, err := config.New() + if err != nil { + log = logger.New("postmoogle.", "info") + log.Fatal("cannot read config: %v", err) + } + log = logger.New("postmoogle.", cfg.LogLevel) log.Info("#############################") @@ -81,7 +87,7 @@ func initBot(cfg *config.Config) { // nolint // Fatal = panic, not os.Exit() log.Fatal("cannot initialize matrix bot: %v", err) } - mxb = bot.New(lp, mxlog, cfg.Prefix, cfg.Domain, cfg.NoOwner, cfg.Federation) + mxb = bot.New(lp, mxlog, cfg.Prefix, cfg.Domain, cfg.NoOwner, cfg.Federation, cfg.Users) log.Debug("bot has been created") } diff --git a/config/config.go b/config/config.go index 4735b55..6c4804f 100644 --- a/config/config.go +++ b/config/config.go @@ -1,14 +1,29 @@ package config import ( + "fmt" + "gitlab.com/etke.cc/go/env" + + "gitlab.com/etke.cc/postmoogle/utils" ) const prefix = "postmoogle" // New config -func New() *Config { +func New() (*Config, error) { env.SetPrefix(prefix) + + mxidPatterns := env.Slice("users") + regexPatterns, err := utils.WildcardMXIDsToRegexes(mxidPatterns) + if err != nil { + return nil, fmt.Errorf( + "failed to convert wildcard user patterns (`%s`) to regular expression: %s", + mxidPatterns, + err, + ) + } + cfg := &Config{ Homeserver: env.String("homeserver", defaultConfig.Homeserver), Login: env.String("login", defaultConfig.Login), @@ -21,6 +36,7 @@ func New() *Config { Federation: env.Bool("federation"), MaxSize: env.Int("maxsize", defaultConfig.MaxSize), StatusMsg: env.String("statusmsg", defaultConfig.StatusMsg), + Users: *regexPatterns, Sentry: Sentry{ DSN: env.String("sentry.dsn", defaultConfig.Sentry.DSN), }, @@ -31,5 +47,5 @@ func New() *Config { }, } - return cfg + return cfg, nil } diff --git a/config/types.go b/config/types.go index 5dc8985..4920919 100644 --- a/config/types.go +++ b/config/types.go @@ -1,5 +1,7 @@ package config +import "regexp" + // Config of Postmoogle type Config struct { // Homeserver url @@ -26,6 +28,8 @@ type Config struct { MaxSize int // StatusMsg of the bot StatusMsg string + // Users holds list of allowed users (wildcards supported), e.g.: @*:example.com, @bot.*:example.com, @admin:*. Empty = * + Users []*regexp.Regexp // DB config DB DB diff --git a/utils/user.go b/utils/user.go new file mode 100644 index 0000000..0e8c59e --- /dev/null +++ b/utils/user.go @@ -0,0 +1,109 @@ +package utils + +import ( + "fmt" + "regexp" + "strings" +) + +// WildcardMXIDsToRegexes converts a list of wildcard patterns to a list of regular expressions +func WildcardMXIDsToRegexes(wildCardPatterns []string) (*[]*regexp.Regexp, error) { + regexPatterns := make([]*regexp.Regexp, len(wildCardPatterns)) + + for idx, wildCardPattern := range wildCardPatterns { + regex, err := parseMXIDWildcard(wildCardPattern) + if err != nil { + return nil, fmt.Errorf("failed to parse allowed user rule `%s`: %s", wildCardPattern, err) + } + regexPatterns[idx] = regex + } + + return ®exPatterns, nil +} + +// Match tells if the given user id is allowed to use the bot, according to the given whitelist +func Match(userID string, allowed []*regexp.Regexp) bool { + // No whitelisted users means everyone is whitelisted + if len(allowed) == 0 { + return true + } + + for _, regex := range allowed { + if regex.MatchString(userID) { + return true + } + } + + return false +} + +// parseMXIDWildcard parses a user whitelisting wildcard rule and returns a regular expression which corresponds to it +// +// Example conversion: `@bot.*.something:*.example.com` -> `^bot\.([^:@]*)\.something:([^:@]*)\.example.com$` +// Example of recognized wildcard patterns: `@someone:example.com`, `@*:example.com`, `@bot.*:example.com`, `@someone:*`, `@someone:*.example.com` +// +// The `*` wildcard character is normally interpretted as "a number of literal characters or an empty string". +// Our implementation below matches this (yielding `([^:@])*`), which could provide a slightly suboptimal regex in these cases: +// - `@*:example.com` -> `^@([^:@])*:example\.com$`, although `^@([^:@])+:example\.com$` would be preferable +// - `@someone:*` -> `@someone:([^:@])*$`, although `@someone:([^:@])+$` would be preferable +// When it's a bare wildcard (`*`, instead of `*.example.com`) we likely prefer to yield a regex that matches **at least one character**. +// This probably doesn't matter because mxids that we'll match against are all valid and fully complete. +func parseMXIDWildcard(wildCardRule string) (*regexp.Regexp, error) { + if !strings.HasPrefix(wildCardRule, "@") { + return nil, fmt.Errorf("rules need to be fully-qualified, starting with a @") + } + + remainingRule := wildCardRule[1:] + if strings.Contains(remainingRule, "@") { + return nil, fmt.Errorf("rules cannot contain more than one @") + } + + parts := strings.Split(remainingRule, ":") + if len(parts) != 2 { + return nil, fmt.Errorf("expected exactly 2 parts in the rule, separated by `:`") + } + + localPart := parts[0] + localPartPattern, err := getRegexPatternForPart(localPart) + if err != nil { + return nil, fmt.Errorf("failed to convert local part `%s` to regex: %s", localPart, err) + } + + domainPart := parts[1] + domainPartPattern, err := getRegexPatternForPart(domainPart) + if err != nil { + return nil, fmt.Errorf("failed to convert domain part `%s` to regex: %s", domainPart, err) + } + + finalPattern := fmt.Sprintf("^@%s:%s$", localPartPattern, domainPartPattern) + + regex, err := regexp.Compile(finalPattern) + if err != nil { + return nil, fmt.Errorf("failed to compile regex `%s`: %s", finalPattern, err) + } + + return regex, nil +} + +func getRegexPatternForPart(part string) (string, error) { + if part == "" { + return "", fmt.Errorf("rejecting empty part") + } + + var pattern strings.Builder + for _, rune := range part { + if rune == '*' { + // We match everything except for `:` and `@`, because that would be an invalid MXID anyway. + // + // If the whole part is `*` (only) instead of merely containing `*` within it, + // we may also consider replacing it with `([^:@]+)` (+, instead of *). + // See parseMXIDWildcard for notes about this. + pattern.WriteString("([^:@]*)") + continue + } + + pattern.WriteString(regexp.QuoteMeta(string(rune))) + } + + return pattern.String(), nil +} diff --git a/utils/user_test.go b/utils/user_test.go new file mode 100644 index 0000000..5068204 --- /dev/null +++ b/utils/user_test.go @@ -0,0 +1,221 @@ +package utils + +import "testing" + +func TestRuleToRegex(t *testing.T) { + type testDataDefinition struct { + name string + checkedValue string + expectedResult string + expectedError bool + } + + tests := []testDataDefinition{ + { + name: "simple pattern without wildcards succeeds", + checkedValue: "@someone:example.com", + expectedResult: `^@someone:example\.com$`, + expectedError: false, + }, + { + name: "pattern with wildcard as the whole local part succeeds", + checkedValue: "@*:example.com", + expectedResult: `^@([^:@]*):example\.com$`, + expectedError: false, + }, + { + name: "pattern with wildcard within the local part succeeds", + checkedValue: "@bot.*.something:example.com", + expectedResult: `^@bot\.([^:@]*)\.something:example\.com$`, + expectedError: false, + }, + { + name: "pattern with wildcard as the whole domain part succeeds", + checkedValue: "@someone:*", + expectedResult: `^@someone:([^:@]*)$`, + expectedError: false, + }, + { + name: "pattern with wildcard within the domain part succeeds", + checkedValue: "@someone:*.organization.com", + expectedResult: `^@someone:([^:@]*)\.organization\.com$`, + expectedError: false, + }, + { + name: "pattern with wildcard in both parts succeeds", + checkedValue: "@*:*", + expectedResult: `^@([^:@]*):([^:@]*)$`, + expectedError: false, + }, + { + name: "pattern that does not appear fully-qualified fails", + checkedValue: "someone:example.com", + expectedResult: ``, + expectedError: true, + }, + { + name: "pattern that does not appear fully-qualified fails", + checkedValue: "@someone", + expectedResult: ``, + expectedError: true, + }, + { + name: "pattern with empty domain part fails", + checkedValue: "@someone:", + expectedResult: ``, + expectedError: true, + }, + { + name: "pattern with empty local part fails", + checkedValue: "@:example.com", + expectedResult: ``, + expectedError: true, + }, + { + name: "pattern with multiple @ fails", + checkedValue: "@someone@someone:example.com", + expectedResult: ``, + expectedError: true, + }, + { + name: "pattern with multiple : fails", + checkedValue: "@someone:someone:example.com", + expectedResult: ``, + expectedError: true, + }, + } + + for _, testData := range tests { + func(testData testDataDefinition) { + t.Run(testData.name, func(t *testing.T) { + actualResult, err := parseMXIDWildcard(testData.checkedValue) + + if testData.expectedError { + if err != nil { + return + } + + t.Errorf("expected an error, but did not get one") + } + + if err != nil { + t.Errorf("did not expect an error, but got one: %s", err) + } + + if actualResult.String() == testData.expectedResult { + return + } + + t.Errorf( + "Expected `%s` to yield `%s`, not `%s`", + testData.checkedValue, + testData.expectedResult, + actualResult.String(), + ) + }) + }(testData) + } +} + +func TestMatch(t *testing.T) { + type testDataDefinition struct { + name string + checkedValue string + allowedUsers []string + expectedResult bool + } + + tests := []testDataDefinition{ + { + name: "Empty allowed users allows anyone", + checkedValue: "@someone:example.com", + allowedUsers: []string{}, + expectedResult: true, + }, + { + name: "Direct full mxid match is allowed", + checkedValue: "@someone:example.com", + allowedUsers: []string{"@someone:example.com"}, + expectedResult: true, + }, + { + name: "Direct full mxid match later on is allowed", + checkedValue: "@someone:example.com", + allowedUsers: []string{"@another:example.com", "@someone:example.com"}, + expectedResult: true, + }, + { + name: "No mxid match is not allowed", + checkedValue: "@someone:example.com", + allowedUsers: []string{"@another:example.com"}, + expectedResult: false, + }, + { + name: "mxid localpart only wildcard match is allowed", + checkedValue: "@someone:example.com", + allowedUsers: []string{"@*:example.com"}, + expectedResult: true, + }, + { + name: "mxid localpart with wildcard match is allowed", + checkedValue: "@bot.abc:example.com", + allowedUsers: []string{"@bot.*:example.com"}, + expectedResult: true, + }, + { + name: "mxid localpart with wildcard match is not allowed when it does not match", + checkedValue: "@bot.abc:example.com", + allowedUsers: []string{"@employee.*:example.com"}, + expectedResult: false, + }, + { + name: "mxid localpart wildcard for another domain is not allowed", + checkedValue: "@someone:example.com", + allowedUsers: []string{"@*:another.com"}, + expectedResult: false, + }, + { + name: "mxid domainpart with only wildcard match is allowed", + checkedValue: "@someone:example.com", + allowedUsers: []string{"@someone:*"}, + expectedResult: true, + }, + { + name: "mxid domainpart with wildcard match is allowed", + checkedValue: "@someone:example.organization.com", + allowedUsers: []string{"@someone:*.organization.com"}, + expectedResult: true, + }, + { + name: "mxid domainpart with wildcard match is not allowed when it does not match", + checkedValue: "@someone:example.another.com", + allowedUsers: []string{"@someone:*.organization.com"}, + expectedResult: false, + }, + } + + for _, testData := range tests { + func(testData testDataDefinition) { + t.Run(testData.name, func(t *testing.T) { + allowedUserRegexes, err := WildcardMXIDsToRegexes(testData.allowedUsers) + if err != nil { + t.Error(err) + } + + actualResult := Match(testData.checkedValue, *allowedUserRegexes) + + if actualResult == testData.expectedResult { + return + } + + t.Errorf( + "Expected `%s` compared against `%v` to yield `%v`, not `%v`", + testData.checkedValue, + testData.allowedUsers, + testData.expectedResult, + actualResult, + ) + }) + }(testData) + } +}