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:
Hein
2026-10-01 13:46:01 +02:00
parent c1153522f2
commit ca89cb8a73
14 changed files with 552 additions and 87 deletions
+60 -3
View File
@@ -3,6 +3,7 @@ package common
import (
"fmt"
"reflect"
"regexp"
"sort"
"strings"
@@ -105,7 +106,7 @@ func (v *ColumnValidator) ValidateColumn(column string) error {
}
// Allow columns prefixed with "cql" (case insensitive) for computed columns
if strings.HasPrefix(strings.ToLower(column), "cql") {
if lc := strings.ToLower(column); strings.HasPrefix(lc, "cql") && (!Hardening().SortStrict || reCQLColumn.MatchString(lc)) {
return nil
}
@@ -275,8 +276,14 @@ func (v *ColumnValidator) FilterRequestOptions(options RequestOptions) RequestOp
validSorts = append(validSorts, sort)
} else {
foundJoin := false
strictSort := Hardening().SortStrict
for _, j := range options.JoinAliases {
if strings.Contains(sort.Column, j) {
if strictSort {
if isJoinAliasColumn(sort.Column, j) {
foundJoin = true
break
}
} else if strings.Contains(sort.Column, j) {
foundJoin = true
break
}
@@ -287,7 +294,7 @@ func (v *ColumnValidator) FilterRequestOptions(options RequestOptions) RequestOp
}
if strings.HasPrefix(sort.Column, "(") && strings.HasSuffix(sort.Column, ")") {
// Allow sort by expression/subquery, but validate for security
if IsSafeSortExpression(sort.Column) {
if IsSafeSortExpression(sort.Column) && (!strictSort || isSortExpressionRestricted(sort.Column)) {
validSorts = append(validSorts, sort)
} else {
logger.Warn("Unsafe sort expression '%s' removed", sort.Column)
@@ -376,6 +383,56 @@ func (v *ColumnValidator) FilterRequestOptions(options RequestOptions) RequestOp
return filtered
}
var reCQLColumn = regexp.MustCompile(`^cql[a-z0-9_]*$`)
var (
reJoinColumnIdent = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
)
// isJoinAliasColumn reports whether col is exactly "<alias>.<identifier>".
// An empty alias never matches.
func isJoinAliasColumn(col, alias string) bool {
if alias == "" || !strings.HasPrefix(col, alias+".") {
return false
}
return reJoinColumnIdent.MatchString(col[len(alias)+1:])
}
// isSortExpressionRestricted applies the hardened checks to a client sort
// expression. Subqueries and ordinary functions are allowed; dangerous
// functions/catalogs (pg_sleep, pg_*, dblink, ...) and unbalanced parentheses are
// rejected. Subqueries are blocked only when hardening.sql_block_subqueries is on.
func isSortExpressionRestricted(expr string) bool {
stripped, ok := stripSQLLiterals(expr)
if !ok {
return false
}
depth := 0
for i := 0; i < len(stripped); i++ {
switch stripped[i] {
case '(':
depth++
case ')':
depth--
if depth < 0 {
return false
}
}
}
if depth != 0 {
return false
}
if m := reStrictDangerousFunc.FindString(stripped); m != "" {
logger.Warn("Forbidden function '%s' in sort expression: %s", m, expr)
return false
}
if Hardening().SQLBlockSubqueries && reStrictSubquery.MatchString(stripped) {
logger.Warn("Subquery in sort expression rejected: %s", expr)
return false
}
return true
}
// IsSafeSortExpression validates that a sort expression (enclosed in brackets) is safe
// and doesn't contain SQL injection attempts or dangerous commands
func IsSafeSortExpression(expr string) bool {