feat(server): validate username

Ayman Bagabas created

Change summary

server/backend/sqlite/sqlite.go |  6 ++++
server/backend/sqlite/user.go   | 48 +++++++++++++++++++++++++++++-----
server/cmd/user.go              | 15 +++++++---
server/config/config.go         |  5 +++
server/utils/utils.go           | 21 +++++++++++++++
5 files changed, 82 insertions(+), 13 deletions(-)

Detailed changes

server/backend/sqlite/sqlite.go 🔗

@@ -7,6 +7,7 @@ import (
 	"os"
 	"path/filepath"
 	"strconv"
+	"strings"
 	"text/template"
 
 	"github.com/charmbracelet/log"
@@ -410,6 +411,11 @@ func (d *SqliteBackend) SetProjectName(repo string, name string) error {
 //
 // It implements backend.Backend.
 func (d *SqliteBackend) AddCollaborator(repo string, username string) error {
+	username = strings.ToLower(username)
+	if err := utils.ValidateUsername(username); err != nil {
+		return err
+	}
+
 	repo = utils.SanitizeRepo(repo)
 	return wrapDbErr(wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
 		_, err := tx.Exec(`INSERT INTO collab (user_id, repo_id, updated_at)

server/backend/sqlite/user.go 🔗

@@ -2,8 +2,10 @@ package sqlite
 
 import (
 	"context"
+	"strings"
 
 	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/charmbracelet/soft-serve/server/utils"
 	"github.com/jmoiron/sqlx"
 	"golang.org/x/crypto/ssh"
 )
@@ -136,6 +138,11 @@ func (d *SqliteBackend) AccessLevelByPublicKey(repo string, pk ssh.PublicKey) ba
 //
 // It implements backend.Backend.
 func (d *SqliteBackend) AddPublicKey(username string, pk ssh.PublicKey) error {
+	username = strings.ToLower(username)
+	if err := utils.ValidateUsername(username); err != nil {
+		return err
+	}
+
 	return wrapDbErr(
 		wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
 			var userID int
@@ -154,16 +161,16 @@ func (d *SqliteBackend) AddPublicKey(username string, pk ssh.PublicKey) error {
 //
 // It implements backend.Backend.
 func (d *SqliteBackend) CreateUser(username string, opts backend.UserOptions) (backend.User, error) {
+	username = strings.ToLower(username)
+	if err := utils.ValidateUsername(username); err != nil {
+		return nil, err
+	}
+
 	var user *User
 	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
-		into := "INSERT INTO user (username"
-		values := "VALUES (?"
-		args := []interface{}{username}
-		if opts.Admin {
-			into += ", admin"
-			values += ", ?"
-			args = append(args, opts.Admin)
-		}
+		into := "INSERT INTO user (username, admin"
+		values := "VALUES (?, ?"
+		args := []interface{}{username, opts.Admin}
 		into += ", updated_at)"
 		values += ", CURRENT_TIMESTAMP)"
 
@@ -202,6 +209,11 @@ func (d *SqliteBackend) CreateUser(username string, opts backend.UserOptions) (b
 //
 // It implements backend.Backend.
 func (d *SqliteBackend) DeleteUser(username string) error {
+	username = strings.ToLower(username)
+	if err := utils.ValidateUsername(username); err != nil {
+		return err
+	}
+
 	return wrapDbErr(
 		wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
 			_, err := tx.Exec("DELETE FROM user WHERE username = ?", username)
@@ -226,6 +238,11 @@ func (d *SqliteBackend) RemovePublicKey(username string, pk ssh.PublicKey) error
 
 // ListPublicKeys lists the public keys of a user.
 func (d *SqliteBackend) ListPublicKeys(username string) ([]ssh.PublicKey, error) {
+	username = strings.ToLower(username)
+	if err := utils.ValidateUsername(username); err != nil {
+		return nil, err
+	}
+
 	keys := make([]ssh.PublicKey, 0)
 	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
 		var keyStrings []string
@@ -256,6 +273,11 @@ func (d *SqliteBackend) ListPublicKeys(username string) ([]ssh.PublicKey, error)
 //
 // It implements backend.Backend.
 func (d *SqliteBackend) SetUsername(username string, newUsername string) error {
+	username = strings.ToLower(username)
+	if err := utils.ValidateUsername(username); err != nil {
+		return err
+	}
+
 	return wrapDbErr(
 		wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
 			_, err := tx.Exec("UPDATE user SET username = ? WHERE username = ?", newUsername, username)
@@ -268,6 +290,11 @@ func (d *SqliteBackend) SetUsername(username string, newUsername string) error {
 //
 // It implements backend.Backend.
 func (d *SqliteBackend) SetAdmin(username string, admin bool) error {
+	username = strings.ToLower(username)
+	if err := utils.ValidateUsername(username); err != nil {
+		return err
+	}
+
 	return wrapDbErr(
 		wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
 			_, err := tx.Exec("UPDATE user SET admin = ? WHERE username = ?", admin, username)
@@ -280,6 +307,11 @@ func (d *SqliteBackend) SetAdmin(username string, admin bool) error {
 //
 // It implements backend.Backend.
 func (d *SqliteBackend) User(username string) (backend.User, error) {
+	username = strings.ToLower(username)
+	if err := utils.ValidateUsername(username); err != nil {
+		return nil, err
+	}
+
 	if err := wrapTx(d.db, context.Background(), func(tx *sqlx.Tx) error {
 		return tx.Get(&username, "SELECT username FROM user WHERE username = ?", username)
 	}); err != nil {

server/cmd/user.go 🔗

@@ -25,19 +25,24 @@ func userCommand() *cobra.Command {
 		Args:              cobra.ExactArgs(1),
 		PersistentPreRunE: checkIfAdmin,
 		RunE: func(cmd *cobra.Command, args []string) error {
+			var pubkeys []ssh.PublicKey
 			cfg, _ := fromContext(cmd)
 			username := args[0]
-			pk, _, err := backend.ParseAuthorizedKey(key)
-			if err != nil {
-				return err
+			if key != "" {
+				pk, _, err := backend.ParseAuthorizedKey(key)
+				if err != nil {
+					return err
+				}
+
+				pubkeys = []ssh.PublicKey{pk}
 			}
 
 			opts := backend.UserOptions{
 				Admin:      admin,
-				PublicKeys: []ssh.PublicKey{pk},
+				PublicKeys: pubkeys,
 			}
 
-			_, err = cfg.Backend.CreateUser(username, opts)
+			_, err := cfg.Backend.CreateUser(username, opts)
 			return err
 		},
 	}

server/config/config.go 🔗

@@ -109,6 +109,11 @@ func ParseConfig(path string) (*Config, error) {
 	return cfg, nil
 }
 
+// WriteConfig writes the configuration to the given file.
+func WriteConfig(path string, cfg *Config) error {
+	return os.WriteFile(path, []byte(newConfigFile(cfg)), 0o600) // nolint: errcheck
+}
+
 // DefaultConfig returns a Config with the values populated with the defaults
 // or specified environment variables.
 func DefaultConfig() *Config {

server/utils/utils.go 🔗

@@ -1,8 +1,10 @@
 package utils
 
 import (
+	"fmt"
 	"path/filepath"
 	"strings"
+	"unicode"
 )
 
 // SanitizeRepo returns a sanitized version of the given repository name.
@@ -12,3 +14,22 @@ func SanitizeRepo(repo string) string {
 	repo = strings.TrimSuffix(repo, ".git")
 	return repo
 }
+
+// ValidateUsername returns an error if any of the given usernames are invalid.
+func ValidateUsername(username string) error {
+	if username == "" {
+		return fmt.Errorf("username cannot be empty")
+	}
+
+	if !unicode.IsLetter(rune(username[0])) {
+		return fmt.Errorf("username must start with a letter")
+	}
+
+	for _, r := range username {
+		if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' {
+			return fmt.Errorf("username can only contain letters, numbers, and hyphens")
+		}
+	}
+
+	return nil
+}