diff --git a/config/config.go b/config/config.go index 321fdd6d..dbf92946 100644 --- a/config/config.go +++ b/config/config.go @@ -39,10 +39,11 @@ const ( ) var ( - globalConf globalConfig - defaultSFTPDBanner = fmt.Sprintf("SFTPGo_%v", version.Get().Version) - defaultFTPDBanner = fmt.Sprintf("SFTPGo %v ready", version.Get().Version) - defaultSFTPDBinding = sftpd.Binding{ + globalConf globalConfig + defaultSFTPDBanner = fmt.Sprintf("SFTPGo_%v", version.Get().Version) + defaultFTPDBanner = fmt.Sprintf("SFTPGo %v ready", version.Get().Version) + defaultInstallCodeHint = "Installation code" + defaultSFTPDBinding = sftpd.Binding{ Address: "", Port: 2022, ApplyProxyConfig: true, @@ -323,6 +324,10 @@ func Init() { AllowCredentials: false, MaxAge: 0, }, + Setup: httpd.SetupConfig{ + InstallationCode: "", + InstallationCodeHint: defaultInstallCodeHint, + }, }, HTTPConfig: httpclient.Config{ Timeout: 20, @@ -354,7 +359,6 @@ func Init() { MinTLSVersion: 12, TLSCipherSuites: nil, }, - PluginsConfig: nil, SMTPConfig: smtp.Config{ Host: "", Port: 25, @@ -366,6 +370,7 @@ func Init() { Domain: "", TemplatesPath: "templates", }, + PluginsConfig: nil, } viper.SetEnvPrefix(configEnvPrefix) @@ -497,7 +502,10 @@ func HasServicesToStart() bool { return false } -func getRedactedPassword() string { +func getRedactedPassword(value string) string { + if value == "" { + return value + } return "[redacted]" } @@ -509,22 +517,19 @@ func getRedactedGlobalConf() globalConfig { conf.Common.PostDisconnectHook = util.GetRedactedURL(conf.Common.PostDisconnectHook) conf.Common.DataRetentionHook = util.GetRedactedURL(conf.Common.DataRetentionHook) conf.SFTPD.KeyboardInteractiveHook = util.GetRedactedURL(conf.SFTPD.KeyboardInteractiveHook) - conf.HTTPDConfig.SigningPassphrase = getRedactedPassword() - conf.ProviderConf.Password = getRedactedPassword() + conf.HTTPDConfig.SigningPassphrase = getRedactedPassword(conf.HTTPDConfig.SigningPassphrase) + conf.HTTPDConfig.Setup.InstallationCode = getRedactedPassword(conf.HTTPDConfig.Setup.InstallationCode) + conf.ProviderConf.Password = getRedactedPassword(conf.ProviderConf.Password) conf.ProviderConf.Actions.Hook = util.GetRedactedURL(conf.ProviderConf.Actions.Hook) conf.ProviderConf.ExternalAuthHook = util.GetRedactedURL(conf.ProviderConf.ExternalAuthHook) conf.ProviderConf.PreLoginHook = util.GetRedactedURL(conf.ProviderConf.PreLoginHook) conf.ProviderConf.PostLoginHook = util.GetRedactedURL(conf.ProviderConf.PostLoginHook) conf.ProviderConf.CheckPasswordHook = util.GetRedactedURL(conf.ProviderConf.CheckPasswordHook) - conf.SMTPConfig.Password = getRedactedPassword() + conf.SMTPConfig.Password = getRedactedPassword(conf.SMTPConfig.Password) conf.HTTPDConfig.Bindings = nil for _, binding := range globalConf.HTTPDConfig.Bindings { - if binding.OIDC.ClientID != "" { - binding.OIDC.ClientID = getRedactedPassword() - } - if binding.OIDC.ClientSecret != "" { - binding.OIDC.ClientSecret = getRedactedPassword() - } + binding.OIDC.ClientID = getRedactedPassword(binding.OIDC.ClientID) + binding.OIDC.ClientSecret = getRedactedPassword(binding.OIDC.ClientSecret) conf.HTTPDConfig.Bindings = append(conf.HTTPDConfig.Bindings, binding) } return conf @@ -576,6 +581,18 @@ func LoadConfig(configDir, configFile string) error { return nil } +func isUploadModeValid() bool { + return globalConf.Common.UploadMode >= 0 && globalConf.Common.UploadMode <= 2 +} + +func isProxyProtocolValid() bool { + return globalConf.Common.ProxyProtocol >= 0 && globalConf.Common.ProxyProtocol <= 2 +} + +func isExternalAuthScopeValid() bool { + return globalConf.ProviderConf.ExternalAuthScope >= 0 && globalConf.ProviderConf.ExternalAuthScope <= 15 +} + func resetInvalidConfigs() { if strings.TrimSpace(globalConf.SFTPD.Banner) == "" { globalConf.SFTPD.Banner = defaultSFTPDBanner @@ -583,27 +600,30 @@ func resetInvalidConfigs() { if strings.TrimSpace(globalConf.FTPD.Banner) == "" { globalConf.FTPD.Banner = defaultFTPDBanner } + if strings.TrimSpace(globalConf.HTTPDConfig.Setup.InstallationCodeHint) == "" { + globalConf.HTTPDConfig.Setup.InstallationCodeHint = defaultInstallCodeHint + } if globalConf.ProviderConf.UsersBaseDir != "" && !util.IsFileInputValid(globalConf.ProviderConf.UsersBaseDir) { warn := fmt.Sprintf("invalid users base dir %#v will be ignored", globalConf.ProviderConf.UsersBaseDir) globalConf.ProviderConf.UsersBaseDir = "" logger.Warn(logSender, "", "Non-fatal configuration error: %v", warn) logger.WarnToConsole("Non-fatal configuration error: %v", warn) } - if globalConf.Common.UploadMode < 0 || globalConf.Common.UploadMode > 2 { + if !isUploadModeValid() { warn := fmt.Sprintf("invalid upload_mode 0, 1 and 2 are supported, configured: %v reset upload_mode to 0", globalConf.Common.UploadMode) globalConf.Common.UploadMode = 0 logger.Warn(logSender, "", "Non-fatal configuration error: %v", warn) logger.WarnToConsole("Non-fatal configuration error: %v", warn) } - if globalConf.Common.ProxyProtocol < 0 || globalConf.Common.ProxyProtocol > 2 { + if !isProxyProtocolValid() { warn := fmt.Sprintf("invalid proxy_protocol 0, 1 and 2 are supported, configured: %v reset proxy_protocol to 0", globalConf.Common.ProxyProtocol) globalConf.Common.ProxyProtocol = 0 logger.Warn(logSender, "", "Non-fatal configuration error: %v", warn) logger.WarnToConsole("Non-fatal configuration error: %v", warn) } - if globalConf.ProviderConf.ExternalAuthScope < 0 || globalConf.ProviderConf.ExternalAuthScope > 15 { + if !isExternalAuthScopeValid() { warn := fmt.Sprintf("invalid external_auth_scope: %v reset to 0", globalConf.ProviderConf.ExternalAuthScope) globalConf.ProviderConf.ExternalAuthScope = 0 logger.Warn(logSender, "", "Non-fatal configuration error: %v", warn) @@ -1555,6 +1575,8 @@ func setViperDefaults() { viper.SetDefault("httpd.cors.allowed_headers", globalConf.HTTPDConfig.Cors.AllowedHeaders) viper.SetDefault("httpd.cors.exposed_headers", globalConf.HTTPDConfig.Cors.ExposedHeaders) viper.SetDefault("httpd.cors.allow_credentials", globalConf.HTTPDConfig.Cors.AllowCredentials) + viper.SetDefault("httpd.setup.installation_code", globalConf.HTTPDConfig.Setup.InstallationCode) + viper.SetDefault("httpd.setup.installation_code_hint", globalConf.HTTPDConfig.Setup.InstallationCodeHint) viper.SetDefault("httpd.cors.max_age", globalConf.HTTPDConfig.Cors.MaxAge) viper.SetDefault("http.timeout", globalConf.HTTPConfig.Timeout) viper.SetDefault("http.retry_wait_min", globalConf.HTTPConfig.RetryWaitMin) @@ -1581,7 +1603,6 @@ func setViperDefaults() { viper.SetDefault("smtp.auth_type", globalConf.SMTPConfig.AuthType) viper.SetDefault("smtp.encryption", globalConf.SMTPConfig.Encryption) viper.SetDefault("smtp.domain", globalConf.SMTPConfig.Domain) - viper.SetDefault("smtp.templates_path", globalConf.SMTPConfig.TemplatesPath) } func lookupBoolFromEnv(envName string) (bool, bool) { diff --git a/config/config_test.go b/config/config_test.go index 9eb7b251..e8cc9fe7 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -250,6 +250,34 @@ func TestInvalidUsersBaseDir(t *testing.T) { assert.NoError(t, err) } +func TestInvalidInstallationHint(t *testing.T) { + reset() + + configDir := ".." + confName := tempConfigName + ".json" + configFilePath := filepath.Join(configDir, confName) + err := config.LoadConfig(configDir, "") + assert.NoError(t, err) + httpdConfig := config.GetHTTPDConfig() + httpdConfig.Setup = httpd.SetupConfig{ + InstallationCode: "abc", + InstallationCodeHint: " ", + } + c := make(map[string]httpd.Conf) + c["httpd"] = httpdConfig + jsonConf, err := json.Marshal(c) + assert.NoError(t, err) + err = os.WriteFile(configFilePath, jsonConf, os.ModePerm) + assert.NoError(t, err) + err = config.LoadConfig(configDir, confName) + assert.NoError(t, err) + httpdConfig = config.GetHTTPDConfig() + assert.Equal(t, "abc", httpdConfig.Setup.InstallationCode) + assert.Equal(t, "Installation code", httpdConfig.Setup.InstallationCodeHint) + err = os.Remove(configFilePath) + assert.NoError(t, err) +} + func TestDefenderProviderDriver(t *testing.T) { if config.GetProviderConf().Driver != dataprovider.SQLiteDataProviderName { t.Skip("this test is not supported with the current database provider") @@ -1094,6 +1122,7 @@ func TestConfigFromEnv(t *testing.T) { os.Setenv("SFTPGO_KMS__SECRETS__URL", "local") os.Setenv("SFTPGO_KMS__SECRETS__MASTER_KEY_PATH", "path") os.Setenv("SFTPGO_TELEMETRY__TLS_CIPHER_SUITES", "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA") + os.Setenv("SFTPGO_HTTPD__SETUP__INSTALLATION_CODE", "123") t.Cleanup(func() { os.Unsetenv("SFTPGO_SFTPD__BINDINGS__0__ADDRESS") os.Unsetenv("SFTPGO_WEBDAVD__BINDINGS__0__PORT") @@ -1104,6 +1133,7 @@ func TestConfigFromEnv(t *testing.T) { os.Unsetenv("SFTPGO_KMS__SECRETS__URL") os.Unsetenv("SFTPGO_KMS__SECRETS__MASTER_KEY_PATH") os.Unsetenv("SFTPGO_TELEMETRY__TLS_CIPHER_SUITES") + os.Unsetenv("SFTPGO_HTTPD__SETUP__INSTALLATION_CODE") }) err := config.LoadConfig(".", "invalid config") assert.NoError(t, err) @@ -1123,4 +1153,5 @@ func TestConfigFromEnv(t *testing.T) { assert.Len(t, telemetryConfig.TLSCipherSuites, 2) assert.Equal(t, "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", telemetryConfig.TLSCipherSuites[0]) assert.Equal(t, "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", telemetryConfig.TLSCipherSuites[1]) + assert.Equal(t, "123", config.GetHTTPDConfig().Setup.InstallationCode) } diff --git a/dataprovider/mysql.go b/dataprovider/mysql.go index e9dd7444..9b9da95a 100644 --- a/dataprovider/mysql.go +++ b/dataprovider/mysql.go @@ -145,7 +145,7 @@ func getMySQLConnectionString(redactedPwd bool) (string, error) { var connectionString string if config.ConnectionString == "" { password := config.Password - if redactedPwd { + if redactedPwd && password != "" { password = "[redacted]" } sslMode := getSSLMode() diff --git a/dataprovider/pgsql.go b/dataprovider/pgsql.go index 7c5697f6..02493ce1 100644 --- a/dataprovider/pgsql.go +++ b/dataprovider/pgsql.go @@ -150,7 +150,7 @@ func getPGSQLConnectionString(redactedPwd bool) string { var connectionString string if config.ConnectionString == "" { password := config.Password - if redactedPwd { + if redactedPwd && password != "" { password = "[redacted]" } connectionString = fmt.Sprintf("host='%v' port=%v dbname='%v' user='%v' password='%v' sslmode=%v connect_timeout=10", diff --git a/docs/full-configuration.md b/docs/full-configuration.md index f6e6e4a9..126d9121 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -276,6 +276,9 @@ The configuration file contains the following sections: - `exposed_headers`, list of strings. - `allow_credentials` boolean. - `max_age`, integer. + - `setup` struct containing configurations for the initial setup screen + - `installation_code`, string. If set, this installation code will be required when creating the first admin account. Please note that even if set using an environment variable this field is read at SFTPGo startup and not at runtime. This is not a license key or similar, the purpose here is to prevent anyone who can access to the initial setup screen from creating an admin user. Default: blank. + - `installation_code_hint`, string. Description for the installation code input field. Default: `Installation code`. - **"telemetry"**, the configuration for the telemetry server, more details [below](#telemetry-server) - `bind_port`, integer. The port used for serving HTTP requests. Set to 0 to disable HTTP server. Default: 0 - `bind_address`, string. Leave blank to listen on all available network interfaces. On \*NIX you can specify an absolute path to listen on a Unix-domain socket. Default: `127.0.0.1` diff --git a/httpd/httpd.go b/httpd/httpd.go index c889b548..57ec8784 100644 --- a/httpd/httpd.go +++ b/httpd/httpd.go @@ -228,7 +228,9 @@ var ( webStaticFilesPath string webOpenAPIPath string // max upload size for http clients, 1GB by default - maxUploadFileSize = int64(1048576000) + maxUploadFileSize = int64(1048576000) + installationCode string + installationCodeHint string ) func init() { @@ -426,6 +428,18 @@ type ServicesStatus struct { MFA mfa.ServiceStatus `json:"mfa"` } +// SetupConfig defines the configuration parameters for the initial web admin setup +type SetupConfig struct { + // Installation code to require when creating the first admin account. + // As for the other configurations, this value is read at SFTPGo startup and not at runtime + // even if set using an environment variable. + // This is not a license key or similar, the purpose here is to prevent anyone who can access + // to the initial setup screen from creating an admin user + InstallationCode string `json:"installation_code" mapstructure:"installation_code"` + // Description for the installation code input field + InstallationCodeHint string `json:"installation_code_hint" mapstructure:"installation_code_hint"` +} + // CorsConfig defines the CORS configuration type CorsConfig struct { AllowedOrigins []string `json:"allowed_origins" mapstructure:"allowed_origins"` @@ -474,6 +488,8 @@ type Conf struct { MaxUploadFileSize int64 `json:"max_upload_file_size" mapstructure:"max_upload_file_size"` // CORS configuration Cors CorsConfig `json:"cors" mapstructure:"cors"` + // Initial setup configuration + Setup SetupConfig `json:"setup" mapstructure:"setup"` } type apiResponse struct { @@ -521,7 +537,12 @@ func (c *Conf) checkRequiredDirs(staticFilesPath, templatesPath string) error { func (c *Conf) getRedacted() Conf { redacted := "[redacted]" conf := *c - conf.SigningPassphrase = redacted + if conf.SigningPassphrase != "" { + conf.SigningPassphrase = redacted + } + if conf.Setup.InstallationCode != "" { + conf.Setup.InstallationCode = redacted + } conf.Bindings = nil for _, binding := range c.Bindings { if binding.OIDC.ClientID != "" { @@ -604,6 +625,8 @@ func (c *Conf) Initialize(configDir string) error { } maxUploadFileSize = c.MaxUploadFileSize + installationCode = c.Setup.InstallationCode + installationCodeHint = c.Setup.InstallationCodeHint startCleanupTicker(tokenDuration / 2) return <-exitChannel } diff --git a/httpd/internal_test.go b/httpd/internal_test.go index b3e62bd4..834e8644 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -266,6 +266,7 @@ G6p7xS+JswJrzX4885bZJ9Oi1AR2yM3sC9l0O7I4lDbNPmWIXBLeEhGMmcPKv/Kc w0kqpr7MgJ94qhXCBcVcfPuFN9fBOadM3UBj1B45Cz3pptoK+ScI8XKno6jvVK/p xr5cb9VBRBtB9aOKVfuRhpatAfS2Pzm2Htae9lFn7slGPUmu2hkjDw== -----END RSA PRIVATE KEY-----` + defaultAdminUsername = "admin" ) type failingWriter struct { @@ -300,6 +301,21 @@ func TestShouldBind(t *testing.T) { } } +func TestRedactedConf(t *testing.T) { + c := Conf{ + SigningPassphrase: "passphrase", + Setup: SetupConfig{ + InstallationCode: "123", + }, + } + redactedField := "[redacted]" + redactedConf := c.getRedacted() + assert.Equal(t, redactedField, redactedConf.SigningPassphrase) + assert.Equal(t, redactedField, redactedConf.Setup.InstallationCode) + assert.NotEqual(t, c.SigningPassphrase, redactedConf.SigningPassphrase) + assert.NotEqual(t, c.Setup.InstallationCode, redactedConf.Setup.InstallationCode) +} + func TestGetRespStatus(t *testing.T) { var err error err = util.NewMethodDisabledError("") @@ -708,7 +724,7 @@ func TestCreateTokenError(t *testing.T) { } rr := httptest.NewRecorder() admin := dataprovider.Admin{ - Username: "admin", + Username: defaultAdminUsername, Password: "password", } req, _ := http.NewRequest(http.MethodGet, tokenPath, nil) @@ -918,7 +934,7 @@ func TestAPIKeyAuthForbidden(t *testing.T) { func TestJWTTokenValidation(t *testing.T) { tokenAuth := jwtauth.New(jwa.HS256.String(), util.GenerateRandomBytes(32), nil) claims := make(map[string]interface{}) - claims["username"] = "admin" + claims["username"] = defaultAdminUsername claims[jwt.ExpirationKey] = time.Now().UTC().Add(-1 * time.Hour) token, _, err := tokenAuth.Encode(claims) assert.NoError(t, err) @@ -2308,3 +2324,77 @@ func TestSecureMiddlewareIntegration(t *testing.T) { server.binding.Security.updateProxyHeaders() assert.Len(t, server.binding.Security.proxyHeaders, 0) } + +func TestWebAdminSetupWithInstallCode(t *testing.T) { + installationCode = "1234" + // delete all the admins + admins, err := dataprovider.GetAdmins(100, 0, dataprovider.OrderASC) + assert.NoError(t, err) + for _, admin := range admins { + err = dataprovider.DeleteAdmin(admin.Username, "", "") + assert.NoError(t, err) + } + // close the provider and initializes it without creating the default admin + providerConf := dataprovider.GetProviderConfig() + providerConf.CreateDefaultAdmin = false + err = dataprovider.Close() + assert.NoError(t, err) + err = dataprovider.Initialize(providerConf, "..", true) + assert.NoError(t, err) + + server := httpdServer{ + enableWebAdmin: true, + enableWebClient: true, + } + server.initializeRouter() + + rr := httptest.NewRecorder() + r, err := http.NewRequest(http.MethodGet, webAdminSetupPath, nil) + assert.NoError(t, err) + server.router.ServeHTTP(rr, r) + assert.Equal(t, http.StatusOK, rr.Code) + + for _, webURL := range []string{"/", webBasePath, webBaseAdminPath, webAdminLoginPath, webClientLoginPath} { + rr = httptest.NewRecorder() + r, err = http.NewRequest(http.MethodGet, webURL, nil) + assert.NoError(t, err) + server.router.ServeHTTP(rr, r) + assert.Equal(t, http.StatusFound, rr.Code) + assert.Equal(t, webAdminSetupPath, rr.Header().Get("Location")) + } + + form := make(url.Values) + csrfToken := createCSRFToken() + form.Set("_form_token", csrfToken) + form.Set("install_code", "12345") + form.Set("username", defaultAdminUsername) + form.Set("password", "password") + form.Set("confirm_password", "password") + rr = httptest.NewRecorder() + r, err = http.NewRequest(http.MethodPost, webAdminSetupPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + server.router.ServeHTTP(rr, r) + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "Installation code mismatch") + + _, err = dataprovider.AdminExists(defaultAdminUsername) + assert.Error(t, err) + form.Set("install_code", "1234") + rr = httptest.NewRecorder() + r, err = http.NewRequest(http.MethodPost, webAdminSetupPath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + server.router.ServeHTTP(rr, r) + assert.Equal(t, http.StatusFound, rr.Code) + + _, err = dataprovider.AdminExists(defaultAdminUsername) + assert.NoError(t, err) + + err = dataprovider.Close() + assert.NoError(t, err) + providerConf.CreateDefaultAdmin = true + err = dataprovider.Initialize(providerConf, "..", true) + assert.NoError(t, err) + installationCode = "" +} diff --git a/httpd/server.go b/httpd/server.go index 06007fca..3083879c 100644 --- a/httpd/server.go +++ b/httpd/server.go @@ -603,6 +603,11 @@ func (s *httpdServer) handleWebAdminSetupPost(w http.ResponseWriter, r *http.Req } username := r.Form.Get("username") password := r.Form.Get("password") + installCode := r.Form.Get("install_code") + if installationCode != "" && installCode != installationCode { + renderAdminSetupPage(w, r, username, fmt.Sprintf("%v mismatch", installationCodeHint)) + return + } confirmPassword := r.Form.Get("confirm_password") if username == "" { renderAdminSetupPage(w, r, username, "Please set a username") diff --git a/httpd/webadmin.go b/httpd/webadmin.go index 6d775afd..c994c15c 100644 --- a/httpd/webadmin.go +++ b/httpd/webadmin.go @@ -212,8 +212,10 @@ type defenderHostsPage struct { type setupPage struct { basePage - Username string - Error string + Username string + HasInstallationCode bool + InstallationCodeHint string + Error string } type folderPage struct { @@ -553,9 +555,11 @@ func renderMaintenancePage(w http.ResponseWriter, r *http.Request, error string) func renderAdminSetupPage(w http.ResponseWriter, r *http.Request, username, error string) { data := setupPage{ - basePage: getBasePageData(pageSetupTitle, webAdminSetupPath, r), - Username: username, - Error: error, + basePage: getBasePageData(pageSetupTitle, webAdminSetupPath, r), + Username: username, + HasInstallationCode: installationCode != "", + InstallationCodeHint: installationCodeHint, + Error: error, } renderAdminTemplate(w, templateSetup, data) diff --git a/service/service_portable.go b/service/service_portable.go index 0e30aa10..4ec15f7b 100644 --- a/service/service_portable.go +++ b/service/service_portable.go @@ -247,7 +247,7 @@ func (s *Service) configurePortableUser() string { s.PortableUser.Username = "user" } printablePassword := "" - if len(s.PortableUser.Password) > 0 { + if s.PortableUser.Password != "" { printablePassword = "[redacted]" } if len(s.PortableUser.PublicKeys) == 0 && s.PortableUser.Password == "" { diff --git a/sftpgo.json b/sftpgo.json index c82c56c6..2802258b 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -264,6 +264,10 @@ "exposed_headers": [], "allow_credentials": false, "max_age": 0 + }, + "setup": { + "installation_code": "", + "installation_code_hint": "Installation code" } }, "telemetry": { diff --git a/templates/webadmin/adminsetup.html b/templates/webadmin/adminsetup.html index e7266667..b77b87ec 100644 --- a/templates/webadmin/adminsetup.html +++ b/templates/webadmin/adminsetup.html @@ -97,6 +97,12 @@ {{end}}