mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-02 11:31:57 +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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user