add a setting to skip natural keys validation

Enabling the "skip_natural_keys_validation" data provider setting,
the natural keys for REST API/Web Admin as usernames, admin names,
folder names are not restricted to unreserved URI chars

Fixes #334 #308
This commit is contained in:
Nicola Murino 2021-03-05 19:08:22 +01:00
parent db354e838c
commit bdb6f585c7
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
10 changed files with 106 additions and 24 deletions

View file

@ -210,6 +210,7 @@ func Init() {
}, },
UpdateMode: 0, UpdateMode: 0,
PreferDatabaseCredentials: false, PreferDatabaseCredentials: false,
SkipNaturalKeysValidation: false,
}, },
HTTPDConfig: httpd.Conf{ HTTPDConfig: httpd.Conf{
Bindings: []httpd.Binding{defaultHTTPDBinding}, Bindings: []httpd.Binding{defaultHTTPDBinding},
@ -889,6 +890,7 @@ func setViperDefaults() {
viper.SetDefault("data_provider.password_hashing.argon2_options.iterations", globalConf.ProviderConf.PasswordHashing.Argon2Options.Iterations) viper.SetDefault("data_provider.password_hashing.argon2_options.iterations", globalConf.ProviderConf.PasswordHashing.Argon2Options.Iterations)
viper.SetDefault("data_provider.password_hashing.argon2_options.parallelism", globalConf.ProviderConf.PasswordHashing.Argon2Options.Parallelism) viper.SetDefault("data_provider.password_hashing.argon2_options.parallelism", globalConf.ProviderConf.PasswordHashing.Argon2Options.Parallelism)
viper.SetDefault("data_provider.update_mode", globalConf.ProviderConf.UpdateMode) viper.SetDefault("data_provider.update_mode", globalConf.ProviderConf.UpdateMode)
viper.SetDefault("data_provider.skip_natural_keys_validation", globalConf.ProviderConf.SkipNaturalKeysValidation)
viper.SetDefault("httpd.templates_path", globalConf.HTTPDConfig.TemplatesPath) viper.SetDefault("httpd.templates_path", globalConf.HTTPDConfig.TemplatesPath)
viper.SetDefault("httpd.static_files_path", globalConf.HTTPDConfig.StaticFilesPath) viper.SetDefault("httpd.static_files_path", globalConf.HTTPDConfig.StaticFilesPath)
viper.SetDefault("httpd.backups_path", globalConf.HTTPDConfig.BackupsPath) viper.SetDefault("httpd.backups_path", globalConf.HTTPDConfig.BackupsPath)

View file

@ -62,6 +62,17 @@ type Admin struct {
AdditionalInfo string `json:"additional_info,omitempty"` AdditionalInfo string `json:"additional_info,omitempty"`
} }
func (a *Admin) checkPassword() error {
if a.Password != "" && !strings.HasPrefix(a.Password, argonPwdPrefix) {
pwd, err := argon2id.CreateHash(a.Password, argon2Params)
if err != nil {
return err
}
a.Password = pwd
}
return nil
}
func (a *Admin) validate() error { func (a *Admin) validate() error {
if a.Username == "" { if a.Username == "" {
return &ValidationError{err: "username is mandatory"} return &ValidationError{err: "username is mandatory"}
@ -69,15 +80,11 @@ func (a *Admin) validate() error {
if a.Password == "" { if a.Password == "" {
return &ValidationError{err: "please set a password"} return &ValidationError{err: "please set a password"}
} }
if !usernameRegex.MatchString(a.Username) { if !config.SkipNaturalKeysValidation && !usernameRegex.MatchString(a.Username) {
return &ValidationError{err: fmt.Sprintf("username %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~", a.Username)} return &ValidationError{err: fmt.Sprintf("username %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~", a.Username)}
} }
if a.Password != "" && !strings.HasPrefix(a.Password, argonPwdPrefix) { if err := a.checkPassword(); err != nil {
pwd, err := argon2id.CreateHash(a.Password, argon2Params) return err
if err != nil {
return err
}
a.Password = pwd
} }
a.Permissions = utils.RemoveDuplicates(a.Permissions) a.Permissions = utils.RemoveDuplicates(a.Permissions)
if len(a.Permissions) == 0 { if len(a.Permissions) == 0 {

View file

@ -271,6 +271,10 @@ type Config struct {
// Cloud Storage) should be stored in the database instead of in the directory specified by // Cloud Storage) should be stored in the database instead of in the directory specified by
// CredentialsPath. // CredentialsPath.
PreferDatabaseCredentials bool `json:"prefer_database_credentials" mapstructure:"prefer_database_credentials"` PreferDatabaseCredentials bool `json:"prefer_database_credentials" mapstructure:"prefer_database_credentials"`
// SkipNaturalKeysValidation allows to use any UTF-8 character for natural keys as username, admin name,
// folder name. These keys are used in URIs for REST API and Web admin. By default only unreserved URI
// characters are allowed: ALPHA / DIGIT / "-" / "." / "_" / "~".
SkipNaturalKeysValidation bool `json:"skip_natural_keys_validation" mapstructure:"skip_natural_keys_validation"`
} }
// BackupData defines the structure for the backup/restore files // BackupData defines the structure for the backup/restore files
@ -1362,7 +1366,7 @@ func validateBaseParams(user *User) error {
if user.Username == "" { if user.Username == "" {
return &ValidationError{err: "username is mandatory"} return &ValidationError{err: "username is mandatory"}
} }
if !usernameRegex.MatchString(user.Username) { if !config.SkipNaturalKeysValidation && !usernameRegex.MatchString(user.Username) {
return &ValidationError{err: fmt.Sprintf("username %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~", return &ValidationError{err: fmt.Sprintf("username %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~",
user.Username)} user.Username)}
} }
@ -1395,7 +1399,7 @@ func ValidateFolder(folder *vfs.BaseVirtualFolder) error {
if folder.Name == "" { if folder.Name == "" {
return &ValidationError{err: "folder name is mandatory"} return &ValidationError{err: "folder name is mandatory"}
} }
if !usernameRegex.MatchString(folder.Name) { if !config.SkipNaturalKeysValidation && !usernameRegex.MatchString(folder.Name) {
return &ValidationError{err: fmt.Sprintf("folder name %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~", return &ValidationError{err: fmt.Sprintf("folder name %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~",
folder.Name)} folder.Name)}
} }

View file

@ -188,6 +188,7 @@ The configuration file contains the following sections:
- `iterations`, unsigned integer. The number of iterations over the memory. Default: 1. - `iterations`, unsigned integer. The number of iterations over the memory. Default: 1.
- `parallelism`. unsigned 8 bit integer. The number of threads (or lanes) used by the algorithm. Default: 2. - `parallelism`. unsigned 8 bit integer. The number of threads (or lanes) used by the algorithm. Default: 2.
- `update_mode`, integer. Defines how the database will be initialized/updated. 0 means automatically. 1 means manually using the initprovider sub-command. - `update_mode`, integer. Defines how the database will be initialized/updated. 0 means automatically. 1 means manually using the initprovider sub-command.
- `skip_natural_keys_validation`, boolean. If `true` you can use any UTF-8 character for natural keys as username, admin name, folder name. These keys are used in URIs for REST API and Web admin. If `false` only unreserved URI characters are allowed: ALPHA / DIGIT / "-" / "." / "_" / "~". Default: `false`.
- **"httpd"**, the configuration for the HTTP server used to serve REST API and to expose the built-in web interface - **"httpd"**, the configuration for the HTTP server used to serve REST API and to expose the built-in web interface
- `bindings`, list of structs. Each struct has the following fields: - `bindings`, list of structs. Each struct has the following fields:
- `port`, integer. The port used for serving HTTP requests. Default: 8080. - `port`, integer. The port used for serving HTTP requests. Default: 8080.

View file

@ -2228,6 +2228,65 @@ func TestCloseConnectionAfterUserUpdateDelete(t *testing.T) {
assert.Len(t, common.Connections.GetStats(), 0) assert.Len(t, common.Connections.GetStats(), 0)
} }
func TestSkipNaturalKeysValidation(t *testing.T) {
err := dataprovider.Close()
assert.NoError(t, err)
err = config.LoadConfig(configDir, "")
assert.NoError(t, err)
providerConf := config.GetProviderConf()
providerConf.SkipNaturalKeysValidation = true
err = dataprovider.Initialize(providerConf, configDir, true)
assert.NoError(t, err)
u := getTestUser()
u.Username = "user@example.com"
user, _, err := httpdtest.AddUser(u, http.StatusCreated)
assert.NoError(t, err)
user.AdditionalInfo = "info"
user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
assert.NoError(t, err)
user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK)
assert.NoError(t, err)
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
a := getTestAdmin()
a.Username = "admin@example.com"
admin, _, err := httpdtest.AddAdmin(a, http.StatusCreated)
assert.NoError(t, err)
admin.Email = admin.Username
admin, _, err = httpdtest.UpdateAdmin(admin, http.StatusOK)
assert.NoError(t, err)
admin, _, err = httpdtest.GetAdminByUsername(admin.Username, http.StatusOK)
assert.NoError(t, err)
_, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
assert.NoError(t, err)
f := vfs.BaseVirtualFolder{
Name: "文件夹",
MappedPath: filepath.Clean(os.TempDir()),
}
folder, resp, err := httpdtest.AddFolder(f, http.StatusCreated)
assert.NoError(t, err, string(resp))
folder, resp, err = httpdtest.UpdateFolder(folder, http.StatusOK)
assert.NoError(t, err, string(resp))
folder, resp, err = httpdtest.GetFolderByName(folder.Name, http.StatusOK)
assert.NoError(t, err, string(resp))
_, err = httpdtest.RemoveFolder(folder, http.StatusOK)
assert.NoError(t, err)
err = dataprovider.Close()
assert.NoError(t, err)
err = config.LoadConfig(configDir, "")
assert.NoError(t, err)
providerConf = config.GetProviderConf()
providerConf.CredentialsPath = credentialsPath
err = os.RemoveAll(credentialsPath)
assert.NoError(t, err)
err = dataprovider.Initialize(providerConf, configDir, true)
assert.NoError(t, err)
}
func TestUserBaseDir(t *testing.T) { func TestUserBaseDir(t *testing.T) {
err := dataprovider.Close() err := dataprovider.Close()
assert.NoError(t, err) assert.NoError(t, err)

View file

@ -146,7 +146,8 @@
"parallelism": 2 "parallelism": 2
} }
}, },
"update_mode": 0 "update_mode": 0,
"skip_natural_keys_validation": false
}, },
"httpd": { "httpd": {
"bindings": [ "bindings": [

View file

@ -93,7 +93,7 @@
var table = $('#dataTable').DataTable(); var table = $('#dataTable').DataTable();
table.button('delete:name').enable(false); table.button('delete:name').enable(false);
var username = table.row({ selected: true }).data()[1]; var username = table.row({ selected: true }).data()[1];
var path = '{{.AdminURL}}' + "/" + username; var path = '{{.AdminURL}}' + "/" + fixedEncodeURIComponent(username);
$('#deleteModal').modal('hide'); $('#deleteModal').modal('hide');
$.ajax({ $.ajax({
url: path, url: path,
@ -137,8 +137,8 @@
name: 'edit', name: 'edit',
action: function (e, dt, node, config) { action: function (e, dt, node, config) {
var username = dt.row({ selected: true }).data()[1]; var username = dt.row({ selected: true }).data()[1];
var path = '{{.AdminURL}}' + "/" + username; var path = '{{.AdminURL}}' + "/" + fixedEncodeURIComponent(username);
window.location.href = encodeURI(path); window.location.href = path;
}, },
enabled: false enabled: false
}; };

View file

@ -221,6 +221,14 @@
<!-- Custom scripts for all pages--> <!-- Custom scripts for all pages-->
<script src="/static/js/sb-admin-2.min.js"></script> <script src="/static/js/sb-admin-2.min.js"></script>
<script type="text/javascript">
function fixedEncodeURIComponent(str) {
return encodeURIComponent(str).replace(/[!'()*]/g, function (c) {
return '%' + c.charCodeAt(0).toString(16);
});
}
</script>
<!-- Page level plugins --> <!-- Page level plugins -->
{{block "extra_js" .}}{{end}} {{block "extra_js" .}}{{end}}

View file

@ -91,10 +91,10 @@ function deleteAction() {
var table = $('#dataTable').DataTable(); var table = $('#dataTable').DataTable();
table.button('delete:name').enable(false); table.button('delete:name').enable(false);
var folderName = table.row({ selected: true }).data()[0]; var folderName = table.row({ selected: true }).data()[0];
var path = '{{.FolderURL}}' + "/" + folderName; var path = '{{.FolderURL}}' + "/" + fixedEncodeURIComponent(folderName);
$('#deleteModal').modal('hide'); $('#deleteModal').modal('hide');
$.ajax({ $.ajax({
url: encodeURI(path), url: path,
type: 'DELETE', type: 'DELETE',
dataType: 'json', dataType: 'json',
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'}, headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
@ -135,8 +135,8 @@ function deleteAction() {
name: 'edit', name: 'edit',
action: function (e, dt, node, config) { action: function (e, dt, node, config) {
var folderName = table.row({ selected: true }).data()[0]; var folderName = table.row({ selected: true }).data()[0];
var path = '{{.FolderURL}}' + "/" + folderName; var path = '{{.FolderURL}}' + "/" + fixedEncodeURIComponent(folderName);
window.location.href = encodeURI(path); window.location.href = path;
}, },
enabled: false enabled: false
}; };
@ -148,7 +148,7 @@ function deleteAction() {
var selectedRows = table.rows({ selected: true }).count(); var selectedRows = table.rows({ selected: true }).count();
if (selectedRows == 1){ if (selectedRows == 1){
var folderName = table.row({ selected: true }).data()[0]; var folderName = table.row({ selected: true }).data()[0];
var path = '{{.FolderTemplateURL}}' + "?from=" + encodeURIComponent(folderName); var path = '{{.FolderTemplateURL}}' + "?from=" + fixedEncodeURIComponent(folderName);
window.location.href = path; window.location.href = path;
} else { } else {
window.location.href = '{{.FolderTemplateURL}}'; window.location.href = '{{.FolderTemplateURL}}';

View file

@ -99,10 +99,10 @@
var table = $('#dataTable').DataTable(); var table = $('#dataTable').DataTable();
table.button('delete:name').enable(false); table.button('delete:name').enable(false);
var username = table.row({ selected: true }).data()[1]; var username = table.row({ selected: true }).data()[1];
var path = '{{.UserURL}}' + "/" + username; var path = '{{.UserURL}}' + "/" + fixedEncodeURIComponent(username);
$('#deleteModal').modal('hide'); $('#deleteModal').modal('hide');
$.ajax({ $.ajax({
url: encodeURI(path), url: path,
type: 'DELETE', type: 'DELETE',
dataType: 'json', dataType: 'json',
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'}, headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
@ -143,8 +143,8 @@
name: 'edit', name: 'edit',
action: function (e, dt, node, config) { action: function (e, dt, node, config) {
var username = dt.row({ selected: true }).data()[1]; var username = dt.row({ selected: true }).data()[1];
var path = '{{.UserURL}}' + "/" + username; var path = '{{.UserURL}}' + "/" + fixedEncodeURIComponent(username);
window.location.href = encodeURI(path); window.location.href = path;
}, },
enabled: false enabled: false
}; };
@ -154,7 +154,7 @@
name: 'clone', name: 'clone',
action: function (e, dt, node, config) { action: function (e, dt, node, config) {
var username = dt.row({ selected: true }).data()[1]; var username = dt.row({ selected: true }).data()[1];
var path = '{{.UserURL}}' + "?clone-from=" + encodeURIComponent(username); var path = '{{.UserURL}}' + "?clone-from=" + fixedEncodeURIComponent(username);
window.location.href = path; window.location.href = path;
}, },
enabled: false enabled: false
@ -167,7 +167,7 @@
var selectedRows = table.rows({ selected: true }).count(); var selectedRows = table.rows({ selected: true }).count();
if (selectedRows == 1){ if (selectedRows == 1){
var username = dt.row({ selected: true }).data()[1]; var username = dt.row({ selected: true }).data()[1];
var path = '{{.UserTemplateURL}}' + "?from=" + encodeURIComponent(username); var path = '{{.UserTemplateURL}}' + "?from=" + fixedEncodeURIComponent(username);
window.location.href = path; window.location.href = path;
} else { } else {
window.location.href = '{{.UserTemplateURL}}'; window.location.href = '{{.UserTemplateURL}}';