Files
ResolveSpec/pkg/common/cors.go
T
Hein ca89cb8a73 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.
2026-10-01 13:46:01 +02:00

230 lines
6.8 KiB
Go

package common
import (
"fmt"
"strings"
"github.com/bitechdev/ResolveSpec/pkg/config"
)
// CORSConfig holds CORS configuration
type CORSConfig struct {
AllowedOrigins []string
AllowedMethods []string
AllowedHeaders []string
MaxAge int
}
// DefaultCORSConfig returns a default CORS configuration suitable for HeadSpec
func DefaultCORSConfig() CORSConfig {
configManager := config.GetConfigManager()
cfg, _ := configManager.GetConfig()
hosts := make([]string, 0)
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()
for i := range cfg.Servers.Instances {
server := cfg.Servers.Instances[i]
if server.Port == 0 {
continue
}
hosts = append(hosts, server.ExternalURLs...)
hosts = append(hosts, fmt.Sprintf("http://%s:%d", server.Host, server.Port))
hosts = append(hosts, fmt.Sprintf("https://%s:%d", server.Host, server.Port))
hosts = append(hosts, fmt.Sprintf("http://%s:%d", "localhost", server.Port))
for _, ip := range ipsList {
hosts = append(hosts, fmt.Sprintf("http://%s:%d", ip.String(), server.Port))
hosts = append(hosts, fmt.Sprintf("https://%s:%d", ip.String(), server.Port))
}
}
return CORSConfig{
AllowedOrigins: hosts,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: GetHeadSpecHeaders(),
MaxAge: 86400, // 24 hours
}
}
// GetHeadSpecHeaders returns all headers used by HeadSpec
func GetHeadSpecHeaders() []string {
return []string{
// Standard headers
"Content-Type",
"Authorization",
"Accept",
"Accept-Language",
"Content-Language",
// Field Selection
"X-Select-Fields",
"X-Not-Select-Fields",
"X-Clean-JSON",
// Filtering & Search
"X-FieldFilter-*",
"X-SearchFilter-*",
"X-SearchOp-*",
"X-SearchOr-*",
"X-SearchAnd-*",
"X-SearchCols",
"X-Custom-SQL-W",
"X-Custom-SQL-W-*",
"X-Custom-SQL-Or",
"X-Custom-SQL-Or-*",
// Joins & Relations
"X-Preload",
"X-Preload-*",
"X-Expand",
"X-Expand-*",
"X-Custom-SQL-Join",
"X-Custom-SQL-Join-*",
// Sorting & Pagination
"X-Sort",
"X-Sort-*",
"X-Limit",
"X-Offset",
"X-Cursor-Forward",
"X-Cursor-Backward",
// Advanced Features
"X-AdvSQL-*",
"X-CQL-Sel-*",
"X-Distinct",
"X-SkipCount",
"X-SkipCache",
"X-Fetch-RowNumber",
"X-PKRow",
// Response Format
"X-SimpleAPI",
"X-DetailAPI",
"X-Syncfusion",
"X-Single-Record-As-Object",
// Transaction Control
"X-Transaction-Atomic",
// X-Files - comprehensive JSON configuration
"X-Files",
}
}
// 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) {
if !Hardening().CORSStrictOrigins {
setCORSHeadersLegacy(w, r, config)
return
}
origin := r.Header("Origin")
if 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
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
}
}
// Set allowed methods
if len(config.AllowedMethods) > 0 {
w.SetHeader("Access-Control-Allow-Methods", strings.Join(config.AllowedMethods, ", "))
}
// 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)
} else if len(config.AllowedHeaders) > 0 {
w.SetHeader("Access-Control-Allow-Headers", strings.Join(config.AllowedHeaders, ", "))
}
// Set max age
if config.MaxAge > 0 {
w.SetHeader("Access-Control-Max-Age", fmt.Sprintf("%d", config.MaxAge))
}
// 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")
}
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, ", "))
}