diff --git a/app.go b/app.go index e46fa344..7fa06118 100644 --- a/app.go +++ b/app.go @@ -2,6 +2,7 @@ package forge import ( "context" + "net/http" "os" "time" ) @@ -85,12 +86,37 @@ type AppConfig struct { EnablePprof bool // Enable pprof profiling endpoints at /_/debug/pprof (default: false) PprofPrefix string // URL prefix for pprof endpoints (default: "/_/debug/pprof") + // PprofGuard authorizes access to the pprof endpoints. It is called before + // every pprof request; return false to deny (the caller gets a 404, so the + // endpoints are not discoverable). + // + // Strongly recommended whenever EnablePprof is on outside a local dev loop. + // The endpoints expose heap and goroutine contents, allow a caller to pin a + // CPU for an arbitrary duration, and /cmdline discloses the process argv — + // which commonly carries credentials passed as flags. + // + // Nil means no authorization check. + PprofGuard func(*http.Request) bool + ErrorHandler ErrorHandler // Server HTTPAddress string // Default: ":8080" HTTPTimeout time.Duration // Default: 30s + // MaxRequestBodySize caps request bodies in bytes. Bodies larger than this + // are rejected instead of being buffered, so a single request cannot drive + // unbounded allocation. Default: 10 MiB (router.DefaultMaxRequestBodySize). + // Negative disables the limit; override per route with WithMaxBodySize. + MaxRequestBodySize int64 + + // WebSocketOrigins is the Origin allow-list for WebSocket upgrades. Empty + // means same-origin only — browsers send cookies with upgrades but do not + // apply CORS to them, so an open upgrade endpoint is hijackable by any site + // the user visits. Entries may be "https://app.example.com", "app.example.com", + // "*.example.com", or "*" to allow any origin. + WebSocketOrigins []string + // Shutdown ShutdownTimeout time.Duration // Default: 30s ShutdownSignals []os.Signal // Default: SIGINT, SIGTERM @@ -378,6 +404,40 @@ func WithPprofPrefix(prefix string) AppOption { } } +// WithPprofGuard restricts access to the pprof endpoints. The guard runs before +// every pprof request; returning false yields a 404 so the endpoints stay +// undiscoverable. Implies WithPprof(). +// +// Use this whenever profiling is enabled on anything reachable beyond localhost: +// the endpoints dump heap and goroutine state, let a caller pin a CPU for an +// arbitrary duration, and /cmdline reveals the process argv. +// +// forge.WithPprofGuard(func(r *http.Request) bool { +// return subtle.ConstantTimeCompare( +// []byte(r.Header.Get("X-Debug-Token")), []byte(token)) == 1 +// }) +func WithPprofGuard(guard func(*http.Request) bool) AppOption { + return func(c *AppConfig) { + c.EnablePprof = true + c.PprofGuard = guard + } +} + +// WithMaxRequestBodySize caps request bodies app-wide, in bytes. Defaults to +// 10 MiB; pass a negative value to disable the limit. Override per route with +// WithMaxBodySize. +func WithMaxRequestBodySize(bytes int64) AppOption { + return func(c *AppConfig) { c.MaxRequestBodySize = bytes } +} + +// WithWebSocketOrigins sets the Origin allow-list for WebSocket upgrades. +// Without it only same-origin upgrades are accepted — see AppConfig.WebSocketOrigins. +func WithWebSocketOrigins(origins ...string) AppOption { + return func(c *AppConfig) { + c.WebSocketOrigins = append(c.WebSocketOrigins, origins...) + } +} + // NewApp creates a new Forge application. func NewApp(config AppConfig) App { return newApp(config) diff --git a/app_impl.go b/app_impl.go index 20627516..008c4526 100644 --- a/app_impl.go +++ b/app_impl.go @@ -262,6 +262,22 @@ func newApp(config AppConfig) *app { routerOpts = append(routerOpts, internalrouter.WithHTTPAddress(config.HTTPAddress)) } + // Internal error text is echoed to clients only outside production, where + // it aids debugging; in production a 500 carries no detail. Logs always have + // the full error either way. + shared.SetExposeInternalErrors(config.Environment != "production") + + // Cap request bodies. Zero leaves the router's own default in place. + if config.MaxRequestBodySize != 0 { + routerOpts = append(routerOpts, internalrouter.WithMaxRequestBodySize(config.MaxRequestBodySize)) + } + + // Restrict WebSocket upgrades to the configured origins. When unset the + // router enforces same-origin, which is the safe default. + if len(config.WebSocketOrigins) > 0 { + routerOpts = append(routerOpts, internalrouter.WithWebSocketOrigins(config.WebSocketOrigins...)) + } + // Enable panic recovery to prevent unhandled panics from crashing the server routerOpts = append(routerOpts, internalrouter.WithRecovery()) @@ -952,6 +968,14 @@ func (a *app) setupPprofEndpoints() error { // Single wildcard route handles everything: index page, specific // handlers, and named profiles (heap, goroutine, etc.). if err := a.router.Handle(prefix+"/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Authorize before doing anything else. 404 rather than 403 so the + // endpoints are not discoverable by an unauthorized caller. + if a.config.PprofGuard != nil && !a.config.PprofGuard(r) { + http.NotFound(w, r) + + return + } + // Strip prefix to get the sub-path (e.g. "/heap", "/profile", ""). sub := strings.TrimPrefix(r.URL.Path, prefix) @@ -975,8 +999,16 @@ func (a *app) setupPprofEndpoints() error { return err } + if a.config.PprofGuard == nil { + a.logger.Warn("pprof endpoints enabled without PprofGuard: heap, goroutine and cmdline data are unauthenticated", + F("prefix", prefix), + F("environment", a.config.Environment), + ) + } + a.logger.Info("pprof profiling endpoints enabled", F("prefix", prefix), + F("guarded", a.config.PprofGuard != nil), ) return nil diff --git a/examples/cors_preflight/go.mod b/examples/cors_preflight/go.mod index 027251ff..394e25f9 100644 --- a/examples/cors_preflight/go.mod +++ b/examples/cors_preflight/go.mod @@ -63,7 +63,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect github.com/xraph/vessel v1.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect diff --git a/examples/cors_preflight/go.sum b/examples/cors_preflight/go.sum index 32bc6253..9af4450a 100644 --- a/examples/cors_preflight/go.sum +++ b/examples/cors_preflight/go.sum @@ -274,8 +274,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/examples/di-patterns/go.mod b/examples/di-patterns/go.mod index 84b74903..0e60a3bf 100644 --- a/examples/di-patterns/go.mod +++ b/examples/di-patterns/go.mod @@ -63,7 +63,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect github.com/xraph/vessel v1.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect diff --git a/examples/di-patterns/go.sum b/examples/di-patterns/go.sum index 32bc6253..9af4450a 100644 --- a/examples/di-patterns/go.sum +++ b/examples/di-patterns/go.sum @@ -274,8 +274,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/examples/farp-auto-detection/go.mod b/examples/farp-auto-detection/go.mod index 284b35d0..ec334b86 100644 --- a/examples/farp-auto-detection/go.mod +++ b/examples/farp-auto-detection/go.mod @@ -79,7 +79,7 @@ require ( github.com/xraph/confy v0.5.2 // indirect github.com/xraph/farp v1.3.0 // indirect github.com/xraph/farp/discovery v1.2.0 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect github.com/xraph/vessel v1.0.2 // indirect go.etcd.io/etcd/api/v3 v3.5.17 // indirect go.etcd.io/etcd/client/pkg/v3 v3.5.17 // indirect diff --git a/examples/farp-auto-detection/go.sum b/examples/farp-auto-detection/go.sum index fc1ba61d..ca36f668 100644 --- a/examples/farp-auto-detection/go.sum +++ b/examples/farp-auto-detection/go.sum @@ -296,8 +296,8 @@ github.com/xraph/farp v1.3.0 h1:9eH09TyRglpvyYL2Su4oxcrAaJm6D3Ey4w9vYDVO298= github.com/xraph/farp v1.3.0/go.mod h1:Nlli8WUsxvQL5wXiJqcAn6OsUHBzKJxrl9JLJ9J6Wqo= github.com/xraph/farp/discovery v1.2.0 h1:DGUZUGdYUhFxvAzTdPZc+11m1e95LzzEiyfS5Sh8xYA= github.com/xraph/farp/discovery v1.2.0/go.mod h1:Lx1zYvPRryyzUyVIPidl2XvspeRPRwHPNfPRQWUhRVg= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/examples/farp-discovery-example/go.mod b/examples/farp-discovery-example/go.mod index 585ed8e5..a48b37f5 100644 --- a/examples/farp-discovery-example/go.mod +++ b/examples/farp-discovery-example/go.mod @@ -79,7 +79,7 @@ require ( github.com/xraph/confy v0.5.2 // indirect github.com/xraph/farp v1.3.0 // indirect github.com/xraph/farp/discovery v1.2.0 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect github.com/xraph/vessel v1.0.2 // indirect go.etcd.io/etcd/api/v3 v3.5.17 // indirect go.etcd.io/etcd/client/pkg/v3 v3.5.17 // indirect diff --git a/examples/farp-discovery-example/go.sum b/examples/farp-discovery-example/go.sum index fc1ba61d..ca36f668 100644 --- a/examples/farp-discovery-example/go.sum +++ b/examples/farp-discovery-example/go.sum @@ -296,8 +296,8 @@ github.com/xraph/farp v1.3.0 h1:9eH09TyRglpvyYL2Su4oxcrAaJm6D3Ey4w9vYDVO298= github.com/xraph/farp v1.3.0/go.mod h1:Nlli8WUsxvQL5wXiJqcAn6OsUHBzKJxrl9JLJ9J6Wqo= github.com/xraph/farp/discovery v1.2.0 h1:DGUZUGdYUhFxvAzTdPZc+11m1e95LzzEiyfS5Sh8xYA= github.com/xraph/farp/discovery v1.2.0/go.mod h1:Lx1zYvPRryyzUyVIPidl2XvspeRPRwHPNfPRQWUhRVg= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/examples/file_upload/go.mod b/examples/file_upload/go.mod index ebde73c5..09bd6082 100644 --- a/examples/file_upload/go.mod +++ b/examples/file_upload/go.mod @@ -63,7 +63,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect github.com/xraph/vessel v1.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect diff --git a/examples/file_upload/go.sum b/examples/file_upload/go.sum index 32bc6253..9af4450a 100644 --- a/examples/file_upload/go.sum +++ b/examples/file_upload/go.sum @@ -274,8 +274,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/examples/openapi-demo/go.mod b/examples/openapi-demo/go.mod index 957c0cd6..959e0aa3 100644 --- a/examples/openapi-demo/go.mod +++ b/examples/openapi-demo/go.mod @@ -63,7 +63,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect github.com/xraph/vessel v1.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect diff --git a/examples/openapi-demo/go.sum b/examples/openapi-demo/go.sum index 32bc6253..9af4450a 100644 --- a/examples/openapi-demo/go.sum +++ b/examples/openapi-demo/go.sum @@ -274,8 +274,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/examples/openapi_complete/go.mod b/examples/openapi_complete/go.mod index 650847fd..af1fba9e 100644 --- a/examples/openapi_complete/go.mod +++ b/examples/openapi_complete/go.mod @@ -63,7 +63,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect github.com/xraph/vessel v1.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect diff --git a/examples/openapi_complete/go.sum b/examples/openapi_complete/go.sum index 32bc6253..9af4450a 100644 --- a/examples/openapi_complete/go.sum +++ b/examples/openapi_complete/go.sum @@ -274,8 +274,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/examples/openapi_unified/go.mod b/examples/openapi_unified/go.mod index 524abcc2..d94d571e 100644 --- a/examples/openapi_unified/go.mod +++ b/examples/openapi_unified/go.mod @@ -63,7 +63,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect github.com/xraph/vessel v1.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect diff --git a/examples/openapi_unified/go.sum b/examples/openapi_unified/go.sum index 32bc6253..9af4450a 100644 --- a/examples/openapi_unified/go.sum +++ b/examples/openapi_unified/go.sum @@ -274,8 +274,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/examples/webtransport/go.mod b/examples/webtransport/go.mod index 1bbf0979..3b86ffef 100644 --- a/examples/webtransport/go.mod +++ b/examples/webtransport/go.mod @@ -74,7 +74,7 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect github.com/xraph/forgeui v1.4.1 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect github.com/xraph/vessel v1.0.2 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect diff --git a/examples/webtransport/go.sum b/examples/webtransport/go.sum index 41b2f1cf..21b9f9e3 100644 --- a/examples/webtransport/go.sum +++ b/examples/webtransport/go.sum @@ -288,8 +288,8 @@ github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= github.com/xraph/forgeui v1.4.1 h1:LHK1t/sZ+9zL+MNUZralO9/rc0f5UCa19dpbWTuRMNg= github.com/xraph/forgeui v1.4.1/go.mod h1:rH/+wb1tt2pXSHotWAvoP+Lt846xlIjuwPDSpS5K5mw= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/extensions/auth/examples/auth_example/go.mod b/extensions/auth/examples/auth_example/go.mod index 90229036..0365ea61 100644 --- a/extensions/auth/examples/auth_example/go.mod +++ b/extensions/auth/examples/auth_example/go.mod @@ -66,7 +66,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect github.com/xraph/vessel v1.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect diff --git a/extensions/auth/examples/auth_example/go.sum b/extensions/auth/examples/auth_example/go.sum index 84a47495..b756b1ec 100644 --- a/extensions/auth/examples/auth_example/go.sum +++ b/extensions/auth/examples/auth_example/go.sum @@ -283,8 +283,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/extensions/cache/go.mod b/extensions/cache/go.mod index 7d7b94f7..f63f2d3d 100644 --- a/extensions/cache/go.mod +++ b/extensions/cache/go.mod @@ -68,7 +68,7 @@ require ( github.com/spf13/pflag v1.0.9 // indirect github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect diff --git a/extensions/cache/go.sum b/extensions/cache/go.sum index 32bc6253..9af4450a 100644 --- a/extensions/cache/go.sum +++ b/extensions/cache/go.sum @@ -274,8 +274,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/extensions/consensus/go.mod b/extensions/consensus/go.mod index 9fb36d24..22f541a6 100644 --- a/extensions/consensus/go.mod +++ b/extensions/consensus/go.mod @@ -78,7 +78,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/metric v1.40.0 // indirect diff --git a/extensions/consensus/go.sum b/extensions/consensus/go.sum index f48281e0..456cbce6 100644 --- a/extensions/consensus/go.sum +++ b/extensions/consensus/go.sum @@ -291,8 +291,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= diff --git a/extensions/cron/go.mod b/extensions/cron/go.mod index 5f71deb1..7defaab7 100644 --- a/extensions/cron/go.mod +++ b/extensions/cron/go.mod @@ -68,7 +68,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect diff --git a/extensions/cron/go.sum b/extensions/cron/go.sum index 38afff9f..372e9ab7 100644 --- a/extensions/cron/go.sum +++ b/extensions/cron/go.sum @@ -276,8 +276,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/extensions/database/go.mod b/extensions/database/go.mod index f8c5b9f6..312ceb9b 100644 --- a/extensions/database/go.mod +++ b/extensions/database/go.mod @@ -17,7 +17,7 @@ require ( github.com/uptrace/bun/dialect/pgdialect v1.2.15 github.com/uptrace/bun/dialect/sqlitedialect v1.2.15 github.com/xraph/forge v1.4.4 - github.com/xraph/go-utils v1.1.1 + github.com/xraph/go-utils v1.1.2 github.com/xraph/vessel v1.0.2 go.mongodb.org/mongo-driver v1.17.4 ) diff --git a/extensions/database/go.sum b/extensions/database/go.sum index 618f5229..e9870b6f 100644 --- a/extensions/database/go.sum +++ b/extensions/database/go.sum @@ -312,8 +312,8 @@ github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6 github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= diff --git a/extensions/discovery/go.mod b/extensions/discovery/go.mod index d84fe5f4..76054c96 100644 --- a/extensions/discovery/go.mod +++ b/extensions/discovery/go.mod @@ -82,7 +82,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect go.etcd.io/etcd/api/v3 v3.5.17 // indirect go.etcd.io/etcd/client/pkg/v3 v3.5.17 // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/extensions/discovery/go.sum b/extensions/discovery/go.sum index fc1ba61d..ca36f668 100644 --- a/extensions/discovery/go.sum +++ b/extensions/discovery/go.sum @@ -296,8 +296,8 @@ github.com/xraph/farp v1.3.0 h1:9eH09TyRglpvyYL2Su4oxcrAaJm6D3Ey4w9vYDVO298= github.com/xraph/farp v1.3.0/go.mod h1:Nlli8WUsxvQL5wXiJqcAn6OsUHBzKJxrl9JLJ9J6Wqo= github.com/xraph/farp/discovery v1.2.0 h1:DGUZUGdYUhFxvAzTdPZc+11m1e95LzzEiyfS5Sh8xYA= github.com/xraph/farp/discovery v1.2.0/go.mod h1:Lx1zYvPRryyzUyVIPidl2XvspeRPRwHPNfPRQWUhRVg= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/extensions/events/examples/events-demo/go.mod b/extensions/events/examples/events-demo/go.mod index 1cc00c5e..0ae49366 100644 --- a/extensions/events/examples/events-demo/go.mod +++ b/extensions/events/examples/events-demo/go.mod @@ -72,7 +72,7 @@ require ( github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect github.com/xraph/vessel v1.0.2 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.mongodb.org/mongo-driver v1.17.4 // indirect diff --git a/extensions/events/examples/events-demo/go.sum b/extensions/events/examples/events-demo/go.sum index 81489525..eef32c51 100644 --- a/extensions/events/examples/events-demo/go.sum +++ b/extensions/events/examples/events-demo/go.sum @@ -288,8 +288,8 @@ github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= github.com/xraph/forge v1.4.4 h1:6zt75/XRxEDNPng7YOZSEJI0oKAuJpKrVLZjRRgvS70= github.com/xraph/forge v1.4.4/go.mod h1:Il5MLHm1npOC2UCZwDEd4E4p7lEDqlsg2pJ3W4nOQSo= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= diff --git a/extensions/events/go.mod b/extensions/events/go.mod index 0f5ccdaa..9a0d4ad3 100644 --- a/extensions/events/go.mod +++ b/extensions/events/go.mod @@ -11,7 +11,7 @@ require ( github.com/redis/go-redis/v9 v9.14.1 github.com/stretchr/testify v1.11.1 github.com/xraph/forge v1.4.4 - github.com/xraph/go-utils v1.1.1 + github.com/xraph/go-utils v1.1.2 github.com/xraph/vessel v1.0.2 go.mongodb.org/mongo-driver v1.17.4 ) diff --git a/extensions/events/go.sum b/extensions/events/go.sum index f69616b3..676197fe 100644 --- a/extensions/events/go.sum +++ b/extensions/events/go.sum @@ -294,8 +294,8 @@ github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6 github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= diff --git a/extensions/features/go.mod b/extensions/features/go.mod index 1350595c..0a5c38a2 100644 --- a/extensions/features/go.mod +++ b/extensions/features/go.mod @@ -84,7 +84,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect diff --git a/extensions/features/go.sum b/extensions/features/go.sum index ed5567b7..1057f069 100644 --- a/extensions/features/go.sum +++ b/extensions/features/go.sum @@ -320,8 +320,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/extensions/grpc/examples/grpc-advanced/go.mod b/extensions/grpc/examples/grpc-advanced/go.mod index 9bc6f83c..1ea9692f 100644 --- a/extensions/grpc/examples/grpc-advanced/go.mod +++ b/extensions/grpc/examples/grpc-advanced/go.mod @@ -61,7 +61,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect github.com/xraph/vessel v1.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect diff --git a/extensions/grpc/examples/grpc-advanced/go.sum b/extensions/grpc/examples/grpc-advanced/go.sum index 81590678..152884e3 100644 --- a/extensions/grpc/examples/grpc-advanced/go.sum +++ b/extensions/grpc/examples/grpc-advanced/go.sum @@ -264,8 +264,8 @@ github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= github.com/xraph/forge v1.4.4 h1:6zt75/XRxEDNPng7YOZSEJI0oKAuJpKrVLZjRRgvS70= github.com/xraph/forge v1.4.4/go.mod h1:Il5MLHm1npOC2UCZwDEd4E4p7lEDqlsg2pJ3W4nOQSo= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/extensions/grpc/go.mod b/extensions/grpc/go.mod index cd65feea..f2506402 100644 --- a/extensions/grpc/go.mod +++ b/extensions/grpc/go.mod @@ -67,7 +67,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect diff --git a/extensions/grpc/go.sum b/extensions/grpc/go.sum index f55ea723..55c6907a 100644 --- a/extensions/grpc/go.sum +++ b/extensions/grpc/go.sum @@ -278,8 +278,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/extensions/hls/go.mod b/extensions/hls/go.mod index f0b9d1ad..5e905396 100644 --- a/extensions/hls/go.mod +++ b/extensions/hls/go.mod @@ -91,7 +91,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect diff --git a/extensions/hls/go.sum b/extensions/hls/go.sum index 14a4569a..2d1231a8 100644 --- a/extensions/hls/go.sum +++ b/extensions/hls/go.sum @@ -312,8 +312,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/extensions/kafka/go.mod b/extensions/kafka/go.mod index 0ead8ca1..b6e9f0c8 100644 --- a/extensions/kafka/go.mod +++ b/extensions/kafka/go.mod @@ -81,7 +81,7 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect diff --git a/extensions/kafka/go.sum b/extensions/kafka/go.sum index 284528ae..9db7a63d 100644 --- a/extensions/kafka/go.sum +++ b/extensions/kafka/go.sum @@ -313,8 +313,8 @@ github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6 github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/extensions/mcp/examples/mcp-server/go.mod b/extensions/mcp/examples/mcp-server/go.mod index 7a820684..ae0933b3 100644 --- a/extensions/mcp/examples/mcp-server/go.mod +++ b/extensions/mcp/examples/mcp-server/go.mod @@ -59,7 +59,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect github.com/xraph/vessel v1.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect diff --git a/extensions/mcp/examples/mcp-server/go.sum b/extensions/mcp/examples/mcp-server/go.sum index 18b3b8fc..8a8e177b 100644 --- a/extensions/mcp/examples/mcp-server/go.sum +++ b/extensions/mcp/examples/mcp-server/go.sum @@ -258,8 +258,8 @@ github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= github.com/xraph/forge v1.4.4 h1:6zt75/XRxEDNPng7YOZSEJI0oKAuJpKrVLZjRRgvS70= github.com/xraph/forge v1.4.4/go.mod h1:Il5MLHm1npOC2UCZwDEd4E4p7lEDqlsg2pJ3W4nOQSo= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/extensions/mcp/go.mod b/extensions/mcp/go.mod index aa4be5e8..1c6bb1f5 100644 --- a/extensions/mcp/go.mod +++ b/extensions/mcp/go.mod @@ -5,7 +5,7 @@ go 1.25.7 require ( github.com/xraph/confy v0.5.2 github.com/xraph/forge v1.4.4 - github.com/xraph/go-utils v1.1.1 + github.com/xraph/go-utils v1.1.2 github.com/xraph/vessel v1.0.2 ) diff --git a/extensions/mcp/go.sum b/extensions/mcp/go.sum index 32bc6253..9af4450a 100644 --- a/extensions/mcp/go.sum +++ b/extensions/mcp/go.sum @@ -274,8 +274,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/extensions/mqtt/go.mod b/extensions/mqtt/go.mod index 2f58535e..e81606b3 100644 --- a/extensions/mqtt/go.mod +++ b/extensions/mqtt/go.mod @@ -66,7 +66,7 @@ require ( github.com/spf13/pflag v1.0.9 // indirect github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect diff --git a/extensions/mqtt/go.sum b/extensions/mqtt/go.sum index 6e6dfa11..c2492f80 100644 --- a/extensions/mqtt/go.sum +++ b/extensions/mqtt/go.sum @@ -278,8 +278,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/extensions/orpc/examples/orpc_example/go.mod b/extensions/orpc/examples/orpc_example/go.mod index a9cba372..e62f494a 100644 --- a/extensions/orpc/examples/orpc_example/go.mod +++ b/extensions/orpc/examples/orpc_example/go.mod @@ -59,7 +59,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect github.com/xraph/vessel v1.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect diff --git a/extensions/orpc/examples/orpc_example/go.sum b/extensions/orpc/examples/orpc_example/go.sum index b03554cc..dd9d9dbd 100644 --- a/extensions/orpc/examples/orpc_example/go.sum +++ b/extensions/orpc/examples/orpc_example/go.sum @@ -258,8 +258,8 @@ github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= github.com/xraph/forge v1.2.0 h1:+f5pAoWIa4vzFDD3j5b3qDk+X52/Hr1kyoV6CV8vx0g= github.com/xraph/forge v1.2.0/go.mod h1:n7mKRcLgNsGblsMDmpBrZNieMSe7dFmnGaA1O8sDZSw= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/extensions/orpc/go.mod b/extensions/orpc/go.mod index a7e2a059..cdd59dc7 100644 --- a/extensions/orpc/go.mod +++ b/extensions/orpc/go.mod @@ -64,7 +64,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect diff --git a/extensions/orpc/go.sum b/extensions/orpc/go.sum index 32bc6253..9af4450a 100644 --- a/extensions/orpc/go.sum +++ b/extensions/orpc/go.sum @@ -274,8 +274,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/extensions/queue/go.mod b/extensions/queue/go.mod index 25269f2c..07189e55 100644 --- a/extensions/queue/go.mod +++ b/extensions/queue/go.mod @@ -89,7 +89,7 @@ require ( github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.mongodb.org/mongo-driver v1.17.4 // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/extensions/queue/go.sum b/extensions/queue/go.sum index 2e1bc0b2..ce4e3f8e 100644 --- a/extensions/queue/go.sum +++ b/extensions/queue/go.sum @@ -320,8 +320,8 @@ github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6 github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= diff --git a/extensions/search/go.mod b/extensions/search/go.mod index 75034a0e..77819dee 100644 --- a/extensions/search/go.mod +++ b/extensions/search/go.mod @@ -64,7 +64,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect diff --git a/extensions/search/go.sum b/extensions/search/go.sum index 32bc6253..9af4450a 100644 --- a/extensions/search/go.sum +++ b/extensions/search/go.sum @@ -274,8 +274,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/extensions/security/examples/security-auto-middleware/go.mod b/extensions/security/examples/security-auto-middleware/go.mod index 7617b21a..1940307b 100644 --- a/extensions/security/examples/security-auto-middleware/go.mod +++ b/extensions/security/examples/security-auto-middleware/go.mod @@ -60,7 +60,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect github.com/xraph/vessel v1.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect diff --git a/extensions/security/examples/security-auto-middleware/go.sum b/extensions/security/examples/security-auto-middleware/go.sum index b4d2c631..9a3c8812 100644 --- a/extensions/security/examples/security-auto-middleware/go.sum +++ b/extensions/security/examples/security-auto-middleware/go.sum @@ -260,8 +260,8 @@ github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= github.com/xraph/forge v1.4.4 h1:6zt75/XRxEDNPng7YOZSEJI0oKAuJpKrVLZjRRgvS70= github.com/xraph/forge v1.4.4/go.mod h1:Il5MLHm1npOC2UCZwDEd4E4p7lEDqlsg2pJ3W4nOQSo= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/extensions/security/go.mod b/extensions/security/go.mod index e17f21e2..43387308 100644 --- a/extensions/security/go.mod +++ b/extensions/security/go.mod @@ -5,7 +5,7 @@ go 1.25.7 require ( github.com/golang-jwt/jwt/v5 v5.3.0 github.com/xraph/forge v1.4.4 - github.com/xraph/go-utils v1.1.1 + github.com/xraph/go-utils v1.1.2 github.com/xraph/vessel v1.0.2 golang.org/x/crypto v0.51.0 ) diff --git a/extensions/security/go.sum b/extensions/security/go.sum index 6647711a..b7ce2a68 100644 --- a/extensions/security/go.sum +++ b/extensions/security/go.sum @@ -276,8 +276,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/extensions/storage/go.mod b/extensions/storage/go.mod index ecc8f61c..4bf79616 100644 --- a/extensions/storage/go.mod +++ b/extensions/storage/go.mod @@ -9,7 +9,7 @@ require ( github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.19.14 github.com/aws/aws-sdk-go-v2/service/s3 v1.88.6 github.com/xraph/forge v1.4.4 - github.com/xraph/go-utils v1.1.1 + github.com/xraph/go-utils v1.1.2 github.com/xraph/vessel v1.0.2 ) diff --git a/extensions/storage/go.sum b/extensions/storage/go.sum index 14a4569a..2d1231a8 100644 --- a/extensions/storage/go.sum +++ b/extensions/storage/go.sum @@ -312,8 +312,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/extensions/streaming/go.mod b/extensions/streaming/go.mod index ef608d51..a45862bc 100644 --- a/extensions/streaming/go.mod +++ b/extensions/streaming/go.mod @@ -70,7 +70,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/extensions/streaming/go.sum b/extensions/streaming/go.sum index 41b2f1cf..21b9f9e3 100644 --- a/extensions/streaming/go.sum +++ b/extensions/streaming/go.sum @@ -288,8 +288,8 @@ github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= github.com/xraph/forgeui v1.4.1 h1:LHK1t/sZ+9zL+MNUZralO9/rc0f5UCa19dpbWTuRMNg= github.com/xraph/forgeui v1.4.1/go.mod h1:rH/+wb1tt2pXSHotWAvoP+Lt846xlIjuwPDSpS5K5mw= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= diff --git a/extensions/webrtc/go.mod b/extensions/webrtc/go.mod index 09d0b680..2b1a98a2 100644 --- a/extensions/webrtc/go.mod +++ b/extensions/webrtc/go.mod @@ -89,7 +89,7 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v0.5.2 // indirect github.com/xraph/forgeui v1.4.1 // indirect - github.com/xraph/go-utils v1.1.1 // indirect + github.com/xraph/go-utils v1.1.2 // indirect github.com/xraph/vessel v1.0.2 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect diff --git a/extensions/webrtc/go.sum b/extensions/webrtc/go.sum index 84be56b7..8a5055fa 100644 --- a/extensions/webrtc/go.sum +++ b/extensions/webrtc/go.sum @@ -337,8 +337,8 @@ github.com/xraph/confy v0.5.2 h1:YlZDDG2sZWyiSm4yjXTLQ7RFdy/3izg/plJh1g9guEA= github.com/xraph/confy v0.5.2/go.mod h1:FPupuOi04fSgh8pKb9BOTPxyzq9WhIncfY9aDH0bLO8= github.com/xraph/forgeui v1.4.1 h1:LHK1t/sZ+9zL+MNUZralO9/rc0f5UCa19dpbWTuRMNg= github.com/xraph/forgeui v1.4.1/go.mod h1:rH/+wb1tt2pXSHotWAvoP+Lt846xlIjuwPDSpS5K5mw= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/go.mod b/go.mod index dc82806b..75e762ad 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/uptrace/bunrouter v1.0.23 github.com/xraph/confy v0.5.2 github.com/xraph/forgeui v1.4.1 - github.com/xraph/go-utils v1.1.1 + github.com/xraph/go-utils v1.1.2 github.com/xraph/vessel v1.0.2 go.opentelemetry.io/otel v1.40.0 go.opentelemetry.io/otel/exporters/jaeger v1.17.0 diff --git a/go.sum b/go.sum index b53a4760..0872b885 100644 --- a/go.sum +++ b/go.sum @@ -306,8 +306,8 @@ github.com/xraph/farp/discovery v1.2.0 h1:DGUZUGdYUhFxvAzTdPZc+11m1e95LzzEiyfS5S github.com/xraph/farp/discovery v1.2.0/go.mod h1:Lx1zYvPRryyzUyVIPidl2XvspeRPRwHPNfPRQWUhRVg= github.com/xraph/forgeui v1.4.1 h1:LHK1t/sZ+9zL+MNUZralO9/rc0f5UCa19dpbWTuRMNg= github.com/xraph/forgeui v1.4.1/go.mod h1:rH/+wb1tt2pXSHotWAvoP+Lt846xlIjuwPDSpS5K5mw= -github.com/xraph/go-utils v1.1.1 h1:k5TOQ1qhXLdEX8W5F60mSuPcFOPc8sBsnhmjx/Kpr5g= -github.com/xraph/go-utils v1.1.1/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= +github.com/xraph/go-utils v1.1.2 h1:h+gLUnSHbtfXPjFjUW2LaUBDi0inMG0pdx/lnvcez3E= +github.com/xraph/go-utils v1.1.2/go.mod h1:tZN4SuGy9otCo6dETp7Cvkgyiy0BvaJaZbTBv4Euvo4= github.com/xraph/vessel v1.0.2 h1:IeNTwxiFgqH2vW9lh8PNXr1SeGdEc7cxQ6sH7jMokfo= github.com/xraph/vessel v1.0.2/go.mod h1:5hgrMbuczxu2kRIM3iVJ3wPSb6HOvbMhV4nkRFaPNqs= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/internal/router/asyncapi_generator.go b/internal/router/asyncapi_generator.go index 8fff4b98..2e6b79d4 100644 --- a/internal/router/asyncapi_generator.go +++ b/internal/router/asyncapi_generator.go @@ -418,7 +418,12 @@ func (g *asyncAPIGenerator) handleSpecEndpoint(ctx Context) error { } ctx.Response().Header().Set("Content-Type", "application/json") - ctx.Response().Header().Set("Access-Control-Allow-Origin", "*") + + // No wildcard Access-Control-Allow-Origin here. The spec enumerates every + // channel, message shape and internal name in the app; making it readable by + // any origin hands that map to any page the user visits. Apps that genuinely + // need cross-origin spec access should mount the CORS middleware with an + // explicit allow-list instead. encoder := json.NewEncoder(ctx.Response()) if g.config.PrettyJSON { diff --git a/internal/router/handler.go b/internal/router/handler.go index c766a5ec..2e3e4042 100644 --- a/internal/router/handler.go +++ b/internal/router/handler.go @@ -378,6 +378,14 @@ func resolveService(ctx Context, serviceType reflect.Type, serviceName string) ( return val, nil } +// SetExposeInternalErrors controls whether internal error detail appears in 5xx +// response bodies. Forwards to the shared toggle so the router and the default +// error handler stay in agreement — both emit 5xx envelopes, and gating only one +// of them leaves the leak open on whichever path the app happens to use. +func SetExposeInternalErrors(expose bool) { + shared.SetExposeInternalErrors(expose) +} + func handleError(ctx Context, err error) { // Check if the error implements the IHTTPError interface // IHTTPError is the interface from errs package (go-utils) @@ -389,13 +397,26 @@ func handleError(ctx Context, err error) { var httpErr httpErrorInterface if errors.As(err, &httpErr) { - _ = ctx.JSON(httpErr.StatusCode(), httpErr.ResponseBody()) - } else { - _ = ctx.JSON(http.StatusInternalServerError, map[string]any{ - "code": "INTERNAL_SERVER_ERROR", - "message": err.Error(), - }) + // InternalError and friends embed the wrapped error text in their body, + // so a 5xx body is redacted unless exposure is enabled. 4xx bodies pass + // through — the client needs those to fix the request. + status := httpErr.StatusCode() + + _ = ctx.JSON(status, shared.SanitizeErrorBody(status, httpErr.ResponseBody())) + + return } + + // Unhandled error: the status is all the client is entitled to. + message := http.StatusText(http.StatusInternalServerError) + if shared.ExposeInternalErrors() { + message = err.Error() + } + + _ = ctx.JSON(http.StatusInternalServerError, map[string]any{ + "code": "INTERNAL_SERVER_ERROR", + "message": message, + }) } // processResponse handles response struct tags using shared logic. diff --git a/internal/router/hardening_test.go b/internal/router/hardening_test.go new file mode 100644 index 00000000..00f75f20 --- /dev/null +++ b/internal/router/hardening_test.go @@ -0,0 +1,200 @@ +package router + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/xraph/vessel" +) + +type sizedPayload struct { + Blob string `json:"blob"` +} + +// Request bodies were decoded with no cap, so a single unauthenticated request +// could drive allocation as large as the client chose to send. +func TestRequestBodyIsCapped(t *testing.T) { + r := NewRouter(WithContainer(vessel.New()), WithMaxRequestBodySize(1024)) + + if err := r.POST("/echo", func(_ Context, req *sizedPayload) (*sizedPayload, error) { + return req, nil + }); err != nil { + t.Fatal(err) + } + + body := `{"blob":"` + strings.Repeat("A", 64*1024) + `"}` + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/echo", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(rec, req) + + if rec.Code == http.StatusOK { + t.Errorf("64 KiB body accepted against a 1 KiB cap (status %d)", rec.Code) + } +} + +func TestRequestBodyUnderCapSucceeds(t *testing.T) { + r := NewRouter(WithContainer(vessel.New()), WithMaxRequestBodySize(64*1024)) + + if err := r.POST("/echo", func(_ Context, req *sizedPayload) (*sizedPayload, error) { + return req, nil + }); err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/echo", strings.NewReader(`{"blob":"small"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("small body rejected: status %d, body %s", rec.Code, rec.Body.String()) + } +} + +func TestResolveMaxBodySizePrecedence(t *testing.T) { + cases := []struct { + name string + routerWide int64 + route int64 + want int64 + }{ + {"falls back to the default", 0, 0, DefaultMaxRequestBodySize}, + {"router-wide setting applies", 4096, 0, 4096}, + {"route overrides router", 4096, 8192, 8192}, + {"route opts out", 4096, -1, 0}, + {"router opts out", -1, 0, 0}, + {"route raises an opted-out router", -1, 2048, 2048}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := &router{maxBodySize: c.routerWide} + if got := r.resolveMaxBodySize(c.route); got != c.want { + t.Errorf("resolveMaxBodySize(route=%d) with router=%d = %d, want %d", + c.route, c.routerWide, got, c.want) + } + }) + } +} + +// SSE fields are newline-delimited. An unescaped newline in the event name or a +// multi-line data payload lets the remainder be parsed as further SSE fields — +// event forgery when any part of the value is user-influenced. +func TestSSESendRejectsNewlineInEventName(t *testing.T) { + stream, rec := newTestSSEStream(t) + + err := stream.Send("update\n\nevent: admin", []byte("payload")) + if err == nil { + t.Fatal("event name containing a newline was accepted") + } + + if body := rec.Body.String(); strings.Contains(body, "event: admin") { + t.Errorf("forged event reached the wire: %q", body) + } +} + +func TestSSESendCommentRejectsNewline(t *testing.T) { + stream, _ := newTestSSEStream(t) + + if err := stream.SendComment("keepalive\n\ndata: forged"); err == nil { + t.Fatal("comment containing a newline was accepted") + } +} + +// Multi-line data must be emitted as one "data: " line per line, per the SSE +// grammar — otherwise the payload is both corrupted and injectable. +func TestSSEEncodesMultiLineData(t *testing.T) { + stream, rec := newTestSSEStream(t) + + if err := stream.Send("message", []byte("line one\nline two\r\nline three")); err != nil { + t.Fatal(err) + } + + body := rec.Body.String() + + for _, want := range []string{ + "event: message\n", + "data: line one\n", + "data: line two\n", + "data: line three\n", + } { + if !strings.Contains(body, want) { + t.Errorf("missing %q in:\n%s", want, body) + } + } + + // A bare "line two" without its data: prefix would mean the payload escaped + // its field. + if strings.Contains(body, "\nline two") { + t.Errorf("data line emitted without its field prefix:\n%s", body) + } +} + +func newTestSSEStream(t *testing.T) (*sseStream, *httptest.ResponseRecorder) { + t.Helper() + + rec := httptest.NewRecorder() + + stream, err := newSSEStream(rec, httptest.NewRequest(http.MethodGet, "/events", nil), 0) + if err != nil { + t.Fatal(err) + } + + return stream, rec +} + +// Connection IDs were derived from time.Now().UnixNano(), which collides for +// upgrades landing in the same clock tick. Two live connections sharing an ID +// means cross-talk anywhere connections are tracked by ID. +func TestConnectionIDsAreUnique(t *testing.T) { + const n = 10_000 + + seen := make(map[string]bool, n) + + for range n { + id := generateConnectionID() + if seen[id] { + t.Fatalf("duplicate connection ID generated: %q", id) + } + + seen[id] = true + } +} + +// A 500 must not echo internal error text unless explicitly enabled; the wrapped +// message can carry driver output, paths and internal hostnames. +func TestInternalErrorDetailIsGatedByEnvironment(t *testing.T) { + t.Cleanup(func() { SetExposeInternalErrors(false) }) + + const secret = "pq: relation \"internal_billing\" does not exist" + + run := func(expose bool) string { + SetExposeInternalErrors(expose) + + r := NewRouter(WithContainer(vessel.New())) + + if err := r.GET("/boom", func(Context) error { + return InternalError(errors.New(secret)) + }); err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/boom", nil)) + + return rec.Body.String() + } + + if body := run(false); strings.Contains(body, "internal_billing") { + t.Errorf("production response leaked internal error detail: %s", body) + } + + if body := run(true); !strings.Contains(body, "internal_billing") { + t.Errorf("development response should include detail, got: %s", body) + } +} diff --git a/internal/router/interceptor_context.go b/internal/router/interceptor_context.go new file mode 100644 index 00000000..1e100af0 --- /dev/null +++ b/internal/router/interceptor_context.go @@ -0,0 +1,215 @@ +package router + +import ( + "errors" + "sync" + + "github.com/xraph/forge/internal/shared" + "github.com/xraph/go-utils/di" +) + +// errContextDetached is returned by a detached syncContext. It only surfaces to +// an interceptor goroutine whose result has already been discarded, so it never +// reaches a handler. +var errContextDetached = errors.New("interceptor context detached: fan-out already resolved") + +// contextGuard serializes access to one forge context across a fan-out of +// interceptor goroutines, and can be detached once the fan-out's decision is +// made so stragglers stop touching the context altogether. +type contextGuard struct { + mu sync.Mutex + detached bool +} + +// detach makes every syncContext sharing this guard inert. Callers must detach +// before returning from a fan-out that leaves goroutines running: the forge +// context is pooled and reused by the next request, so a straggler writing to +// it after the handler returns corrupts unrelated in-flight requests. +func (g *contextGuard) detach() { + g.mu.Lock() + g.detached = true + g.mu.Unlock() +} + +// embeddedContext exists purely so syncContext can embed the context interface. +// Embedding Context directly would create a field named "Context", which +// shadows the interface's own Context() method and stops the wrapper from +// satisfying the interface. Embedding through an alias keeps the method. +type embeddedContext = Context + +// syncContext serializes the context operations that mutate state shared by +// every interceptor running under Parallel/ParallelAny. +// +// The underlying forge context carries a plain map for values and lazily +// creates its DI scope on first use, with no synchronization of its own — +// it is built for one goroutine per request. Handing the same context to N +// interceptor goroutines therefore races, and an unsynchronized map write is +// a fatal runtime error that recover() cannot catch, so it takes the process +// down rather than just failing the request. Racing the lazy scope init also +// orphans a scope that is never ended. +// +// Only the state-mutating surface is wrapped. Reads of immutable request data +// (Param, Query, Header, Request, Response) pass through untouched, as do the +// response writers — interceptors are expected to allow/block and enrich, not +// to write responses, and a mutex here would not make concurrent response +// writes correct anyway. +type syncContext struct { + embeddedContext // everything not overridden below passes straight through + + guard *contextGuard +} + +// newSyncContext wraps ctx so it can be shared across interceptor goroutines. +// Every wrapper produced for one fan-out must share a single guard. +func newSyncContext(ctx Context, guard *contextGuard) Context { + return &syncContext{embeddedContext: ctx, guard: guard} +} + +// --- values map --- + +func (c *syncContext) Set(key string, value any) { + c.guard.mu.Lock() + defer c.guard.mu.Unlock() + + if c.guard.detached { + return + } + + c.embeddedContext.Set(key, value) +} + +func (c *syncContext) Get(key string) any { + c.guard.mu.Lock() + defer c.guard.mu.Unlock() + + if c.guard.detached { + return nil + } + + return c.embeddedContext.Get(key) +} + +func (c *syncContext) MustGet(key string) any { + c.guard.mu.Lock() + defer c.guard.mu.Unlock() + + if c.guard.detached { + return nil + } + + return c.embeddedContext.MustGet(key) +} + +// --- lazily created DI scope --- + +func (c *syncContext) Scope() di.Scope { + c.guard.mu.Lock() + defer c.guard.mu.Unlock() + + if c.guard.detached { + return nil + } + + return c.embeddedContext.Scope() +} + +func (c *syncContext) Resolve(name string) (any, error) { + c.guard.mu.Lock() + defer c.guard.mu.Unlock() + + if c.guard.detached { + return nil, errContextDetached + } + + return c.embeddedContext.Resolve(name) +} + +func (c *syncContext) Must(name string) any { + c.guard.mu.Lock() + defer c.guard.mu.Unlock() + + if c.guard.detached { + return nil + } + + return c.embeddedContext.Must(name) +} + +// --- lazily loaded session --- + +func (c *syncContext) Session() (shared.Session, error) { + c.guard.mu.Lock() + defer c.guard.mu.Unlock() + + if c.guard.detached { + return nil, errContextDetached + } + + return c.embeddedContext.Session() +} + +func (c *syncContext) SetSession(session shared.Session) { + c.guard.mu.Lock() + defer c.guard.mu.Unlock() + + if c.guard.detached { + return + } + + c.embeddedContext.SetSession(session) +} + +func (c *syncContext) SaveSession() error { + c.guard.mu.Lock() + defer c.guard.mu.Unlock() + + if c.guard.detached { + return errContextDetached + } + + return c.embeddedContext.SaveSession() +} + +func (c *syncContext) DestroySession() error { + c.guard.mu.Lock() + defer c.guard.mu.Unlock() + + if c.guard.detached { + return errContextDetached + } + + return c.embeddedContext.DestroySession() +} + +func (c *syncContext) GetSessionValue(key string) (any, bool) { + c.guard.mu.Lock() + defer c.guard.mu.Unlock() + + if c.guard.detached { + return nil, false + } + + return c.embeddedContext.GetSessionValue(key) +} + +func (c *syncContext) SetSessionValue(key string, value any) { + c.guard.mu.Lock() + defer c.guard.mu.Unlock() + + if c.guard.detached { + return + } + + c.embeddedContext.SetSessionValue(key, value) +} + +func (c *syncContext) DeleteSessionValue(key string) { + c.guard.mu.Lock() + defer c.guard.mu.Unlock() + + if c.guard.detached { + return + } + + c.embeddedContext.DeleteSessionValue(key) +} diff --git a/internal/router/interceptor_isolation_test.go b/internal/router/interceptor_isolation_test.go new file mode 100644 index 00000000..ce130bc2 --- /dev/null +++ b/internal/router/interceptor_isolation_test.go @@ -0,0 +1,179 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "sync" + "testing" + + forge_http "github.com/xraph/go-utils/http" + "github.com/xraph/vessel" +) + +// recordingInterceptor allows a request and records that it ran. +func recordingInterceptor(name string, seen *[]string, mu *sync.Mutex) Interceptor { + return NewInterceptor(name, func(Context, RouteInfo) InterceptorResult { + mu.Lock() + + *seen = append(*seen, name) + + mu.Unlock() + + return Allow() + }) +} + +// Group interceptors are inherited by appending onto the group's slice. If that +// slice is not cloned first, each route writes its own interceptor into the +// group's spare capacity and the previously registered route silently picks up +// the later route's interceptor — dropping, for example, a route's auth check. +func TestGroupInterceptorsAreNotSharedBetweenRoutes(t *testing.T) { + var ( + mu sync.Mutex + seen []string + ) + + r := NewRouter(WithContainer(vessel.New())) + + // Three separate options so the group's slice grows incrementally and ends + // up with capacity beyond its length — the condition that exposes aliasing. + group := r.Group("/api", + WithGroupInterceptor(recordingInterceptor("group1", &seen, &mu)), + WithGroupInterceptor(recordingInterceptor("group2", &seen, &mu)), + WithGroupInterceptor(recordingInterceptor("group3", &seen, &mu)), + ) + + handler := func(ctx Context) error { return ctx.String(http.StatusOK, "ok") } + + if err := group.GET("/private", handler, + WithInterceptor(recordingInterceptor("requireAuth", &seen, &mu))); err != nil { + t.Fatal(err) + } + + if err := group.GET("/public", handler, + WithInterceptor(recordingInterceptor("publicOnly", &seen, &mu))); err != nil { + t.Fatal(err) + } + + // Hit the route registered FIRST — it is the one a later registration can + // have clobbered. + mu.Lock() + seen = nil + mu.Unlock() + + rec := httptest.NewRecorder() + r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/private", nil)) + + mu.Lock() + + got := append([]string(nil), seen...) + + mu.Unlock() + + want := []string{"group1", "group2", "group3", "requireAuth"} + if len(got) != len(want) { + t.Fatalf("interceptors run = %v, want %v", got, want) + } + + for i := range want { + if got[i] != want[i] { + t.Errorf("interceptor %d = %q, want %q (full chain %v)", i, got[i], want[i], got) + } + } +} + +// shareContextValues aliases the lender's values map into the borrower. If the +// borrower is returned to the shared pool still holding that alias, the next +// unrelated request draws it out, and NewContext's clear() wipes — then +// overwrites — the lender's map while the lender is still in flight. The map +// holds request identity and tenant scope, so this crosses request boundaries. +func TestBorrowedValuesMapIsNotReturnedToThePool(t *testing.T) { + newCtx := func() Context { + return forge_http.NewContext( + httptest.NewRecorder(), + httptest.NewRequest(http.MethodGet, "/", nil), + nil, + ) + } + + // Request 1: an outer context plus a middleware-bridge context that borrows + // its values map, then finishes and is pooled. + lender := newCtx() + lender.Set("tenant", "acme") + + borrower := newCtx() + release := shareContextValues(lender, borrower) + cleanupBorrowedContext(borrower, release) + + // Request 2: fresh context, likely drawn from the pool. + other := newCtx() + + if got := other.Get("tenant"); got != nil { + t.Errorf("new request observed the previous request's data: tenant=%v", got) + } + + other.Set("tenant", "evilcorp") + + if got := lender.Get("tenant"); got != "acme" { + t.Errorf("in-flight request's context was overwritten by a later request: tenant=%v, want acme", got) + } +} + +// Parallel and ParallelAny hand the same context to N goroutines. The context +// has no internal synchronization, and an unsynchronized map write is a fatal +// runtime error that recover() cannot catch — it takes the process down. Run +// under -race to catch the regression. +func TestParallelInterceptorsDoNotRaceOnContext(t *testing.T) { + ctx := forge_http.NewContext( + httptest.NewRecorder(), + httptest.NewRequest(http.MethodGet, "/dashboard", nil), + nil, + ) + + touch := func(key string) Interceptor { + return NewInterceptor("touch:"+key, func(c Context, _ RouteInfo) InterceptorResult { + for i := range 200 { + c.Set(key, i) + _ = c.Get(key) + } + + return AllowWithValues(map[string]any{key: "done"}) + }) + } + + route := RouteInfo{Path: "/dashboard"} + + if res := Parallel(touch("a"), touch("b"), touch("c"), touch("d")).Intercept(ctx, route); res.Blocked { + t.Errorf("Parallel blocked unexpectedly: %v", res.Error) + } + + if res := ParallelAny(touch("e"), touch("f"), touch("g")).Intercept(ctx, route); res.Blocked { + t.Errorf("ParallelAny blocked unexpectedly: %v", res.Error) + } +} + +// Once a fan-out has resolved, stragglers must not keep mutating a context the +// handler has already returned — the pool may have reassigned it. +func TestDetachedSyncContextStopsWriting(t *testing.T) { + inner := forge_http.NewContext( + httptest.NewRecorder(), + httptest.NewRequest(http.MethodGet, "/", nil), + nil, + ) + inner.Set("owner", "request-1") + + guard := &contextGuard{} + wrapped := newSyncContext(inner, guard) + + guard.detach() + + wrapped.Set("owner", "straggler") + + if got := inner.Get("owner"); got != "request-1" { + t.Errorf("detached wrapper still wrote through: owner=%v, want request-1", got) + } + + if got := wrapped.Get("owner"); got != nil { + t.Errorf("detached wrapper still read through: owner=%v, want nil", got) + } +} diff --git a/internal/router/interceptor_parallel.go b/internal/router/interceptor_parallel.go index 6a48c0e5..4cf19498 100644 --- a/internal/router/interceptor_parallel.go +++ b/internal/router/interceptor_parallel.go @@ -43,6 +43,11 @@ func Parallel(interceptors ...Interceptor) Interceptor { execCtx, cancel := context.WithCancel(ctx.Context()) defer cancel() + // The forge context is not safe for concurrent use; every interceptor + // gets a wrapper sharing one guard. See syncContext. + guard := &contextGuard{} + defer guard.detach() + for _, interceptor := range interceptors { wg.Add(1) @@ -56,7 +61,7 @@ func Parallel(interceptors ...Interceptor) Interceptor { default: } - result := i.Intercept(ctx, route) + result := i.Intercept(newSyncContext(ctx, guard), route) results <- parallelResult{name: i.Name(), result: result} // If blocked, cancel other interceptors @@ -136,6 +141,13 @@ func ParallelAny(interceptors ...Interceptor) Interceptor { execCtx, cancel := context.WithCancel(ctx.Context()) defer cancel() + // ParallelAny returns on the first success, leaving the remaining + // interceptors running. Detaching on the way out stops those + // stragglers from touching a context the handler has already + // returned — and that the pool may have handed to another request. + guard := &contextGuard{} + defer guard.detach() + var wg sync.WaitGroup for _, interceptor := range interceptors { @@ -150,7 +162,7 @@ func ParallelAny(interceptors ...Interceptor) Interceptor { default: } - result := i.Intercept(ctx, route) + result := i.Intercept(newSyncContext(ctx, guard), route) results <- outcome{ allowed: !result.Blocked, result: parallelResult{name: i.Name(), result: result}, diff --git a/internal/router/middleware.go b/internal/router/middleware.go index c90dcffc..57cf370c 100644 --- a/internal/router/middleware.go +++ b/internal/router/middleware.go @@ -22,11 +22,12 @@ func (f MiddlewareFunc) ToMiddleware(container vessel.Vessel) Middleware { httpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Create a new context from the http request with the same container forgeCtx := forge_http.NewContext(w, r, container) - defer forgeCtx.(forge_http.ContextWithClean).Cleanup() // Share values map by reference instead of copying per-key. - // This is O(1) vs O(n) and avoids allocations. - shareContextValues(ctx, forgeCtx) + // This is O(1) vs O(n) and avoids allocations. The borrow is + // released before forgeCtx returns to the pool. + release := shareContextValues(ctx, forgeCtx) + defer cleanupBorrowedContext(forgeCtx, release) // Execute the forge handler if err := next(forgeCtx); err != nil { @@ -70,10 +71,11 @@ func (m PureMiddleware) ToMiddleware() Middleware { // Create a new context from the http request with the same container forgeCtx := forge_http.NewContext(w, r, container) - defer forgeCtx.(forge_http.ContextWithClean).Cleanup() // Share values map by reference instead of copying per-key. - shareContextValues(ctx, forgeCtx) + // The borrow is released before forgeCtx returns to the pool. + release := shareContextValues(ctx, forgeCtx) + defer cleanupBorrowedContext(forgeCtx, release) // Execute the forge handler if err := next(forgeCtx); err != nil { @@ -168,20 +170,62 @@ func FromMiddleware(m Middleware) PureMiddleware { panic("FromMiddleware requires container and errorHandler. Use ToPureMiddleware instead.") } +type contextValuer interface { + Values() map[string]any +} + +type contextSharer interface { + ShareValues(map[string]any) +} + +// contextValuesReleaser is implemented by contexts that track values-map +// ownership themselves and can hand back a private map on request. +type contextValuesReleaser interface { + ReleaseSharedValues() +} + // shareContextValues transfers values from one context to another by sharing -// the underlying map reference. This is O(1) compared to the previous O(n) -// per-key copy loop, and avoids per-hop allocations in the middleware chain. -func shareContextValues(src Context, dst Context) { - type valuer interface { - Values() map[string]any +// the underlying map reference. This is O(1) compared to a per-key copy loop, +// and avoids per-hop allocations in the middleware chain. +// +// The returned function MUST be called before dst is cleaned up. Contexts are +// pooled (see forge_http.NewContext), and a borrowed map that rides along into +// the pool gets cleared and overwritten by whichever unrelated request draws +// that context out next — corrupting the lender's values while it is still in +// flight. The lender's map holds request identity and tenant scope (see +// forge.SetScope), so this is a cross-request data leak, not just a stale read. +// Releasing hands dst a private map so only src continues to own the shared one. +// +// Contexts that track ownership internally release themselves on Cleanup; the +// returned function is then a no-op and exists so this call site stays correct +// against context implementations that do not. +func shareContextValues(src Context, dst Context) (release func()) { + v, ok := src.(contextValuer) + if !ok { + return func() {} + } + + s, ok := dst.(contextSharer) + if !ok { + return func() {} } - type sharer interface { - ShareValues(map[string]any) + + s.ShareValues(v.Values()) + + if r, ok := dst.(contextValuesReleaser); ok { + return r.ReleaseSharedValues } - if v, ok := src.(valuer); ok { - if s, ok := dst.(sharer); ok { - s.ShareValues(v.Values()) - } + return func() { s.ShareValues(make(map[string]any)) } +} + +// cleanupBorrowedContext releases a borrowed values map and then returns the +// context to the pool. Order matters: releasing after Cleanup would put the +// context back while it still aliases the lender's map. +func cleanupBorrowedContext(ctx Context, release func()) { + release() + + if c, ok := ctx.(forge_http.ContextWithClean); ok { + c.Cleanup() } } diff --git a/internal/router/origin.go b/internal/router/origin.go new file mode 100644 index 00000000..6aa5f493 --- /dev/null +++ b/internal/router/origin.go @@ -0,0 +1,125 @@ +package router + +import ( + "net/http" + "net/url" + "strings" +) + +// Origin validation for connection upgrades (WebSocket, WebTransport). +// +// Browsers do not apply CORS to WebSocket or WebTransport upgrades, but they do +// send cookies with them. An upgrade endpoint that accepts any Origin is +// therefore hijackable: any site the user visits can open an authenticated +// socket as that user (cross-site WebSocket hijacking). CORS middleware does +// not help — it governs XHR/fetch, not upgrades. +// +// The default here is same-origin: an upgrade carrying an Origin that does not +// match the request's own host is refused. Requests with no Origin header are +// allowed, because non-browser clients (CLIs, servers, mobile SDKs) do not send +// one and browsers always do. Cross-origin browser clients must be allowed +// explicitly via configuration. + +// requestOriginAllowed reports whether an upgrade request's Origin is +// acceptable given the configured allow-list. +// +// allowedOrigins entries may be: +// - "*" allow any origin (explicit opt-in) +// - "https://app.example.com" exact scheme://host[:port] match +// - "app.example.com" bare host match, any scheme +// - "*.example.com" any strict subdomain of example.com +// +// An empty list means same-origin only. +func requestOriginAllowed(r *http.Request, allowedOrigins []string) bool { + origin := r.Header.Get("Origin") + if origin == "" { + // Non-browser client: no Origin to forge, nothing to check. + return true + } + + parsed, err := url.Parse(origin) + if err != nil || parsed.Host == "" || parsed.Scheme == "" { + return false + } + + originScheme := strings.ToLower(parsed.Scheme) + originHostPort := strings.ToLower(parsed.Host) + + if len(allowedOrigins) == 0 { + return sameOrigin(r, originHostPort) + } + + for _, allowed := range allowedOrigins { + allowed = strings.ToLower(strings.TrimSpace(allowed)) + if allowed == "" { + continue + } + + if allowed == "*" { + return true + } + + // Wildcard subdomain. Matched on label boundaries — a plain suffix + // test would accept "evil-example.com" for "*.example.com". + if rest, ok := strings.CutPrefix(allowed, "*."); ok { + if strings.HasSuffix(stripPort(originHostPort), "."+rest) { + return true + } + + continue + } + + // Exact scheme://host[:port]. + if allowed == originScheme+"://"+originHostPort { + return true + } + + // Bare host[:port], any scheme. + if allowed == originHostPort { + return true + } + } + + return false +} + +// sameOrigin reports whether the origin's host matches the host the request was +// addressed to. Scheme is not compared: a TLS-terminating proxy makes the +// server see http for an https origin, and rejecting that would break every +// deployment behind a load balancer. +func sameOrigin(r *http.Request, originHostPort string) bool { + host := strings.ToLower(r.Host) + if host == "" { + // Nothing to compare against; refuse rather than guess. + return false + } + + if host == originHostPort { + return true + } + + // Tolerate a default port on exactly one side (":443" vs bare host). + return stripDefaultPort(host) == stripDefaultPort(originHostPort) +} + +// stripPort removes a trailing ":port" if present. +func stripPort(hostPort string) string { + if i := strings.LastIndexByte(hostPort, ':'); i != -1 && !strings.Contains(hostPort[i:], "]") { + return hostPort[:i] + } + + return hostPort +} + +// stripDefaultPort removes an explicit :80 or :443 so that "example.com" and +// "example.com:443" compare equal. +func stripDefaultPort(hostPort string) string { + switch { + case strings.HasSuffix(hostPort, ":443"): + return strings.TrimSuffix(hostPort, ":443") + case strings.HasSuffix(hostPort, ":80"): + return strings.TrimSuffix(hostPort, ":80") + default: + return hostPort + } +} diff --git a/internal/router/origin_test.go b/internal/router/origin_test.go new file mode 100644 index 00000000..c75f5cec --- /dev/null +++ b/internal/router/origin_test.go @@ -0,0 +1,94 @@ +package router + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func originRequest(host, origin string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/ws", nil) + r.Host = host + + if origin != "" { + r.Header.Set("Origin", origin) + } + + return r +} + +// Connection upgrades are not covered by CORS but do carry cookies, so the +// Origin check is the only thing standing between a logged-in user and any site +// they visit opening an authenticated socket. +func TestRequestOriginAllowed(t *testing.T) { + cases := []struct { + name string + host string + origin string + allowed []string + want bool + }{ + // Default policy: same-origin. + {"no origin header is a non-browser client", "api.example.com", "", nil, true}, + {"same origin", "api.example.com", "https://api.example.com", nil, true}, + {"same origin with explicit default port", "api.example.com", "https://api.example.com:443", nil, true}, + {"cross origin denied by default", "api.example.com", "https://evil.com", nil, false}, + {"empty host cannot establish same-origin", "", "https://evil.com", nil, false}, + + // Explicit allow-list forms. + {"exact origin", "api.example.com", "https://app.example.com", []string{"https://app.example.com"}, true}, + {"bare host, any scheme", "api.example.com", "http://app.example.com", []string{"app.example.com"}, true}, + {"wildcard subdomain", "api.example.com", "https://app.example.com", []string{"*.example.com"}, true}, + {"wildcard nested subdomain", "api.example.com", "https://a.b.example.com", []string{"*.example.com"}, true}, + {"explicit star allows anything", "api.example.com", "https://evil.com", []string{"*"}, true}, + + // Regressions: a plain suffix test accepted all of these. + {"suffix bypass with hyphen", "api.example.com", "https://evil-example.com", []string{"*.example.com"}, false}, + {"suffix bypass without separator", "api.example.com", "https://notexample.com", []string{"*.example.com"}, false}, + {"wildcard does not cover the apex", "api.example.com", "https://example.com", []string{"*.example.com"}, false}, + {"port mismatch on exact entry", "api.example.com", "https://exact.test", []string{"https://exact.test:8443"}, false}, + + // Malformed origins must never match. + {"non-absolute origin", "api.example.com", "not-a-url", []string{"*.example.com"}, false}, + {"scheme-only origin", "api.example.com", "javascript:alert(1)", []string{"*.example.com"}, false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := requestOriginAllowed(originRequest(c.host, c.origin), c.allowed) + if got != c.want { + t.Errorf("requestOriginAllowed(host=%q, origin=%q, allowed=%v) = %v, want %v", + c.host, c.origin, c.allowed, got, c.want) + } + }) + } +} + +// Origin matching is case-insensitive on host, per the URL spec. +func TestRequestOriginAllowedIsCaseInsensitive(t *testing.T) { + r := originRequest("api.example.com", "https://APP.EXAMPLE.COM") + + for _, allowed := range [][]string{ + {"*.example.com"}, + {"https://app.example.com"}, + {"app.example.com"}, + } { + if !requestOriginAllowed(r, allowed) { + t.Errorf("uppercase origin rejected by allow-list %v", allowed) + } + } +} + +// WebTransport and WebSocket must enforce identical rules; an empty list used to +// mean "allow everything" on the WebTransport side. +func TestCheckWebTransportOriginDefaultsToSameOrigin(t *testing.T) { + check := checkWebTransportOrigin(nil) + + if check(originRequest("api.example.com", "https://evil.com")) { + t.Error("empty allow-list permitted a cross-origin WebTransport upgrade") + } + + if !check(originRequest("api.example.com", "https://api.example.com")) { + t.Error("empty allow-list rejected a same-origin WebTransport upgrade") + } +} diff --git a/internal/router/router.go b/internal/router/router.go index 51c6f412..dd1b215f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -128,6 +128,10 @@ type RouteConfig struct { // SensitiveFieldCleaning enables cleaning of sensitive fields in responses. SensitiveFieldCleaning bool + + // MaxBodySize caps this route's request body in bytes, overriding the + // router-wide setting. 0 inherits; negative means unlimited. + MaxBodySize int64 } // GroupConfig holds route group configuration. @@ -201,6 +205,14 @@ type routerConfig struct { asyncAPIConfig *AsyncAPIConfig metricsConfig *shared.MetricsConfig healthConfig *shared.HealthConfig + + // webSocketOrigins is the allow-list for WebSocket upgrade Origins. + // Empty means same-origin only; see origin.go. + webSocketOrigins []string + + // maxBodySize caps request bodies. 0 means DefaultMaxRequestBodySize; + // negative means unlimited. + maxBodySize int64 } // RouterAdapter wraps a routing backend. @@ -239,6 +251,15 @@ func WithTimeout(d time.Duration) RouteOption { return &timeoutOpt{d} } +// WithMaxBodySize caps this route's request body in bytes, overriding the +// router-wide limit. Use it to raise the cap on upload endpoints, or lower it on +// routes that should only ever receive small payloads. +// +// Pass a negative value for no limit on this route. +func WithMaxBodySize(bytes int64) RouteOption { + return &routeMaxBodySizeOpt{bytes} +} + func WithMetadata(key string, value any) RouteOption { return &metadataOpt{key, value} } @@ -309,3 +330,34 @@ func WithRecovery() RouterOption { func WithHTTPAddress(address string) RouterOption { return &httpAddressOpt{address} } + +// WithWebSocketOrigins sets the Origin allow-list for WebSocket upgrades. +// +// By default only same-origin upgrades are accepted, because browsers send +// cookies with WebSocket upgrades but do not apply CORS to them — an +// unrestricted upgrade endpoint lets any site open an authenticated socket as +// the visiting user. Use this to permit specific cross-origin clients. +// +// Accepted entry forms: +// +// "https://app.example.com" exact scheme://host[:port] +// "app.example.com" bare host, any scheme +// "*.example.com" any strict subdomain of example.com +// "*" any origin (disables the check — avoid in production) +// +// Requests with no Origin header are always allowed: non-browser clients do not +// send one, and browsers always do. +func WithWebSocketOrigins(origins ...string) RouterOption { + return &webSocketOriginsOpt{origins} +} + +// WithMaxRequestBodySize caps the request body for every route on this router, +// in bytes. Bodies exceeding the cap fail the read with a 413-shaped error +// instead of allocating without bound. +// +// Defaults to DefaultMaxRequestBodySize. Pass a negative value to disable the +// limit entirely (not recommended on internet-facing routes); override per route +// with WithMaxBodySize for upload endpoints. +func WithMaxRequestBodySize(bytes int64) RouterOption { + return &maxBodySizeOpt{bytes} +} diff --git a/internal/router/router_impl.go b/internal/router/router_impl.go index 69801139..a34999f4 100644 --- a/internal/router/router_impl.go +++ b/internal/router/router_impl.go @@ -58,6 +58,13 @@ type router struct { webTransportConfig WebTransportConfig http3Server any // *http3.Server + // WebSocket origin allow-list. Empty means same-origin only; see origin.go. + webSocketOrigins []string + + // maxBodySize caps request bodies for every route. 0 means use + // DefaultMaxRequestBodySize; negative means unlimited. + maxBodySize int64 + mu *sync.RWMutex // Pointer to shared mutex for groups } @@ -96,7 +103,11 @@ func newRouter(opts ...RouterOption) *router { asyncAPIConfig: cfg.asyncAPIConfig, metricsConfig: cfg.metricsConfig, healthConfig: cfg.healthConfig, - mu: mu, // Initialize shared mutex + + webSocketOrigins: cfg.webSocketOrigins, + maxBodySize: cfg.maxBodySize, + + mu: mu, // Initialize shared mutex } // Create default BunRouter adapter if none provided @@ -249,7 +260,13 @@ func (r *router) Group(prefix string, opts ...GroupOption) Router { middleware: append([]Middleware{}, r.middleware...), prefix: r.prefix + prefix, groupConfig: cfg, - mu: r.mu, // Share mutex with parent (CRITICAL for thread safety) + + // Groups inherit the parent's WebSocket origin policy; otherwise a + // group-registered socket would silently fall back to same-origin only. + webSocketOrigins: r.webSocketOrigins, + maxBodySize: r.maxBodySize, + + mu: r.mu, // Share mutex with parent (CRITICAL for thread safety) } } @@ -484,7 +501,12 @@ func (r *router) register(method, path string, handler any, opts ...RouteOption) if r.groupConfig != nil { cfg.Tags = append(cfg.Tags, r.groupConfig.Tags...) - cfg.Middleware = append(r.groupConfig.Middleware, cfg.Middleware...) + // Clone the group slice before appending. Appending straight onto + // r.groupConfig.Middleware writes into the group's backing array + // whenever it has spare capacity, so every route in the group would + // overwrite the previous route's entries. + cfg.Middleware = append(slices.Clone(r.groupConfig.Middleware), cfg.Middleware...) + for k, v := range r.groupConfig.Metadata { if cfg.Metadata == nil { cfg.Metadata = make(map[string]any) @@ -495,8 +517,12 @@ func (r *router) register(method, path string, handler any, opts ...RouteOption) } } - // Inherit group interceptors (group interceptors run before route interceptors) - cfg.Interceptors = append(r.groupConfig.Interceptors, cfg.Interceptors...) + // Inherit group interceptors (group interceptors run before route + // interceptors). Cloned for the same reason as Middleware above — + // cfg is shallow-copied into route.config and closed over by + // applyMiddlewareAndInterceptors, so an aliased slice would let one + // route silently inherit a sibling's interceptors. + cfg.Interceptors = append(slices.Clone(r.groupConfig.Interceptors), cfg.Interceptors...) // Merge skip interceptors from group if len(r.groupConfig.SkipInterceptors) > 0 { @@ -601,6 +627,7 @@ func (r *router) register(method, path string, handler any, opts ...RouteOption) routeInfo, r.container, r.errorHandler, + r.resolveMaxBodySize(cfg.MaxBodySize), ) // Wrap with panic recovery if enabled @@ -671,6 +698,32 @@ func applyMiddleware(h http.Handler, middleware []Middleware, container vessel.V }) } +// DefaultMaxRequestBodySize is the request body cap applied when neither the +// router nor the route specifies one. Chosen to be generous for JSON APIs while +// still bounding a single request's allocation; raise it per route for upload +// endpoints via WithMaxBodySize, or set a negative value for no limit. +const DefaultMaxRequestBodySize int64 = 10 << 20 // 10 MiB + +// resolveMaxBodySize picks the effective body cap for a route: the route's own +// value if set, otherwise the router's, otherwise the default. A negative value +// at either level means unlimited and is returned as 0 (no limiter installed). +func (r *router) resolveMaxBodySize(routeMax int64) int64 { + size := routeMax + if size == 0 { + size = r.maxBodySize + } + + if size == 0 { + size = DefaultMaxRequestBodySize + } + + if size < 0 { + return 0 // unlimited + } + + return size +} + // applyMiddlewareAndInterceptors applies middleware chain and interceptors to a handler. // Execution order: middleware -> interceptors -> handler // If any interceptor blocks, the handler is not executed. @@ -682,6 +735,7 @@ func applyMiddlewareAndInterceptors( routeInfo RouteInfo, container vessel.Vessel, errorHandler ErrorHandler, + maxBodySize int64, ) http.Handler { // Convert http.Handler to forge Handler that includes interceptor execution forgeHandler := func(ctx Context) error { @@ -720,6 +774,15 @@ func applyMiddlewareAndInterceptors( r = r.WithContext(context.WithValue(r.Context(), forge_http.ContextKeyForSensitiveCleaning, true)) } + // Cap the request body before any handler, binder or middleware can + // read it. Without this, decoding is bounded only by what the client + // chooses to send, so a single unauthenticated request can drive + // arbitrary allocation. MaxBytesReader also signals the server to + // close the connection once the cap is hit. + if maxBodySize > 0 && r.Body != nil { + r.Body = http.MaxBytesReader(w, r.Body, maxBodySize) + } + // Apply per-route timeout for non-streaming handlers. The server-level // WriteTimeout is 0 (disabled) to support SSE/WebSocket, so regular // REST handlers use a context deadline instead. diff --git a/internal/router/router_opts.go b/internal/router/router_opts.go index 6ba4d9cf..a0f68512 100644 --- a/internal/router/router_opts.go +++ b/internal/router/router_opts.go @@ -155,3 +155,21 @@ type httpAddressOpt struct{ address string } func (o *httpAddressOpt) Apply(cfg *routerConfig) { cfg.httpAddress = o.address } + +type webSocketOriginsOpt struct{ origins []string } + +func (o *webSocketOriginsOpt) Apply(cfg *routerConfig) { + cfg.webSocketOrigins = append(cfg.webSocketOrigins, o.origins...) +} + +type maxBodySizeOpt struct{ bytes int64 } + +func (o *maxBodySizeOpt) Apply(cfg *routerConfig) { + cfg.maxBodySize = o.bytes +} + +type routeMaxBodySizeOpt struct{ bytes int64 } + +func (o *routeMaxBodySizeOpt) Apply(cfg *RouteConfig) { + cfg.MaxBodySize = o.bytes +} diff --git a/internal/router/router_streaming.go b/internal/router/router_streaming.go index a6e4eb23..4830876c 100644 --- a/internal/router/router_streaming.go +++ b/internal/router/router_streaming.go @@ -1,6 +1,7 @@ package router import ( + "errors" "net/http" forge_http "github.com/xraph/go-utils/http" @@ -10,15 +11,25 @@ import ( func (r *router) WebSocket(path string, handler WebSocketHandler, opts ...RouteOption) error { // Convert WebSocketHandler to http.Handler httpHandler := func(w http.ResponseWriter, req *http.Request) { - // Upgrade to WebSocket - conn, err := upgradeToWebSocket(w, req) + // Upgrade to WebSocket (validates Origin first) + conn, err := upgradeToWebSocket(w, req, r.webSocketOrigins) if err != nil { + if errors.Is(err, errOriginNotAllowed) { + if r.logger != nil { + r.logger.Warn("websocket upgrade rejected: origin not allowed") + } + + http.Error(w, "Forbidden", http.StatusForbidden) + + return + } + if r.logger != nil { r.logger.Error("failed to upgrade websocket connection") } - http.Error(w, "Failed to upgrade connection", http.StatusInternalServerError) - + // ws.UpgradeHTTP has already written its own error response to the + // client, so do not write a second status line here. return } @@ -28,8 +39,10 @@ func (r *router) WebSocket(path string, handler WebSocketHandler, opts ...RouteO wsConn := newWSConnection(connID, conn, req.Context()) defer wsConn.Close() - // Create context + // Create context. Cleanup releases the DI scope and any multipart temp + // files; without it every connection leaks them for process life. ctx := forge_http.NewContext(w, req, r.container) + defer ctx.(forge_http.ContextWithClean).Cleanup() // Call handler if err := handler(ctx, wsConn); err != nil { @@ -77,8 +90,10 @@ func (r *router) EventStream(path string, handler SSEHandler, opts ...RouteOptio } defer stream.Close() - // Create context + // Create context. Cleanup releases the DI scope and any multipart temp + // files; long-lived streams are exactly where leaking them hurts most. ctx := forge_http.NewContext(w, req, r.container) + defer ctx.(forge_http.ContextWithClean).Cleanup() // Call handler if err := handler(ctx, stream); err != nil { @@ -119,8 +134,10 @@ func (r *router) SSE(path string, handler Handler, opts ...RouteOption) error { w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering - // Create context + // Create context. Cleanup releases the DI scope and any multipart temp + // files; long-lived streams are exactly where leaking them hurts most. ctx := forge_http.NewContext(w, req, r.container) + defer ctx.(forge_http.ContextWithClean).Cleanup() // Call handler - user can now use ctx.WriteSSE() if err := handler(ctx); err != nil { diff --git a/internal/router/router_streaming_test.go b/internal/router/router_streaming_test.go index f32bd7b8..b3db1693 100644 --- a/internal/router/router_streaming_test.go +++ b/internal/router/router_streaming_test.go @@ -350,7 +350,7 @@ func TestUpgradeToWebSocket_MockRequest(t *testing.T) { w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/ws", nil) - _, err := upgradeToWebSocket(w, req) + _, err := upgradeToWebSocket(w, req, nil) // Expected to fail with mock request (no upgrade header) assert.Error(t, err) } diff --git a/internal/router/router_webtransport.go b/internal/router/router_webtransport.go index ed2db6e5..04afc756 100644 --- a/internal/router/router_webtransport.go +++ b/internal/router/router_webtransport.go @@ -5,8 +5,6 @@ import ( "errors" "fmt" "net/http" - "net/url" - "strings" "time" "github.com/quic-go/quic-go" @@ -56,8 +54,10 @@ func (r *router) WebTransport(path string, handler WebTransportHandler, opts ... wtSession := newWebTransportSession(sessionID, session, req.Context()) defer wtSession.Close() - // Create context + // Create context. Cleanup releases the DI scope and any multipart temp + // files; long-lived sessions are exactly where leaking them hurts most. ctx := forge_http.NewContext(w, req, r.container) + defer ctx.(forge_http.ContextWithClean).Cleanup() // Call handler if err := handler(ctx, wtSession); err != nil { @@ -187,52 +187,16 @@ func (r *router) StopHTTP3() error { } // checkWebTransportOrigin returns an origin checker function. -// If allowedOrigins is empty, all origins are allowed. +// +// An empty allowedOrigins list means same-origin only. It previously meant +// "allow everything", which left the default configuration open to cross-site +// hijacking — browsers send cookies with WebTransport upgrades but do not apply +// CORS to them. Pass "*" to opt back into allowing any origin. +// +// Delegates to the shared checker in origin.go so WebTransport and WebSocket +// enforce identical rules, including label-boundary wildcard matching. func checkWebTransportOrigin(allowedOrigins []string) func(r *http.Request) bool { - if len(allowedOrigins) == 0 { - return func(r *http.Request) bool { return true } - } - - // Build a set for fast lookups - allowed := make(map[string]bool, len(allowedOrigins)) - allowAll := false - for _, origin := range allowedOrigins { - origin = strings.TrimSpace(origin) - if origin == "*" { - allowAll = true - break - } - allowed[strings.ToLower(origin)] = true - } - - if allowAll { - return func(r *http.Request) bool { return true } - } - return func(r *http.Request) bool { - origin := r.Header.Get("Origin") - if origin == "" { - // No origin header — allow same-origin requests - return true - } - - // Parse origin to normalize - parsed, err := url.Parse(origin) - if err != nil { - return false - } - - // Check scheme://host against allowed origins - normalized := strings.ToLower(parsed.Scheme + "://" + parsed.Host) - if allowed[normalized] { - return true - } - - // Also check just the host (for convenience) - if allowed[strings.ToLower(parsed.Host)] { - return true - } - - return false + return requestOriginAllowed(r, allowedOrigins) } } diff --git a/internal/router/streaming_sse.go b/internal/router/streaming_sse.go index 75b95a06..b6a3acf8 100644 --- a/internal/router/streaming_sse.go +++ b/internal/router/streaming_sse.go @@ -5,7 +5,9 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" + "strings" "sync" "time" ) @@ -76,8 +78,51 @@ func (s *sseStream) setWriteDeadline() func() { } } +// errInvalidSSEField is returned when a field value cannot be represented in +// the SSE wire format. +var errInvalidSSEField = errors.New("sse: field value contains a newline") + +// validSSEFieldValue reports whether v is safe to emit as a single-line SSE +// field value (event name, comment, id). +// +// A newline in one of these fields terminates the field and lets the remainder +// be parsed as further SSE fields or a whole extra event. Where any part of the +// value is caller- or user-influenced, that is event forgery: an attacker can +// append "\n\nevent: ...\ndata: ..." and deliver arbitrary events to the client. +func validSSEFieldValue(v string) bool { + return !strings.ContainsAny(v, "\r\n") +} + +// writeSSEData writes a data payload, prefixing every line with "data: " as the +// SSE grammar requires. A bare multi-line payload would otherwise both corrupt +// the message and allow field injection. +func writeSSEData(w io.Writer, data []byte) error { + // Normalize CRLF and CR to LF so line splitting matches what an SSE parser + // on the client will do. + normalized := strings.ReplaceAll(string(data), "\r\n", "\n") + normalized = strings.ReplaceAll(normalized, "\r", "\n") + + for line := range strings.SplitSeq(normalized, "\n") { + if _, err := fmt.Fprintf(w, "data: %s\n", line); err != nil { + return err + } + } + + // Blank line terminates the event. + _, err := io.WriteString(w, "\n") + + return err +} + // Send sends an event to the stream. +// +// The event name must not contain a newline; data may span multiple lines and is +// encoded per the SSE grammar. func (s *sseStream) Send(event string, data []byte) error { + if event != "" && !validSSEFieldValue(event) { + return fmt.Errorf("%w: event name %q", errInvalidSSEField, event) + } + s.mu.Lock() defer s.mu.Unlock() @@ -95,7 +140,7 @@ func (s *sseStream) Send(event string, data []byte) error { } // Write data - if _, err := fmt.Fprintf(s.writer, "data: %s\n\n", string(data)); err != nil { + if err := writeSSEData(s.writer, data); err != nil { return err } @@ -171,7 +216,13 @@ func (s *sseStream) SetRetry(milliseconds int) error { } // SendComment sends a comment (keeps connection alive). +// +// The comment must not contain a newline; see validSSEFieldValue. func (s *sseStream) SendComment(comment string) error { + if !validSSEFieldValue(comment) { + return fmt.Errorf("%w: comment %q", errInvalidSSEField, comment) + } + s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/router/streaming_websocket.go b/internal/router/streaming_websocket.go index 16d8d168..552aa8c8 100644 --- a/internal/router/streaming_websocket.go +++ b/internal/router/streaming_websocket.go @@ -4,28 +4,61 @@ import ( "context" "encoding/json" "errors" - "fmt" "net" "net/http" "sync" - "time" "github.com/gobwas/ws" "github.com/gobwas/ws/wsutil" + "github.com/google/uuid" ) // wsConnection implements Connection using gobwas/ws. type wsConnection struct { - id string - conn net.Conn - ctx context.Context //nolint:containedctx // context needed for WebSocket connection lifecycle and cancellation - cancel context.CancelFunc + id string + conn net.Conn + ctx context.Context //nolint:containedctx // context needed for WebSocket connection lifecycle and cancellation + cancel context.CancelFunc + + // readMu serializes Read: WebSocket framing is stateful, so concurrent + // readers would interleave partial frames. Held across the network read, so + // it must not be the same lock that Close and Write use. + readMu sync.Mutex + mu sync.Mutex closed bool + readLimit int64 remoteAddr string localAddr string } +// limitedConn caps how many bytes may be read from a connection for one +// message, without disturbing writes. +type limitedConn struct { + net.Conn + + remaining int64 +} + +func newLimitedConn(c net.Conn, limit int64) net.Conn { + return &limitedConn{Conn: c, remaining: limit} +} + +func (l *limitedConn) Read(p []byte) (int, error) { + if l.remaining <= 0 { + return 0, ErrMessageTooLarge + } + + if int64(len(p)) > l.remaining { + p = p[:l.remaining] + } + + n, err := l.Conn.Read(p) + l.remaining -= int64(n) + + return n, err +} + // newWSConnection creates a new WebSocket connection. func newWSConnection(id string, conn net.Conn, ctx context.Context) *wsConnection { connCtx, cancel := context.WithCancel(ctx) @@ -44,6 +77,7 @@ func newWSConnection(id string, conn net.Conn, ctx context.Context) *wsConnectio conn: conn, ctx: connCtx, cancel: cancel, + readLimit: DefaultMaxWebSocketMessageSize, remoteAddr: remoteAddr, localAddr: localAddr, } @@ -54,19 +88,50 @@ func (c *wsConnection) ID() string { return c.id } +// DefaultMaxWebSocketMessageSize caps a single inbound WebSocket message. +// Frame length is client-controlled, so an unbounded read lets one peer request +// an arbitrarily large allocation. +const DefaultMaxWebSocketMessageSize int64 = 1 << 20 // 1 MiB + +// ErrMessageTooLarge is returned when an inbound message exceeds the read limit. +var ErrMessageTooLarge = errors.New("websocket message exceeds read limit") + +// SetReadLimit overrides the maximum inbound message size for this connection. +// A value <= 0 restores the default. +func (c *wsConnection) SetReadLimit(limit int64) { + c.mu.Lock() + defer c.mu.Unlock() + + if limit <= 0 { + limit = DefaultMaxWebSocketMessageSize + } + + c.readLimit = limit +} + // Read reads a message from the WebSocket. +// +// Reads are serialized: the underlying framing is stateful, so two concurrent +// readers would interleave partial frames. func (c *wsConnection) Read() ([]byte, error) { - c.mu.Lock() + c.readMu.Lock() + defer c.readMu.Unlock() - if c.closed { - c.mu.Unlock() + c.mu.Lock() + closed := c.closed + limit := c.readLimit + c.mu.Unlock() + if closed { return nil, errors.New("connection closed") } - c.mu.Unlock() + if limit <= 0 { + limit = DefaultMaxWebSocketMessageSize + } - data, _, err := wsutil.ReadClientData(c.conn) + // Bound the read rather than trusting the advertised frame length. + data, _, err := wsutil.ReadClientData(newLimitedConn(c.conn, limit)) if err != nil { return nil, err } @@ -136,8 +201,18 @@ func (c *wsConnection) LocalAddr() string { return c.localAddr } -// upgradeToWebSocket upgrades an HTTP connection to WebSocket. -func upgradeToWebSocket(w http.ResponseWriter, r *http.Request) (net.Conn, error) { +// errOriginNotAllowed is returned when an upgrade request's Origin header is +// rejected. It is deliberately vague to the client; the detail goes to the log. +var errOriginNotAllowed = errors.New("origin not allowed") + +// upgradeToWebSocket upgrades an HTTP connection to WebSocket after validating +// the request Origin. See origin.go for why this check is mandatory rather than +// opt-in. +func upgradeToWebSocket(w http.ResponseWriter, r *http.Request, allowedOrigins []string) (net.Conn, error) { + if !requestOriginAllowed(r, allowedOrigins) { + return nil, errOriginNotAllowed + } + conn, _, _, err := ws.UpgradeHTTP(r, w) if err != nil { return nil, err @@ -147,6 +222,11 @@ func upgradeToWebSocket(w http.ResponseWriter, r *http.Request) (net.Conn, error } // generateConnectionID generates a unique connection ID. +// +// Uses a UUID rather than a timestamp: time.Now().UnixNano() collides for +// upgrades landing in the same clock tick, and two live connections sharing an +// ID means cross-talk wherever connections are tracked by ID. It is also +// trivially guessable. func generateConnectionID() string { - return fmt.Sprintf("ws_%d", time.Now().UnixNano()) + return "ws_" + uuid.NewString() } diff --git a/internal/shared/error_disclosure.go b/internal/shared/error_disclosure.go new file mode 100644 index 00000000..04203d45 --- /dev/null +++ b/internal/shared/error_disclosure.go @@ -0,0 +1,45 @@ +package shared + +import ( + "net/http" + "sync/atomic" +) + +// exposeInternalErrors controls whether server-error detail is echoed to +// clients. Set once at app construction; see SetExposeInternalErrors. +var exposeInternalErrors atomic.Bool + +// SetExposeInternalErrors controls whether 5xx responses carry internal error +// detail. The app enables it outside production so local debugging is not +// degraded, and leaves it off in production. +// +// A 5xx body built from a wrapped error commonly embeds whatever the error +// carried — driver output, SQL fragments, file paths, internal hostnames — and +// anyone able to trigger the error can read it. Detail always reaches the logs +// regardless of this setting. +func SetExposeInternalErrors(expose bool) { + exposeInternalErrors.Store(expose) +} + +// ExposeInternalErrors reports the current setting. +func ExposeInternalErrors() bool { + return exposeInternalErrors.Load() +} + +// SanitizeErrorBody returns the body to send for an error response. +// +// For 5xx it replaces the error's own body with a minimal envelope unless +// exposure is enabled: a server error is by definition not something the client +// can act on, so the detail is pure disclosure. 4xx bodies pass through +// untouched — validation messages and field errors are exactly what the client +// needs in order to correct the request. +func SanitizeErrorBody(status int, body any) any { + if status < http.StatusInternalServerError || exposeInternalErrors.Load() { + return body + } + + return map[string]any{ + "code": status, + "error": http.StatusText(status), + } +} diff --git a/internal/shared/error_disclosure_test.go b/internal/shared/error_disclosure_test.go new file mode 100644 index 00000000..07f2ea60 --- /dev/null +++ b/internal/shared/error_disclosure_test.go @@ -0,0 +1,48 @@ +package shared + +import ( + "net/http" + "testing" +) + +// A 5xx body built from a wrapped error embeds the error text. Both the router's +// fallback and DefaultErrorHandler emit these bodies, so the redaction lives here +// where both go through it. +func TestSanitizeErrorBodyRedactsServerErrors(t *testing.T) { + t.Cleanup(func() { SetExposeInternalErrors(false) }) + + leaky := map[string]any{ + "code": 500, + "details": `pq: relation "internal_billing" does not exist`, + } + + SetExposeInternalErrors(false) + + got, ok := SanitizeErrorBody(http.StatusInternalServerError, leaky).(map[string]any) + if !ok { + t.Fatalf("sanitized body has unexpected type %T", got) + } + + if _, present := got["details"]; present { + t.Errorf("server-error detail survived redaction: %v", got) + } + + if got["code"] != http.StatusInternalServerError { + t.Errorf("status code lost: %v", got["code"]) + } + + // 4xx bodies are actionable by the client and must pass through untouched. + clientErr := map[string]any{"code": 400, "details": "field 'email' is required"} + if out := SanitizeErrorBody(http.StatusBadRequest, clientErr); out == nil { + t.Error("client-error body was dropped") + } else if m, _ := out.(map[string]any); m["details"] != "field 'email' is required" { + t.Errorf("client-error detail was altered: %v", m) + } + + // With exposure on, 5xx detail is preserved for local debugging. + SetExposeInternalErrors(true) + + if out, _ := SanitizeErrorBody(http.StatusInternalServerError, leaky).(map[string]any); out["details"] == nil { + t.Error("exposure enabled but detail was still redacted") + } +} diff --git a/internal/shared/errors.go b/internal/shared/errors.go index 6a82010c..8417d1ae 100644 --- a/internal/shared/errors.go +++ b/internal/shared/errors.go @@ -57,9 +57,13 @@ func (h *DefaultErrorHandler) HandleError(ctx context.Context, err error) error // Check if error implements HTTPResponder var responder HTTPResponder if errors.As(err, &responder) { - // Error knows how to format itself + // Error knows how to format itself. Server-error bodies are redacted + // unless exposure is enabled — an InternalError's body embeds the wrapped + // error text, which is not the client's business. if c, ok := ctx.(Context); ok { - return c.JSON(responder.StatusCode(), responder.ResponseBody()) + status := responder.StatusCode() + + return c.JSON(status, SanitizeErrorBody(status, responder.ResponseBody())) } } diff --git a/middleware/cors.go b/middleware/cors.go index 5a7f6e01..2e813e54 100644 --- a/middleware/cors.go +++ b/middleware/cors.go @@ -1,7 +1,9 @@ package middleware import ( + "net" "net/http" + "net/url" "slices" "strconv" "strings" @@ -45,24 +47,67 @@ func DefaultCORSConfig() CORSConfig { } } +// originHost extracts the host (including port, lowercased) from an origin +// header value. Returns "" if the origin is not a parseable absolute URL, which +// callers must treat as "not allowed". +func originHost(requestOrigin string) string { + parsed, err := url.Parse(requestOrigin) + if err != nil || parsed.Host == "" || parsed.Scheme == "" { + return "" + } + + return strings.ToLower(parsed.Host) +} + +// matchesWildcardDomain reports whether host is a subdomain of domain. +// +// The match is on host labels, not on the raw string. A plain suffix test would +// accept "evil-example.com" for "*.example.com" — any domain merely ending in +// the allowed one — which lets an attacker register a matching domain and, when +// AllowCredentials is set, read authenticated responses cross-origin. +func matchesWildcardDomain(host, domain string) bool { + if domain == "" { + return false + } + + // Strip the port before comparing labels; ports are matched separately by + // exact-origin entries. + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + + // A wildcard covers strict subdomains only, so the host must be longer than + // the domain and the extra part must end on a label boundary. + return strings.HasSuffix(host, "."+domain) +} + // isOriginAllowed checks if the request origin is in the allowed list. func isOriginAllowed(requestOrigin string, allowedOrigins []string) bool { if len(allowedOrigins) == 0 { return false } + host := originHost(requestOrigin) + for _, allowed := range allowedOrigins { if allowed == "*" { return true } + // Exact origin match (scheme + host + port), case-sensitive on scheme + // per the Origin serialization, so compare the raw values. if allowed == requestOrigin { return true } + // Support wildcard subdomains like *.example.com if strings.HasPrefix(allowed, "*.") { - domain := allowed[2:] - if strings.HasSuffix(requestOrigin, domain) { + if host == "" { + // Unparseable origin: never match a wildcard. + continue + } + + if matchesWildcardDomain(host, strings.ToLower(allowed[2:])) { return true } } diff --git a/middleware/logging.go b/middleware/logging.go index b77e8912..87a0fef6 100644 --- a/middleware/logging.go +++ b/middleware/logging.go @@ -1,11 +1,16 @@ package middleware import ( + "net/http" + "strings" "time" forge "github.com/xraph/forge" ) +// redactedValue replaces the value of a sensitive header in logs. +const redactedValue = "[REDACTED]" + // LoggingConfig defines configuration for logging middleware. type LoggingConfig struct { // IncludeHeaders includes request headers in logs @@ -40,6 +45,13 @@ func LoggingWithConfig(logger forge.Logger, config LoggingConfig) forge.Middlewa excludeMap[path] = true } + // Pre-lower the sensitive header names once; HTTP header names are + // case-insensitive, so the comparison must be too. + sensitive := make(map[string]bool, len(config.SensitiveHeaders)) + for _, h := range config.SensitiveHeaders { + sensitive[strings.ToLower(h)] = true + } + return func(next forge.Handler) forge.Handler { return func(ctx forge.Context) error { // Skip excluded paths @@ -47,22 +59,61 @@ func LoggingWithConfig(logger forge.Logger, config LoggingConfig) forge.Middlewa return next(ctx) } + r := ctx.Request() + // Start timing start := time.Now() + fields := []forge.Field{ + forge.F("method", r.Method), + forge.F("path", r.URL.Path), + } + + if config.IncludeHeaders { + fields = append(fields, forge.F("headers", redactHeaders(r.Header, sensitive))) + } + // Log request start - logger.Info("request started") + logger.Info("request started", fields...) // Process request err := next(ctx) - // Calculate duration - _ = time.Since(start) + // Log request completion, including the measured duration + completion := []forge.Field{ + forge.F("method", r.Method), + forge.F("path", r.URL.Path), + forge.F("duration", time.Since(start)), + } + if err != nil { + completion = append(completion, forge.F("error", err.Error())) + } - // Log request completion - logger.Info("request completed") + logger.Info("request completed", completion...) return err } } } + +// redactHeaders copies headers for logging, replacing the values of any header +// named in sensitive. +// +// Redaction happens here because the configured header list is the only place +// that knows which values are secrets — logging the raw header map would put +// bearer tokens and session cookies straight into the log. +func redactHeaders(header http.Header, sensitive map[string]bool) map[string]string { + out := make(map[string]string, len(header)) + + for name, values := range header { + if sensitive[strings.ToLower(name)] { + out[name] = redactedValue + + continue + } + + out[name] = strings.Join(values, ", ") + } + + return out +} diff --git a/middleware/rate_limit.go b/middleware/rate_limit.go index f2e0bf56..ecec90c3 100644 --- a/middleware/rate_limit.go +++ b/middleware/rate_limit.go @@ -1,6 +1,7 @@ package middleware import ( + "net" "net/http" "sync" "time" @@ -8,13 +9,21 @@ import ( forge "github.com/xraph/forge" ) +// defaultMaxBuckets caps how many distinct keys a RateLimiter tracks. Without a +// cap the bucket map is an unbounded, attacker-driven allocation: every new key +// adds an entry and cleanup only runs periodically. +const defaultMaxBuckets = 100_000 + // RateLimiter implements token bucket algorithm for rate limiting. type RateLimiter struct { - mu sync.Mutex - buckets map[string]*bucket - rate int // tokens per second - capacity int // max tokens - cleanup time.Duration // cleanup interval + mu sync.Mutex + buckets map[string]*bucket + rate int // tokens per second + capacity int // max tokens + cleanup time.Duration // cleanup interval + maxBuckets int + stopOnce sync.Once + stopCh chan struct{} } type bucket struct { @@ -25,12 +34,17 @@ type bucket struct { // NewRateLimiter creates a new rate limiter // rate: maximum requests per second // burst: maximum burst size (capacity) +// +// Call Stop when the limiter is no longer needed to release its cleanup +// goroutine. func NewRateLimiter(rate, burst int) *RateLimiter { rl := &RateLimiter{ - buckets: make(map[string]*bucket), - rate: rate, - capacity: burst, - cleanup: 5 * time.Minute, + buckets: make(map[string]*bucket), + rate: rate, + capacity: burst, + cleanup: 5 * time.Minute, + maxBuckets: defaultMaxBuckets, + stopCh: make(chan struct{}), } // Start cleanup goroutine @@ -39,6 +53,24 @@ func NewRateLimiter(rate, burst int) *RateLimiter { return rl } +// SetMaxBuckets overrides how many distinct keys are tracked before new keys +// are rejected. Values below 1 are ignored. +func (rl *RateLimiter) SetMaxBuckets(maxBuckets int) { + if maxBuckets < 1 { + return + } + + rl.mu.Lock() + defer rl.mu.Unlock() + + rl.maxBuckets = maxBuckets +} + +// Stop terminates the cleanup goroutine. Safe to call more than once. +func (rl *RateLimiter) Stop() { + rl.stopOnce.Do(func() { close(rl.stopCh) }) +} + // Allow checks if a request from the given key should be allowed. func (rl *RateLimiter) Allow(key string) bool { rl.mu.Lock() @@ -49,11 +81,17 @@ func (rl *RateLimiter) Allow(key string) bool { // Get or create bucket b, exists := rl.buckets[key] if !exists { - b = &bucket{ + // At capacity: deny rather than grow without bound. Denying is the + // safe direction — evicting instead would let an attacker flush + // legitimate clients' buckets and reset their limits. + if len(rl.buckets) >= rl.maxBuckets { + return false + } + + rl.buckets[key] = &bucket{ tokens: rl.capacity - 1, // -1 for this request lastCheck: now, } - rl.buckets[key] = b return true } @@ -84,26 +122,69 @@ func (rl *RateLimiter) cleanupLoop() { ticker := time.NewTicker(rl.cleanup) defer ticker.Stop() - for range ticker.C { - rl.mu.Lock() - - now := time.Now() - for key, b := range rl.buckets { - if now.Sub(b.lastCheck) > rl.cleanup { - delete(rl.buckets, key) + for { + select { + case <-rl.stopCh: + return + case <-ticker.C: + rl.mu.Lock() + + now := time.Now() + for key, b := range rl.buckets { + if now.Sub(b.lastCheck) > rl.cleanup { + delete(rl.buckets, key) + } } + + rl.mu.Unlock() } + } +} - rl.mu.Unlock() +// ClientIP returns the client's IP address from RemoteAddr, discarding the port. +// +// This is the default rate-limit key. Using RemoteAddr verbatim does not work: +// Go's HTTP server sets it to "IP:ephemeral-port" and the port changes per +// connection, so every connection would get a fresh bucket — no effective limit +// at all, plus unbounded map growth. +// +// This deliberately ignores X-Forwarded-For. Behind a proxy every client +// collapses into the proxy's IP, so supply a RateLimitKeyFunc that reads the +// forwarded header only for hops you actually trust — reading it +// unconditionally is a header-spoofing bypass. +func ClientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + // RemoteAddr carried no port (some test servers, unix sockets). + return r.RemoteAddr } + + return host } -// RateLimit middleware enforces rate limiting per client. +// RateLimitKeyFunc derives the bucket key for a request. Return "" to skip rate +// limiting for that request. +type RateLimitKeyFunc func(*http.Request) string + +// RateLimit middleware enforces rate limiting per client, keyed on client IP. func RateLimit(limiter *RateLimiter, logger forge.Logger) forge.Middleware { + return RateLimitWithKey(limiter, ClientIP, logger) +} + +// RateLimitWithKey enforces rate limiting using a caller-supplied key function. +// Use this to key on an authenticated user ID, an API key, or a trusted +// forwarded-for hop instead of the client IP. +func RateLimitWithKey(limiter *RateLimiter, keyFunc RateLimitKeyFunc, logger forge.Logger) forge.Middleware { + if keyFunc == nil { + keyFunc = ClientIP + } + return func(next forge.Handler) forge.Handler { return func(ctx forge.Context) error { - // Use remote address as key (could also use user ID, API key, etc.) - key := ctx.Request().RemoteAddr + key := keyFunc(ctx.Request()) + if key == "" { + return next(ctx) + } if !limiter.Allow(key) { if logger != nil { diff --git a/middleware/recovery.go b/middleware/recovery.go index b2b6fbc4..ddf7af7c 100644 --- a/middleware/recovery.go +++ b/middleware/recovery.go @@ -1,28 +1,69 @@ package middleware import ( + "errors" "fmt" "net/http" + "runtime/debug" forge "github.com/xraph/forge" ) -// Recovery middleware recovers from panics and logs them +// Recovery middleware recovers from panics and logs them with a stack trace. // Returns http.StatusInternalServerError on panic. +// +// http.ErrAbortHandler is deliberately re-panicked: net/http uses it as the +// signal to drop the connection without logging, and swallowing it here would +// turn an intentional abort into a bogus 500. func Recovery(logger forge.Logger) forge.Middleware { return func(next forge.Handler) forge.Handler { return func(ctx forge.Context) error { defer func() { - if err := recover(); err != nil { - // Log the panic with stack trace - logger.Error(fmt.Sprintf("panic recovered: %v", err)) + rec := recover() + if rec == nil { + return + } + + if err, ok := rec.(error); ok && errors.Is(err, http.ErrAbortHandler) { + panic(rec) + } - // Return http.StatusInternalServerError error - _ = ctx.String(http.StatusInternalServerError, "Internal Server Error") + // Guard the logger: a nil logger here would panic inside the + // deferred recover, killing the connection instead of returning + // the 500 this middleware exists to produce. Other middleware in + // this package already treat the logger as optional. + if logger != nil { + logger.Error(fmt.Sprintf("panic recovered: %v\n%s", rec, debug.Stack())) } + + // If the handler already wrote a response before panicking, the + // status line is spent; writing another emits a superfluous + // header and appends error text to a partial body. Leave it + // alone — the log carries the detail. + if responseAlreadyWritten(ctx) { + return + } + + _ = ctx.String(http.StatusInternalServerError, "Internal Server Error") }() return next(ctx) } } } + +// responseAlreadyWritten reports whether the response writer has already +// committed a status code. +// +// It relies on the writer exposing Written(); when the writer is not wrapped +// (so there is nothing to ask) it reports false and Recovery falls back to +// writing the 500, matching the previous behaviour. +func responseAlreadyWritten(ctx forge.Context) bool { + type wroteReporter interface{ Written() bool } + + if w, ok := ctx.Response().(wroteReporter); ok { + return w.Written() + } + + return false +} diff --git a/middleware/request_id.go b/middleware/request_id.go index 135df393..de64bed5 100644 --- a/middleware/request_id.go +++ b/middleware/request_id.go @@ -2,6 +2,7 @@ package middleware import ( "context" + "strings" "github.com/google/uuid" forge "github.com/xraph/forge" @@ -12,29 +13,66 @@ type RequestIDContextKey string const requestIDKey RequestIDContextKey = "request_id" +// RequestIDForgeKey is the forge-context key under which the request ID is +// stored. Exported so callers can read it without guessing the string. +const RequestIDForgeKey = "request_id" + +// maxRequestIDLength bounds an inbound X-Request-ID. The value is echoed into +// the response and into every log line for the request, so an unbounded one lets +// a client inflate log volume at will. +const maxRequestIDLength = 128 + // RequestID middleware adds a unique request ID to each request -// If X-Request-ID header is present, it uses that, otherwise generates a new UUID. +// If X-Request-ID header is present and well-formed, it is used; otherwise a new +// UUID is generated. func RequestID() forge.Middleware { return func(next forge.Handler) forge.Handler { return func(ctx forge.Context) error { // Check for existing request ID in header - requestID := ctx.Request().Header.Get("X-Request-ID") + requestID := sanitizeRequestID(ctx.Request().Header.Get("X-Request-ID")) if requestID == "" { - // Generate new UUID if not present - requestID = uuid.New().String() + // Generate new UUID if not present or not usable + requestID = uuid.NewString() } // Set response header ctx.Response().Header().Set("X-Request-ID", requestID) // Add to Forge context - ctx.Set("request_id", requestID) + ctx.Set(RequestIDForgeKey, requestID) + + // Also store on the stdlib request context so GetRequestID works for + // code that only has a context.Context — background jobs, database + // hooks, anything below the handler. Previously nothing wrote this + // key, so GetRequestID always returned "". + ctx.WithContext(context.WithValue(ctx.Context(), requestIDKey, requestID)) return next(ctx) } } } +// sanitizeRequestID accepts a client-supplied correlation ID only if it is safe +// to echo and to log: bounded in length and limited to printable, non-space +// characters. Returns "" when the value should be replaced with a fresh UUID. +// +// Rejecting rather than escaping keeps the ID usable as a log-correlation token; +// a value carrying spaces or control characters would break naive log parsers +// even though net/http itself strips newlines from header values. +func sanitizeRequestID(v string) string { + if v == "" || len(v) > maxRequestIDLength { + return "" + } + + if strings.ContainsFunc(v, func(r rune) bool { + return r < '!' || r > '~' + }) { + return "" + } + + return v +} + // GetRequestID retrieves the request ID from standard context. func GetRequestID(ctx context.Context) string { if requestID, ok := ctx.Value(requestIDKey).(string); ok { @@ -46,7 +84,7 @@ func GetRequestID(ctx context.Context) string { // GetRequestIDFromForgeContext retrieves the request ID from Forge context. func GetRequestIDFromForgeContext(ctx forge.Context) string { - val := ctx.Get("request_id") + val := ctx.Get(RequestIDForgeKey) if val == nil { return "" } diff --git a/middleware/response_writer.go b/middleware/response_writer.go index c2e8e1ab..71e1f503 100644 --- a/middleware/response_writer.go +++ b/middleware/response_writer.go @@ -54,6 +54,12 @@ func (w *ResponseWriter) Size() int { return w.size } +// Written reports whether a status code has been committed to the client. +// Recovery uses this to avoid writing a second header over a partial response. +func (w *ResponseWriter) Written() bool { + return w.wroteHeader +} + // Flush implements http.Flusher. func (w *ResponseWriter) Flush() { if flusher, ok := w.ResponseWriter.(http.Flusher); ok { diff --git a/middleware/security_regression_test.go b/middleware/security_regression_test.go new file mode 100644 index 00000000..7158873e --- /dev/null +++ b/middleware/security_regression_test.go @@ -0,0 +1,215 @@ +package middleware + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// A wildcard entry like "*.example.com" was matched with strings.HasSuffix, so +// any domain merely ending in "example.com" was accepted. Since getAllowOrigin +// reflects the request origin back and this path is reachable with +// AllowCredentials, an attacker could register a matching domain and read +// authenticated responses. +func TestCORSWildcardMatchesOnLabelBoundaries(t *testing.T) { + allowed := []string{"*.example.com", "https://exact.test:8443"} + + deny := map[string]string{ + "https://evil-example.com": "hyphen prefix is a different domain", + "https://notexample.com": "no label separator", + "https://wwwexample.com": "no label separator", + "https://example.com": "apex is not a subdomain", + "https://exact.test": "port must match an exact entry", + "not-a-url": "unparseable origin", + "": "empty origin", + } + + for origin, why := range deny { + if isOriginAllowed(origin, allowed) { + t.Errorf("origin %q should be denied (%s)", origin, why) + } + } + + permit := []string{ + "https://app.example.com", + "http://deep.nested.example.com", + "https://APP.EXAMPLE.COM", + "https://exact.test:8443", + } + + for _, origin := range permit { + if !isOriginAllowed(origin, allowed) { + t.Errorf("origin %q should be allowed", origin) + } + } +} + +// The limiter keyed on RemoteAddr, which Go sets to "IP:ephemeral-port". A new +// port per connection meant a new bucket per connection: no effective limit, and +// unbounded map growth driven by the client. +func TestRateLimitKeyIgnoresEphemeralPort(t *testing.T) { + for port := range 5 { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = fmt.Sprintf("203.0.113.7:%d", 40000+port) + + if got := ClientIP(r); got != "203.0.113.7" { + t.Fatalf("ClientIP(%q) = %q, want 203.0.113.7", r.RemoteAddr, got) + } + } + + limiter := NewRateLimiter(1, 3) // 1/s, burst 3 + defer limiter.Stop() + + allowed := 0 + + for port := range 100 { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.RemoteAddr = fmt.Sprintf("203.0.113.7:%d", 40000+port) + + if limiter.Allow(ClientIP(r)) { + allowed++ + } + } + + if allowed > 3 { + t.Errorf("rate limit bypassed: %d/100 requests allowed from one IP, burst is 3", allowed) + } + + if len(limiter.buckets) != 1 { + t.Errorf("bucket per connection: %d buckets tracked for a single IP", len(limiter.buckets)) + } +} + +// Bucket count must be bounded; the map was previously attacker-driven. +func TestRateLimiterBoundsTrackedKeys(t *testing.T) { + limiter := NewRateLimiter(1, 5) + defer limiter.Stop() + + limiter.SetMaxBuckets(10) + + for i := range 500 { + limiter.Allow(fmt.Sprintf("10.0.0.%d", i)) + } + + if len(limiter.buckets) > 10 { + t.Errorf("tracked %d keys, cap is 10", len(limiter.buckets)) + } +} + +// Stop must be idempotent — it is a natural thing to call from a shutdown hook +// that may run more than once. +func TestRateLimiterStopIsIdempotent(t *testing.T) { + limiter := NewRateLimiter(1, 1) + + limiter.Stop() + limiter.Stop() +} + +// The Timeout middleware buffers responses so it can substitute a 504. It must +// stop buffering when the handler flushes, or streaming responses stall until +// the handler returns. +func TestTimeoutMiddlewarePassesFlushThrough(t *testing.T) { + var sawFlusher bool + + h := Timeout(time.Second, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + f, ok := w.(http.Flusher) + sawFlusher = ok + + if !ok { + return + } + + _, _ = w.Write([]byte("chunk")) + + f.Flush() + })) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/stream", nil)) + + if !sawFlusher { + t.Fatal("handler did not receive an http.Flusher; streaming is broken through Timeout") + } + + if body := rec.Body.String(); body != "chunk" { + t.Errorf("body = %q, want %q", body, "chunk") + } +} + +// A response larger than the buffer limit must not be held in memory. +func TestTimeoutMiddlewareBoundsItsBuffer(t *testing.T) { + payload := make([]byte, DefaultTimeoutBufferLimit+4096) + for i := range payload { + payload[i] = 'x' + } + + h := Timeout(5*time.Second, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(payload) + })) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/big", nil)) + + if got := rec.Body.Len(); got != len(payload) { + t.Errorf("wrote %d bytes, want %d", got, len(payload)) + } +} + +// An inbound X-Request-ID is echoed into the response and every log line, so it +// must be bounded and free of characters that would break log parsing. +func TestRequestIDRejectsHostileInboundValues(t *testing.T) { + reject := map[string]string{ + "": "empty", + "a b": "contains a space", + "a\tb": "contains a tab", + "\x00": "contains a NUL", + } + + for v, why := range reject { + if got := sanitizeRequestID(v); got != "" { + t.Errorf("sanitizeRequestID(%q) = %q, want rejected (%s)", v, got, why) + } + } + + long := make([]byte, maxRequestIDLength+1) + for i := range long { + long[i] = 'a' + } + + if got := sanitizeRequestID(string(long)); got != "" { + t.Errorf("over-length ID accepted (%d chars)", len(got)) + } + + const ok = "7f3a9c21-4b6e-11ee-be56-0242ac120002" + if got := sanitizeRequestID(ok); got != ok { + t.Errorf("sanitizeRequestID(%q) = %q, want it preserved", ok, got) + } +} + +// Sensitive headers must be redacted when header logging is on; the config field +// existed but was never read. +func TestLoggingRedactsSensitiveHeaders(t *testing.T) { + sensitive := map[string]bool{"authorization": true, "cookie": true} + + header := http.Header{} + header.Set("Authorization", "Bearer super-secret") + header.Set("X-Trace-Id", "trace-1") + + // Assigned directly rather than via Set, which would canonicalize it. Header + // names are case-insensitive, so redaction must match regardless of casing. + header["cookie"] = []string{"session=abc123"} + + got := redactHeaders(header, sensitive) + + for _, name := range []string{"Authorization", "cookie"} { + if v := got[name]; v != redactedValue { + t.Errorf("header %s = %q, want %q", name, v, redactedValue) + } + } + + if got["X-Trace-Id"] != "trace-1" { + t.Errorf("non-sensitive header was altered: %q", got["X-Trace-Id"]) + } +} diff --git a/middleware/timeout.go b/middleware/timeout.go index a61fd3d0..d517cbf9 100644 --- a/middleware/timeout.go +++ b/middleware/timeout.go @@ -1,8 +1,10 @@ package middleware import ( + "bufio" "context" "maps" + "net" "net/http" "sync" "time" @@ -10,16 +12,32 @@ import ( forge "github.com/xraph/forge" ) -// safeResponseWriter wraps http.ResponseWriter to prevent race conditions -// by completely buffering the response until flush is called. +// DefaultTimeoutBufferLimit bounds how much of a response the Timeout +// middleware will hold in memory before it gives up on buffering and starts +// passing writes straight through. Buffering exists so a timeout can replace a +// partially written response; past this size that is no longer worth the memory, +// and an unbounded buffer would let any large response drive allocation. +const DefaultTimeoutBufferLimit = 1 << 20 // 1 MiB + +// safeResponseWriter wraps http.ResponseWriter and buffers the response so a +// timeout can substitute its own response instead of racing a half-written one. +// +// Buffering is abandoned — and writes go straight to the client — as soon as +// any of the following happens, because each means the response is already +// committed or must not be held: +// - the buffer exceeds bufferLimit +// - the handler calls Flush (streaming: the client is waiting on bytes now) +// - the handler calls Hijack (WebSocket upgrade: we no longer own the conn) type safeResponseWriter struct { http.ResponseWriter - mu sync.Mutex - header http.Header - code int - body []byte - flushed bool + mu sync.Mutex + header http.Header + code int + body []byte + flushed bool + passthrough bool + bufferLimit int } func newSafeResponseWriter(w http.ResponseWriter) *safeResponseWriter { @@ -27,10 +45,18 @@ func newSafeResponseWriter(w http.ResponseWriter) *safeResponseWriter { ResponseWriter: w, header: make(http.Header), code: http.StatusOK, + bufferLimit: DefaultTimeoutBufferLimit, } } func (w *safeResponseWriter) Header() http.Header { + w.mu.Lock() + defer w.mu.Unlock() + + if w.passthrough { + return w.ResponseWriter.Header() + } + return w.header } @@ -38,6 +64,12 @@ func (w *safeResponseWriter) WriteHeader(code int) { w.mu.Lock() defer w.mu.Unlock() + if w.passthrough { + w.ResponseWriter.WriteHeader(code) + + return + } + if !w.flushed { w.code = code } @@ -47,31 +79,107 @@ func (w *safeResponseWriter) Write(data []byte) (int, error) { w.mu.Lock() defer w.mu.Unlock() - if !w.flushed { - w.body = append(w.body, data...) + if w.flushed || w.passthrough { + return w.ResponseWriter.Write(data) + } + + // Buffering this write would exceed the cap: commit what we have and let + // this and every later write go directly to the client. + if len(w.body)+len(data) > w.bufferLimit { + w.commitLocked() - return len(data), nil + return w.ResponseWriter.Write(data) } - return w.ResponseWriter.Write(data) + w.body = append(w.body, data...) + + return len(data), nil +} + +// commitLocked writes the buffered head of the response to the client and +// switches to passthrough. Caller must hold w.mu. +func (w *safeResponseWriter) commitLocked() { + if w.passthrough || w.flushed { + return + } + + maps.Copy(w.ResponseWriter.Header(), w.header) + w.ResponseWriter.WriteHeader(w.code) + + if len(w.body) > 0 { + _, _ = w.ResponseWriter.Write(w.body) + w.body = nil + } + + w.passthrough = true +} + +// Flush implements http.Flusher. A handler that flushes is streaming, so the +// response must stop being buffered — otherwise SSE and chunked responses stall +// until the handler returns. +func (w *safeResponseWriter) Flush() { + w.mu.Lock() + w.commitLocked() + w.mu.Unlock() + + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Hijack implements http.Hijacker so WebSocket upgrades survive this middleware. +// Buffering is committed first: once the connection is hijacked this wrapper can +// no longer write to it. +func (w *safeResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hijacker, ok := w.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, http.ErrNotSupported + } + + w.mu.Lock() + w.passthrough = true + w.body = nil + w.mu.Unlock() + + return hijacker.Hijack() +} + +// Push implements http.Pusher. +func (w *safeResponseWriter) Push(target string, opts *http.PushOptions) error { + if p, ok := w.ResponseWriter.(http.Pusher); ok { + return p.Push(target, opts) + } + + return http.ErrNotSupported +} + +// committed reports whether anything has already reached the client, in which +// case the timeout path must not try to write its own status line. +func (w *safeResponseWriter) committed() bool { + w.mu.Lock() + defer w.mu.Unlock() + + return w.passthrough || w.flushed } func (w *safeResponseWriter) flush() { w.mu.Lock() defer w.mu.Unlock() - if !w.flushed { - // Copy headers to underlying writer - maps.Copy(w.ResponseWriter.Header(), w.header) + if w.passthrough || w.flushed { + return + } - w.ResponseWriter.WriteHeader(w.code) + // Copy headers to underlying writer + maps.Copy(w.ResponseWriter.Header(), w.header) - if len(w.body) > 0 { - _, _ = w.ResponseWriter.Write(w.body) - } + w.ResponseWriter.WriteHeader(w.code) - w.flushed = true + if len(w.body) > 0 { + _, _ = w.ResponseWriter.Write(w.body) } + + w.flushed = true } // Timeout middleware enforces a timeout on request handling @@ -105,10 +213,19 @@ func Timeout(duration time.Duration, logger forge.Logger) func(http.Handler) htt return case <-ctx.Done(): - // Timeout occurred - write timeout response directly to underlying writer if logger != nil { logger.Warn("request timeout") } + + // If the handler already committed bytes to the client (it + // streamed, hijacked, or overflowed the buffer) there is no + // status line left to write — doing so would emit a second + // header and corrupt the response. The context deadline is the + // handler's signal to stop. + if safeW.committed() { + return + } + // Write timeout response directly to avoid race with buffered response w.WriteHeader(http.StatusGatewayTimeout) _, _ = w.Write([]byte("Gateway Timeout")) diff --git a/router.go b/router.go index 4ab6d189..e59fc931 100644 --- a/router.go +++ b/router.go @@ -98,6 +98,13 @@ func WithTimeout(d time.Duration) RouteOption { return router.WithTimeout(d) } +// WithMaxBodySize caps this route's request body in bytes, overriding the +// app-wide MaxRequestBodySize. Raise it for upload endpoints; pass a negative +// value for no limit on this route. +func WithMaxBodySize(bytes int64) RouteOption { + return router.WithMaxBodySize(bytes) +} + func WithMetadata(key string, value any) RouteOption { return router.WithMetadata(key, value) }