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
+85 -12
View File
@@ -20,7 +20,15 @@ func DefaultCORSConfig() CORSConfig {
configManager := config.GetConfigManager()
cfg, _ := configManager.GetConfig()
hosts := make([]string, 0)
// hosts = append(hosts, "*")
if cfg == nil {
return CORSConfig{
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: GetHeadSpecHeaders(),
MaxAge: 86400,
}
}
// Explicitly configured origins (cors.allowed_origins); "*" allows any origin without credentials
hosts = append(hosts, cfg.CORS.AllowedOrigins...)
_, _, ipsList := config.GetIPs()
@@ -113,25 +121,63 @@ func GetHeadSpecHeaders() []string {
}
}
// SetCORSHeaders sets CORS headers on a response writer
// originAllowed reports whether origin matches config.AllowedOrigins exactly
// (case-insensitive, trailing slash ignored). wildcard is true when the list
// contains "*".
func originAllowed(origin string, allowed []string) (ok bool, wildcard bool) {
norm := strings.ToLower(strings.TrimRight(strings.TrimSpace(origin), "/"))
for _, a := range allowed {
a = strings.ToLower(strings.TrimRight(strings.TrimSpace(a), "/"))
if a == "*" {
wildcard = true
continue
}
if a != "" && a == norm {
return true, wildcard
}
}
return false, wildcard
}
// SetCORSHeaders sets CORS headers on a response writer.
//
// The request Origin is only reflected (and credentials only allowed) when it
// is listed in config.AllowedOrigins. A "*" entry allows any origin but never
// with credentials. Unlisted origins get no CORS headers, so browsers block
// the cross-origin read.
func SetCORSHeaders(w ResponseWriter, r Request, config CORSConfig) {
// Reflect the request origin; fall back to wildcard only when no origin is present
if !Hardening().CORSStrictOrigins {
setCORSHeadersLegacy(w, r, config)
return
}
origin := r.Header("Origin")
if origin == "" {
origin = "*"
// Not a cross-origin browser request; nothing to protect.
w.SetHeader("Access-Control-Allow-Origin", "*")
} else {
// Vary must be set so caches don't serve one origin's response to another
httpW := w.UnderlyingResponseWriter()
httpW.Header().Set("Vary", "Origin")
w.UnderlyingResponseWriter().Header().Set("Vary", "Origin")
ok, wildcard := originAllowed(origin, config.AllowedOrigins)
switch {
case ok:
w.SetHeader("Access-Control-Allow-Origin", origin)
w.SetHeader("Access-Control-Allow-Credentials", "true")
case wildcard:
w.SetHeader("Access-Control-Allow-Origin", "*")
default:
return
}
}
w.SetHeader("Access-Control-Allow-Origin", origin)
// Set allowed methods
if len(config.AllowedMethods) > 0 {
w.SetHeader("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", "))
}
// Reflect the preflight request headers when present; otherwise use the explicit config list
// The origin is trusted at this point, so reflecting the preflight request
// headers is safe (the config list contains "X-Foo-*" patterns that browsers
// cannot match literally).
requestedHeaders := r.Header("Access-Control-Request-Headers")
if requestedHeaders != "" {
w.SetHeader("Access-Control-Allow-Headers", requestedHeaders)
@@ -144,13 +190,40 @@ func SetCORSHeaders(w ResponseWriter, r Request, config CORSConfig) {
w.SetHeader("Access-Control-Max-Age", fmt.Sprintf("%d", config.MaxAge))
}
// Allow credentials only when a specific origin is reflected (not wildcard)
// Expose headers that clients can read (fresh slice: avoid appending into
// config.AllowedHeaders' backing array)
exposeHeaders := make([]string, 0, len(config.AllowedHeaders)+3)
exposeHeaders = append(exposeHeaders, config.AllowedHeaders...)
exposeHeaders = append(exposeHeaders, "Content-Range", "X-Api-Range-Total", "X-Api-Range-Size")
w.SetHeader("Access-Control-Expose-Headers", strings.Join(exposeHeaders, ", "))
}
// setCORSHeadersLegacy is the pre-hardening behaviour (reflect any origin with
// credentials). Used only when hardening.cors_strict_origins is false.
func setCORSHeadersLegacy(w ResponseWriter, r Request, config CORSConfig) {
origin := r.Header("Origin")
if origin == "" {
origin = "*"
} else {
w.UnderlyingResponseWriter().Header().Set("Vary", "Origin")
}
w.SetHeader("Access-Control-Allow-Origin", origin)
if len(config.AllowedMethods) > 0 {
w.SetHeader("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", "))
}
if requested := r.Header("Access-Control-Request-Headers"); requested != "" {
w.SetHeader("Access-Control-Allow-Headers", requested)
} else if len(config.AllowedHeaders) > 0 {
w.SetHeader("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
}
if config.MaxAge > 0 {
w.SetHeader("Access-Control-Max-Age", fmt.Sprintf("%d", config.MaxAge))
}
if origin != "*" {
w.SetHeader("Access-Control-Allow-Credentials", "true")
}
// Expose headers that clients can read
exposeHeaders := config.AllowedHeaders
exposeHeaders := make([]string, 0, len(config.AllowedHeaders)+3)
exposeHeaders = append(exposeHeaders, config.AllowedHeaders...)
exposeHeaders = append(exposeHeaders, "Content-Range", "X-Api-Range-Total", "X-Api-Range-Size")
w.SetHeader("Access-Control-Expose-Headers", strings.Join(exposeHeaders, ", "))
}