mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-07 13:56:29 +00:00
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.
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user