diff --git a/CHANGELOG.md b/CHANGELOG.md index bb1764de..5467ceed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,13 @@ - Updated the Discord `AuthUser.Name` field to use `global_name` ([#7603](https://github.com/pocketbase/pocketbase/pull/7603); thanks @HansHans135). +- Fixed settings SMTP password clear persistence. + +- Added extra OAuth2 checks when downloading the avatar URL to prevent internal network probing requests in case of a malicious/vulnerable vendor. + - (@todo) Bumped min Go GitHub action version to 1.26.2 because it comes with several [minor security fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.26.2). -- Other minor improvements _(updated `$apis.static` JSVM documentation, added extra OAuth2 checks when downloading the avatar URL to prevent internal network probing requests in case of a malicious/vulnerable vendor, etc.)_. +- Other minor improvements _(updated `$apis.static` JSVM documentation, fixed comment typos, etc.)_. ## v0.36.8 diff --git a/core/settings_model.go b/core/settings_model.go index f7b9b822..e6b33ff3 100644 --- a/core/settings_model.go +++ b/core/settings_model.go @@ -327,6 +327,8 @@ func (s *Settings) MarshalJSON() ([]byte, error) { copy := s.settings s.mu.RUnlock() + copy.SMTP.hidePassword = true + sensitiveFields := []*string{ ©.SMTP.Password, ©.S3.Secret, @@ -346,6 +348,12 @@ func (s *Settings) MarshalJSON() ([]byte, error) { // ------------------------------------------------------------------- type SMTPConfig struct { + // @todo temp workaround to avoid introducing breaking changes; + // consider refactoring and/or normalizing with the other Settings sensitive fields + // + // hidePassword specifies whether to hide the password field from the struct JSON serialization. + hidePassword bool + Enabled bool `form:"enabled" json:"enabled"` Port int `form:"port" json:"port"` Host string `form:"host" json:"host"` @@ -392,6 +400,27 @@ func (c SMTPConfig) Validate() error { ) } +// MarshalJSON implements the [json.Marshaler] interface. +func (c SMTPConfig) MarshalJSON() ([]byte, error) { + type alias SMTPConfig + + if c.hidePassword { + v := struct { + alias + Password string `json:"password,omitempty"` + }{alias(c), ""} + + return json.Marshal(v) + } + + v := struct { + alias + Password string `json:"password"` + }{alias(c), c.Password} + + return json.Marshal(v) +} + // ------------------------------------------------------------------- type S3Config struct { diff --git a/core/settings_model_test.go b/core/settings_model_test.go index 3f489c5b..c78059df 100644 --- a/core/settings_model_test.go +++ b/core/settings_model_test.go @@ -3,6 +3,7 @@ package core_test import ( "encoding/json" "fmt" + "os" "strings" "testing" "time" @@ -10,6 +11,7 @@ import ( "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tests" "github.com/pocketbase/pocketbase/tools/mailer" + "github.com/pocketbase/pocketbase/tools/security" ) func TestSettingsDelete(t *testing.T) { @@ -24,6 +26,72 @@ func TestSettingsDelete(t *testing.T) { } } +func TestSettings_DBExport(t *testing.T) { + scenarios := []struct { + name string + encryption bool + }{ + {"no encryption", false}, + {"with encryption", true}, + } + + encryptionKey := strings.Repeat("a", 32) + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + originalEnv := os.Getenv(app.EncryptionEnv()) + defer func() { + os.Setenv(app.EncryptionEnv(), originalEnv) + }() + + settings := &core.Settings{} + settings.Meta.AppName = "test_app_name" + settings.Logs.MaxDays = 123 + settings.SMTP.Host = "smtp_host" + settings.SMTP.Username = "smtp_username" + settings.SMTP.Password = "" // ensures that empty password is exported + settings.S3.Endpoint = "s3_endpoint" + settings.S3.Secret = "s3_secret" + settings.Backups.Cron = "* * * * *" + settings.Backups.S3.Enabled = true + settings.Backups.S3.Secret = "" + settings.Batch.Timeout = 15 + settings.RateLimits.Enabled = true + settings.TrustedProxy.UseLeftmostIP = true + + if s.encryption { + os.Setenv(app.EncryptionEnv(), encryptionKey) + } + + export, err := settings.DBExport(app) + if err != nil { + t.Fatal(err) + } + + var valueStr string + + if s.encryption { + decrypted, err := security.Decrypt(export["value"].(string), encryptionKey) + if err != nil { + t.Fatalf("failed to decrypt test value: %v", err) + } + + valueStr = string(decrypted) + } else { + valueStr = string(export["value"].([]byte)) + } + + expected := `{"smtp":{"enabled":false,"port":0,"host":"smtp_host","username":"smtp_username","authMethod":"","tls":false,"localName":"","password":""},"backups":{"cron":"* * * * *","cronMaxKeep":0,"s3":{"enabled":true,"bucket":"","region":"","endpoint":"","accessKey":"","forcePathStyle":false}},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"s3_endpoint","accessKey":"","secret":"s3_secret","forcePathStyle":false},"meta":{"appName":"test_app_name","appURL":"","senderName":"","senderAddress":"","hideControls":false},"rateLimits":{"rules":[],"enabled":true},"trustedProxy":{"headers":[],"useLeftmostIP":true},"batch":{"enabled":false,"maxRequests":0,"timeout":15,"maxBodySize":0},"logs":{"maxDays":123,"minLevel":0,"logIP":false,"logAuthId":false}}` + if valueStr != expected { + t.Fatalf("Expected exported settings\n%s\ngot\n%s", expected, valueStr) + } + }) + } +} + func TestSettingsMerge(t *testing.T) { s1 := &core.Settings{} s1.Meta.AppURL = "app_url" // should be unset