From 0adcb91c35a1b318ad761cee4e7c33030162deb3 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 8 Nov 2026 14:51:36 +0100 Subject: [PATCH 1/5] fix: limit sortKey to at most 255 bytes Much bigger sortKeys could crash gotify. --- api/application.go | 15 +++++++++++ api/application_test.go | 58 +++++++++++++++++++++++++++++++++++++++++ fracdex/fracdex.go | 24 ++++++++--------- 3 files changed, 85 insertions(+), 12 deletions(-) diff --git a/api/application.go b/api/application.go index 5313368ba..0ef87ed18 100644 --- a/api/application.go +++ b/api/application.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gotify/server/v3/auth" + "github.com/gotify/server/v3/fracdex" "github.com/gotify/server/v3/model" "github.com/h2non/filetype" "gorm.io/gorm" @@ -94,6 +95,9 @@ type ApplicationParams struct { func (a *ApplicationAPI) CreateApplication(ctx *gin.Context) { applicationParams := ApplicationParams{} if err := ctx.Bind(&applicationParams); err == nil { + if !validateSortKey(ctx, applicationParams.SortKey) { + return + } tokenPublic, tokenPrivate := generateApplicationToken() app := model.Application{ Name: applicationParams.Name, @@ -263,6 +267,9 @@ func (a *ApplicationAPI) UpdateApplication(ctx *gin.Context) { if app != nil && app.UserID == auth.GetUserID(ctx) { applicationParams := ApplicationParams{} if err := ctx.Bind(&applicationParams); err == nil { + if !validateSortKey(ctx, applicationParams.SortKey) { + return + } app.Description = applicationParams.Description app.Name = applicationParams.Name app.DefaultPriority = applicationParams.DefaultPriority @@ -602,6 +609,14 @@ func ValidApplicationImageExt(ext string) bool { } } +func validateSortKey(ctx *gin.Context, sortKey string) bool { + if err := fracdex.ValidateOrderKey(sortKey); err != nil { + ctx.AbortWithError(400, err) + return false + } + return true +} + func handleApplicationError(ctx *gin.Context, err error) { if errors.Is(err, gorm.ErrDuplicatedKey) { ctx.AbortWithError(400, errors.New("sort key is not unique")) diff --git a/api/application_test.go b/api/application_test.go index b54f74169..197b9cb0c 100644 --- a/api/application_test.go +++ b/api/application_test.go @@ -103,6 +103,32 @@ func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnEmptyName() } } +func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnTooLongSortKey() { + s.db.User(5) + + test.WithUser(s.ctx, 5) + s.withFormData("name=app&sortKey=" + strings.Repeat("z", 256)) + s.a.CreateApplication(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + if app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) { + assert.Empty(s.T(), app) + } +} + +func (s *ApplicationSuite) Test_CreateApplication_expectBadRequestOnInvalidSortKey() { + s.db.User(5) + + test.WithUser(s.ctx, 5) + s.withFormData("name=app&sortKey=a10") + s.a.CreateApplication(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + if app, err := s.db.GetApplicationsByUser(5); assert.NoError(s.T(), err) { + assert.Empty(s.T(), app) + } +} + func (s *ApplicationSuite) Test_CreateApplication_ignoresReadOnlyPropertiesInParams() { s.db.User(5) @@ -764,6 +790,38 @@ func (s *ApplicationSuite) Test_UpdateApplication_duplicateSortKey() { assert.Equal(s.T(), 400, s.recorder.Code) } +func (s *ApplicationSuite) Test_UpdateApplication_expectBadRequestOnTooLongSortKey() { + user := s.db.User(5) + user.App(1) // sortKey=a0 + + s.withFormData("name=new_name&sortKey=" + strings.Repeat("z", 256)) + test.WithUser(s.ctx, 5) + s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} + + s.a.UpdateApplication(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) { + assert.Equal(s.T(), "a0", app.SortKey) + } +} + +func (s *ApplicationSuite) Test_UpdateApplication_expectBadRequestOnInvalidSortKey() { + user := s.db.User(5) + user.App(1) // sortKey=a0 + + s.withFormData("name=new_name&sortKey=a10") + test.WithUser(s.ctx, 5) + s.ctx.Params = gin.Params{{Key: "id", Value: "1"}} + + s.a.UpdateApplication(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) + if app, err := s.db.GetApplicationByID(1); assert.NoError(s.T(), err) { + assert.Equal(s.T(), "a0", app.SortKey) + } +} + func (s *ApplicationSuite) withFormData(formData string) { s.ctx.Request = httptest.NewRequest("POST", "/token", strings.NewReader(formData)) s.ctx.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") diff --git a/fracdex/fracdex.go b/fracdex/fracdex.go index 9119ef830..e40c29a9a 100644 --- a/fracdex/fracdex.go +++ b/fracdex/fracdex.go @@ -19,17 +19,11 @@ const ( // If b is empty it indicates largest key. // b must be empty string or > a. func KeyBetween(a, b string) (string, error) { - if a != "" { - err := validateOrderKey(a) - if err != nil { - return "", err - } + if err := ValidateOrderKey(a); err != nil { + return "", err } - if b != "" { - err := validateOrderKey(b) - if err != nil { - return "", err - } + if err := ValidateOrderKey(b); err != nil { + return "", err } if a != "" && b != "" && a >= b { return "", fmt.Errorf("%s >= %s", a, b) @@ -192,7 +186,13 @@ func getIntPart(key string) (string, error) { return key[0:intPartLen], nil } -func validateOrderKey(key string) error { +func ValidateOrderKey(key string) error { + if key == "" { + return nil + } + if len(key) > 255 { + return fmt.Errorf("invalid order key: too long") + } if key == smallestInt { return fmt.Errorf("invalid order key: %s", key) } @@ -294,7 +294,7 @@ func Float64Approx(key string) (float64, error) { return 0.0, errors.New("invalid order key") } - err := validateOrderKey(key) + err := ValidateOrderKey(key) if err != nil { return 0.0, err } From 04bbe07fdfa7c90ce4febf3896232cf4c604462a Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 8 Nov 2026 19:54:27 +0100 Subject: [PATCH 2/5] test: fuzz test --- fracdex/fracdex_test.go | 59 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/fracdex/fracdex_test.go b/fracdex/fracdex_test.go index 8417c44d1..7798edab6 100644 --- a/fracdex/fracdex_test.go +++ b/fracdex/fracdex_test.go @@ -116,3 +116,62 @@ func TestToFloat64Approx(t *testing.T) { test("a400", 0.0, "invalid order key: a400") test("a!", 0.0, "invalid order key: a!") } + +func FuzzValidateOrderKey(f *testing.F) { + for _, seed := range [][2]string{ + {"", ""}, + {"a0", "a1"}, + {"Zz", "a0"}, + {"a0V", "a1"}, + {"", "Y00"}, + {"bzz", ""}, + {"b125", "b129"}, + {"zzzzzzzzzzzzzzzzzzzzzzzzzzz", ""}, + {"", "A00000000000000000000000001"}, + {strings.Repeat("z", 255), strings.Repeat("z", 256)}, + {"a0 ", "a0!"}, + } { + f.Add(seed[0], seed[1]) + } + + f.Fuzz(func(t *testing.T, a, b string) { + // Validating must never panic + if ValidateOrderKey(a) != nil || ValidateOrderKey(b) != nil { + return + } + + // Any valid key must be safe to use with other fracdex functions. + checkBetween := func(lo, hi string) { + if lo != "" && hi != "" && lo >= hi { + return + } + mid, err := KeyBetween(lo, hi) + if err != nil { + return + } + assert.NoError(t, ValidateOrderKey(mid), "KeyBetween(%q, %q) = %q", lo, hi, mid) + assert.NotEmpty(t, mid, "KeyBetween(%q, %q)", lo, hi) + if lo != "" { + assert.Greater(t, mid, lo, "KeyBetween(%q, %q) = %q", lo, hi, mid) + } + if hi != "" { + assert.Less(t, mid, hi, "KeyBetween(%q, %q) = %q", lo, hi, mid) + } + } + + checkBetween(a, "") + checkBetween("", a) + checkBetween(b, "") + checkBetween("", b) + checkBetween(a, b) + checkBetween(b, a) + + for _, key := range []string{a, b} { + if key == "" { + continue + } + _, err := Float64Approx(key) + assert.NoError(t, err, "Float64Approx(%q)", key) + } + }) +} From 4fc447c572c6421770241585c58635b2f2879b36 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 8 Nov 2026 19:54:39 +0100 Subject: [PATCH 3/5] fix: panic when providing values outside of base62 --- fracdex/fracdex.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/fracdex/fracdex.go b/fracdex/fracdex.go index e40c29a9a..fc75a1bbd 100644 --- a/fracdex/fracdex.go +++ b/fracdex/fracdex.go @@ -203,6 +203,11 @@ func ValidateOrderKey(key string) error { if err != nil { return err } + for _, c := range key { + if !strings.ContainsRune(base62Digits, c) { + return fmt.Errorf("invalid order key: %s", key) + } + } f := key[len(i):] if strings.HasSuffix(f, "0") { return fmt.Errorf("invalid order key: %s", key) From c80e70c783ce0c0c227e5ce9dc2dca825305593b Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 8 Nov 2026 19:56:13 +0100 Subject: [PATCH 4/5] fix: smallestint is not a valid sort key KeyBetween("", "A" + 25 zeros + "1") returns smallestInt, which ValidateOrderKey rejects because it cannot be decremented so nothing could sort before it. This appends midpoint("", "") = "V" (0.5) as fraction to the sort key. --- fracdex/fracdex.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fracdex/fracdex.go b/fracdex/fracdex.go index fc75a1bbd..0e1647c3a 100644 --- a/fracdex/fracdex.go +++ b/fracdex/fracdex.go @@ -51,6 +51,9 @@ func KeyBetween(a, b string) (string, error) { if res == "" { return "", errors.New("range underflow") } + if res == smallestInt { + return res + midpoint("", ""), nil + } return res, nil } From 7272a93f1c0127752b6bb176539f72e1eccbb353 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 8 Nov 2026 20:00:36 +0100 Subject: [PATCH 5/5] fix: error on too big key between returns --- fracdex/fracdex.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/fracdex/fracdex.go b/fracdex/fracdex.go index 0e1647c3a..77e81dab4 100644 --- a/fracdex/fracdex.go +++ b/fracdex/fracdex.go @@ -12,6 +12,7 @@ const ( base62Digits = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" smallestInt = "A00000000000000000000000000" zero = "a0" + maxKeyLen = 255 ) // KeyBetween returns a key that sorts lexicographically between a and b. @@ -25,6 +26,17 @@ func KeyBetween(a, b string) (string, error) { if err := ValidateOrderKey(b); err != nil { return "", err } + key, err := keyBetweenInternal(a, b) + if err != nil { + return "", err + } + if len(key) > maxKeyLen { + return "", errors.New("range overflow") + } + return key, nil +} + +func keyBetweenInternal(a, b string) (string, error) { if a != "" && b != "" && a >= b { return "", fmt.Errorf("%s >= %s", a, b) } @@ -193,7 +205,7 @@ func ValidateOrderKey(key string) error { if key == "" { return nil } - if len(key) > 255 { + if len(key) > maxKeyLen { return fmt.Errorf("invalid order key: too long") } if key == smallestInt {