From 56be82f3f33b160ffb114905174072430f020432 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 17 Sep 2026 20:13:03 +0200 Subject: [PATCH] feat(03-02): add allow-listed database-default order helper - Reject identifiers and directions outside the caller allow-list - Emit ordinary ORDER BY without COLLATE so ICU pl-PL applies Co-authored-by: Cursor --- lagoon/order.go | 46 ++++++++++++++++++++++++++++++++++++++++++++ lagoon/order_test.go | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 lagoon/order.go create mode 100644 lagoon/order_test.go diff --git a/lagoon/order.go b/lagoon/order.go new file mode 100644 index 0000000..231b7d5 --- /dev/null +++ b/lagoon/order.go @@ -0,0 +1,46 @@ +package lagoon + +import ( + "fmt" + "strings" + + "gorm.io/gorm" +) + +// OrderBy appends a database-default ORDER BY for an allow-listed qualified +// column. Identifiers and directions are never taken from untrusted input: +// column must match allowed exactly, and dir must be asc or desc. No COLLATE +// is emitted; Postgres ICU pl-PL is the database default (CheckLocale). +func OrderBy(db *gorm.DB, column, dir string, allowed []string) (*gorm.DB, error) { + if db == nil { + return nil, fmt.Errorf("lagoon: gorm db is nil") + } + clause, err := orderClause(column, dir, allowed) + if err != nil { + return nil, err + } + return db.Order(clause), nil +} + +func orderClause(column, dir string, allowed []string) (string, error) { + if !allowListed(column, allowed) { + return "", fmt.Errorf("lagoon: order column %q is not allow-listed", column) + } + switch strings.ToLower(strings.TrimSpace(dir)) { + case "asc": + return column + " ASC", nil + case "desc": + return column + " DESC", nil + default: + return "", fmt.Errorf("lagoon: order direction %q is not allow-listed", dir) + } +} + +func allowListed(column string, allowed []string) bool { + for _, a := range allowed { + if a == column { + return true + } + } + return false +} diff --git a/lagoon/order_test.go b/lagoon/order_test.go new file mode 100644 index 0000000..422acbc --- /dev/null +++ b/lagoon/order_test.go @@ -0,0 +1,39 @@ +package lagoon + +import ( + "strings" + "testing" +) + +func TestOrderClauseAllowList(t *testing.T) { + allowed := []string{"golem15_fonoteka_genres.name", "items.title"} + + got, err := orderClause("golem15_fonoteka_genres.name", "asc", allowed) + if err != nil { + t.Fatal(err) + } + if got != "golem15_fonoteka_genres.name ASC" { + t.Fatalf("got %q", got) + } + if strings.Contains(strings.ToLower(got), "collate") { + t.Fatalf("must not emit COLLATE: %q", got) + } + + got, err = orderClause("items.title", "DESC", allowed) + if err != nil { + t.Fatal(err) + } + if got != "items.title DESC" { + t.Fatalf("got %q", got) + } + + if _, err := orderClause("golem15_fonoteka_genres.name;drop table x", "asc", allowed); err == nil { + t.Fatal("want reject unknown column") + } + if _, err := orderClause("golem15_fonoteka_genres.name", "ascending", allowed); err == nil { + t.Fatal("want reject unknown direction") + } + if _, err := OrderBy(nil, "items.title", "asc", allowed); err == nil { + t.Fatal("want nil db error") + } +}