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.
230 lines
6.8 KiB
Go
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, ", "))
|
|
}
|