diff --git a/CHANGELOG.md b/CHANGELOG.md index 321d34c9..1c395733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,13 +23,13 @@ - Added cron expression macros ([#3132](https://github.com/pocketbase/pocketbase/issues/3132)): ``` - "@yearly": "0 0 1 1 *" - "@annually": "0 0 1 1 *" - "@monthly": "0 0 1 * *" - "@weekly": "0 0 * * 0" - "@daily": "0 0 * * *" - "@midnight": "0 0 * * *" - "@hourly": "0 * * * *" + @yearly - "0 0 1 1 *" + @annually - "0 0 1 1 *" + @monthly - "0 0 1 * *" + @weekly - "0 0 * * 0" + @daily - "0 0 * * *" + @midnight - "0 0 * * *" + @hourly - "0 * * * *" ``` - (@todo update docs examples) To minimize the footguns with `Dao.FindFirstRecordByFilter()` and `Dao.FindRecordsByFilter()`, the functions now supports an optional placeholder params argument that is safe to be populated with untrusted user input. @@ -55,6 +55,13 @@ - Added JSVM `$mails.*` binds for the corresponding Go [mails package](https://pkg.go.dev/github.com/pocketbase/pocketbase/mails) functions. +- Added JSVM helper crypto primitives under the `$security.*` namespace: + ```js + $security.md5(text) + $security.sha256(text) + $security.sha512(text) + ``` + - Fill the `LastVerificationSentAt` and `LastResetSentAt` fields only after a successfull email send ([#3121](https://github.com/pocketbase/pocketbase/issues/3121)). - Skip API `fields` json transformations for non 20x responses ([#3176](https://github.com/pocketbase/pocketbase/issues/3176)). diff --git a/plugins/jsvm/binds.go b/plugins/jsvm/binds.go index 8623819e..39b0862e 100644 --- a/plugins/jsvm/binds.go +++ b/plugins/jsvm/binds.go @@ -450,6 +450,11 @@ func securityBinds(vm *goja.Runtime) { obj := vm.NewObject() vm.Set("$security", obj) + // crypto + obj.Set("md5", security.MD5) + obj.Set("sha256", security.SHA256) + obj.Set("sha512", security.SHA512) + // random obj.Set("randomString", security.RandomString) obj.Set("randomStringWithAlphabet", security.RandomStringWithAlphabet) diff --git a/plugins/jsvm/binds_test.go b/plugins/jsvm/binds_test.go index 4e69f1d7..6747884c 100644 --- a/plugins/jsvm/binds_test.go +++ b/plugins/jsvm/binds_test.go @@ -622,7 +622,40 @@ func TestSecurityBindsCount(t *testing.T) { vm := goja.New() securityBinds(vm) - testBindsCount(vm, "$security", 9, t) + testBindsCount(vm, "$security", 12, t) +} + +func TestSecurityCryptoBinds(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + vm := goja.New() + baseBinds(vm) + securityBinds(vm) + + sceneraios := []struct { + js string + expected string + }{ + {`$security.md5("123")`, "202cb962ac59075b964b07152d234b70"}, + {`$security.sha256("123")`, "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"}, + {`$security.sha512("123")`, "3c9909afec25354d551dae21590bb26e38d53f2173b8d3dc3eee4c047e7ab1c1eb8b85103e3be7ba613b31bb5c9c36214dc9f14a42fd7a2fdb84856bca5c44c2"}, + } + + for _, s := range sceneraios { + t.Run(s.js, func(t *testing.T) { + result, err := vm.RunString(s.js) + if err != nil { + t.Fatalf("Failed to execute js script, got %v", err) + } + + v, _ := result.Export().(string) + + if v != s.expected { + t.Fatalf("Expected %v \ngot \n%v", s.expected, v) + } + }) + } } func TestSecurityRandomStringBinds(t *testing.T) { @@ -644,16 +677,18 @@ func TestSecurityRandomStringBinds(t *testing.T) { } for _, s := range sceneraios { - result, err := vm.RunString(s.js) - if err != nil { - t.Fatalf("[%s] Failed to execute js script, got %v", s.js, err) - } + t.Run(s.js, func(t *testing.T) { + result, err := vm.RunString(s.js) + if err != nil { + t.Fatalf("Failed to execute js script, got %v", err) + } - v, _ := result.Export().(string) + v, _ := result.Export().(string) - if len(v) != s.length { - t.Fatalf("[%s] Expected %d length string, \ngot \n%v", s.js, s.length, v) - } + if len(v) != s.length { + t.Fatalf("Expected %d length string, \ngot \n%v", s.length, v) + } + }) } } @@ -684,16 +719,18 @@ func TestSecurityJWTBinds(t *testing.T) { } for _, s := range sceneraios { - result, err := vm.RunString(s.js) - if err != nil { - t.Fatalf("[%s] Failed to execute js script, got %v", s.js, err) - } + t.Run(s.js, func(t *testing.T) { + result, err := vm.RunString(s.js) + if err != nil { + t.Fatalf("Failed to execute js script, got %v", err) + } - raw, _ := json.Marshal(result.Export()) + raw, _ := json.Marshal(result.Export()) - if string(raw) != s.expected { - t.Fatalf("[%s] Expected \n%s, \ngot \n%s", s.js, s.expected, raw) - } + if string(raw) != s.expected { + t.Fatalf("Expected \n%s, \ngot \n%s", s.expected, raw) + } + }) } } diff --git a/tools/security/crypto.go b/tools/security/crypto.go new file mode 100644 index 00000000..c5389e9b --- /dev/null +++ b/tools/security/crypto.go @@ -0,0 +1,41 @@ +package security + +import ( + "crypto/md5" + "crypto/sha256" + "crypto/sha512" + "encoding/base64" + "fmt" + "strings" +) + +// S256Challenge creates base64 encoded sha256 challenge string derived from code. +// The padding of the result base64 string is stripped per [RFC 7636]. +// +// [RFC 7636]: https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 +func S256Challenge(code string) string { + h := sha256.New() + h.Write([]byte(code)) + return strings.TrimRight(base64.URLEncoding.EncodeToString(h.Sum(nil)), "=") +} + +// MD5 creates md5 hash from the provided plain text. +func MD5(text string) string { + h := md5.New() + h.Write([]byte(text)) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// SHA256 creates sha256 hash as defined in FIPS 180-4 from the provided text. +func SHA256(text string) string { + h := sha256.New() + h.Write([]byte(text)) + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// SHA512 creates sha512 hash as defined in FIPS 180-4 from the provided text. +func SHA512(text string) string { + h := sha512.New() + h.Write([]byte(text)) + return fmt.Sprintf("%x", h.Sum(nil)) +} diff --git a/tools/security/crypto_test.go b/tools/security/crypto_test.go new file mode 100644 index 00000000..cfd332f4 --- /dev/null +++ b/tools/security/crypto_test.go @@ -0,0 +1,87 @@ +package security_test + +import ( + "testing" + + "github.com/pocketbase/pocketbase/tools/security" +) + +func TestS256Challenge(t *testing.T) { + scenarios := []struct { + code string + expected string + }{ + {"", "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"}, + {"123", "pmWkWSBCL51Bfkhn79xPuKBKHz__H6B-mY6G9_eieuM"}, + } + + for _, s := range scenarios { + t.Run(s.code, func(t *testing.T) { + result := security.S256Challenge(s.code) + + if result != s.expected { + t.Fatalf("Expected %q, got %q", s.expected, result) + } + }) + } +} + +func TestMD5(t *testing.T) { + scenarios := []struct { + code string + expected string + }{ + {"", "d41d8cd98f00b204e9800998ecf8427e"}, + {"123", "202cb962ac59075b964b07152d234b70"}, + } + + for _, s := range scenarios { + t.Run(s.code, func(t *testing.T) { + result := security.MD5(s.code) + + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) + } + }) + } +} + +func TestSHA256(t *testing.T) { + scenarios := []struct { + code string + expected string + }{ + {"", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}, + {"123", "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"}, + } + + for _, s := range scenarios { + t.Run(s.code, func(t *testing.T) { + result := security.SHA256(s.code) + + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) + } + }) + } +} + +func TestSHA512(t *testing.T) { + scenarios := []struct { + code string + expected string + }{ + {"", "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"}, + {"123", "3c9909afec25354d551dae21590bb26e38d53f2173b8d3dc3eee4c047e7ab1c1eb8b85103e3be7ba613b31bb5c9c36214dc9f14a42fd7a2fdb84856bca5c44c2"}, + } + + for _, s := range scenarios { + t.Run(s.code, func(t *testing.T) { + result := security.SHA512(s.code) + + if result != s.expected { + t.Fatalf("Expected %v, got %v", s.expected, result) + } + }) + } +} diff --git a/tools/security/encrypt.go b/tools/security/encrypt.go index 2d15b703..e0883b70 100644 --- a/tools/security/encrypt.go +++ b/tools/security/encrypt.go @@ -4,22 +4,10 @@ import ( "crypto/aes" "crypto/cipher" crand "crypto/rand" - "crypto/sha256" "encoding/base64" "io" - "strings" ) -// S256Challenge creates base64 encoded sha256 challenge string derived from code. -// The padding of the result base64 string is stripped per [RFC 7636]. -// -// [RFC 7636]: https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 -func S256Challenge(code string) string { - h := sha256.New() - h.Write([]byte(code)) - return strings.TrimRight(base64.URLEncoding.EncodeToString(h.Sum(nil)), "=") -} - // Encrypt encrypts data with key (must be valid 32 char aes key). func Encrypt(data []byte, key string) (string, error) { block, err := aes.NewCipher([]byte(key)) diff --git a/tools/security/encrypt_test.go b/tools/security/encrypt_test.go index 614601da..ed26e23a 100644 --- a/tools/security/encrypt_test.go +++ b/tools/security/encrypt_test.go @@ -6,24 +6,6 @@ import ( "github.com/pocketbase/pocketbase/tools/security" ) -func TestS256Challenge(t *testing.T) { - scenarios := []struct { - code string - expected string - }{ - {"", "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"}, - {"123", "pmWkWSBCL51Bfkhn79xPuKBKHz__H6B-mY6G9_eieuM"}, - } - - for i, scenario := range scenarios { - result := security.S256Challenge(scenario.code) - - if result != scenario.expected { - t.Errorf("(%d) Expected %q, got %q", i, scenario.expected, result) - } - } -} - func TestEncrypt(t *testing.T) { scenarios := []struct { data string