mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-02 11:31:57 +00:00
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.
122 lines
3.9 KiB
Go
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")
|
|
}
|
|
}
|