Files
ResolveSpec/pkg/common/hardening_test.go
T
Hein ca89cb8a73 fix(common): harden CORS, sort, raw-SQL WHERE and x-custom-sql-or
Add a `hardening` config section (RESOLVESPEC_HARDENING_*) so each
fix can be switched off to restore the previous behaviour:

- cors_strict_origins: only reflect origins listed in
  cors.allowed_origins / server URLs, with credentials; `*` never
  sends credentials; fix shared-slice append of expose headers.
- sort_strict: join aliases must match `alias.identifier` (empty alias
  no longer matches everything); sort expressions reject dangerous
  functions/catalogs; cql* columns must be identifier-safe.
- sql_strict: client raw-SQL fragments must have balanced parens and
  quotes, no comments/`;`/`$$`, DML keywords, dangerous functions or
  system catalogs; a rejected fragment now fails closed ("(1=0)")
  instead of dropping the filter. Subqueries stay allowed unless
  sql_block_subqueries is set.
- x-custom-sql-or is grouped together with the client's own
  conditions (new optional WhereGrouper, implemented for bun and gorm)
  so it can no longer OR past server-side filters.
2026-10-01 13:46:01 +02:00

122 lines
3.9 KiB
Go

package common
import (
"testing"
"github.com/bitechdev/ResolveSpec/pkg/config"
)
func setHardening(t *testing.T, h config.HardeningConfig) {
t.Helper()
old := hardeningProvider
hardeningProvider = func() config.HardeningConfig { return h }
t.Cleanup(func() { hardeningProvider = old })
}
var (
hardOn = config.HardeningConfig{CORSStrictOrigins: true, SortStrict: true, SQLStrict: true}
hardOff = config.HardeningConfig{}
)
func TestSanitizeWhereClause_Strict(t *testing.T) {
setHardening(t, hardOn)
allowed := []string{
"status = 'awaiting update approval'",
"last_update > '2020-01-01'",
"name = 'it''s; fine'",
"(a = 1 or b = 2) and ifblnk(c) = 'x'",
}
for _, w := range allowed {
if got := SanitizeWhereClause(w, ""); got == "" || got == "(1=0)" {
t.Errorf("legitimate clause %q rejected: %q", w, got)
}
}
hostile := []string{
"1=1)) OR ((1=1",
"id = 1 or (select count(*) from pg_shadow) > 0",
"id in (select oid from pg_catalog.pg_class)",
"id = 1 and pg_sleep(5) is not null",
"id = 1; delete/**/from items",
"id = 1 -- x",
"name = 'unterminated",
}
for _, w := range hostile {
if got := SanitizeWhereClause(w, "t"); got != "(1=0)" {
t.Errorf("hostile clause %q not rejected closed: %q", w, got)
}
}
}
func TestSanitizeWhereClause_Subqueries(t *testing.T) {
q := "id in (select l.id from other l where l.x = 1)"
setHardening(t, hardOn)
if got := SanitizeWhereClause(q, "t"); got == "(1=0)" || got == "" {
t.Errorf("subquery should be allowed by default: %q", got)
}
h := hardOn
h.SQLBlockSubqueries = true
setHardening(t, h)
if got := SanitizeWhereClause(q, "t"); got != "(1=0)" {
t.Errorf("subquery should be blocked with sql_block_subqueries: %q", got)
}
}
func TestSanitizeWhereClause_StrictOff_LegacyBehaviour(t *testing.T) {
setHardening(t, hardOff)
if got := SanitizeWhereClause("a = 1 and drop table x", "t"); got != "" {
t.Errorf("legacy fail-open expected empty, got %q", got)
}
}
func TestSortStrict(t *testing.T) {
v := NewColumnValidator(TestModel{})
opts := RequestOptions{
JoinAliases: []string{""},
Sort: []SortOption{{Column: "x.id, (select pg_sleep(10))"}, {Column: "(select pg_sleep(1))"}},
}
setHardening(t, hardOn)
if got := v.FilterRequestOptions(opts).Sort; len(got) != 0 {
t.Errorf("strict: expected no sorts, got %v", got)
}
opts.JoinAliases = []string{"j"}
opts.Sort = []SortOption{{Column: "j.id"}, {Column: "j.id, (select 1)"}, {Column: "(select max(age) from users)"}}
if got := v.FilterRequestOptions(opts).Sort; len(got) != 2 {
t.Errorf("strict: join column and plain subquery sort must be allowed, got %v", got)
}
opts.Sort = []SortOption{{Column: "j.id"}, {Column: "j.id, (select 1)"}}
if got := v.FilterRequestOptions(opts).Sort; len(got) != 1 || got[0].Column != "j.id" {
t.Errorf("strict: expected only j.id, got %v", got)
}
setHardening(t, hardOff)
opts.JoinAliases = []string{""}
opts.Sort = []SortOption{{Column: "x.id, (select pg_sleep(10))"}}
if got := v.FilterRequestOptions(opts).Sort; len(got) != 1 {
t.Errorf("legacy: expected sort kept, got %v", got)
}
}
func TestCQLColumn(t *testing.T) {
v := NewColumnValidator(TestModel{})
setHardening(t, hardOn)
if !v.IsValidColumn("cqlComputed1") || v.IsValidColumn("cql1); drop") {
t.Error("strict cql validation wrong")
}
setHardening(t, hardOff)
if !v.IsValidColumn("cql1); drop") {
t.Error("legacy cql behaviour should be permissive")
}
}
func TestOriginAllowed(t *testing.T) {
list := []string{"https://app.example.com/", "http://localhost:8080"}
if ok, _ := originAllowed("https://APP.example.com", list); !ok {
t.Error("listed origin should match case-insensitively, ignoring trailing slash")
}
if ok, _ := originAllowed("https://evil.example", list); ok {
t.Error("unlisted origin must not match")
}
if ok, wc := originAllowed("https://evil.example", []string{"*"}); ok || !wc {
t.Error("wildcard must be reported separately and never as an exact match")
}
}