From e1899aecc6b6c54697314c4ec6c9bf0782e00a5d Mon Sep 17 00:00:00 2001 From: phyce Date: Mon, 6 Jul 2026 10:38:10 +0000 Subject: [PATCH] Harden HTTP server auth: default-deny admin, constant-time compare - AdminAuthMiddleware now returns 403 when no admin key is configured instead of granting access to /config (which exposes API keys) - Use crypto/subtle.ConstantTimeCompare for all token checks - Document the intentional unauthenticated mode on AuthMiddleware Co-Authored-By: Claude Opus 4.8 (1M context) --- app/server/http/middleware/auth.go | 157 ++++++++++++++++------------- 1 file changed, 87 insertions(+), 70 deletions(-) diff --git a/app/server/http/middleware/auth.go b/app/server/http/middleware/auth.go index c483f5b..f9a7b91 100644 --- a/app/server/http/middleware/auth.go +++ b/app/server/http/middleware/auth.go @@ -1,70 +1,87 @@ -package middleware - -import ( - "net/http" - "nstudio/app/config" - "strings" - - "github.com/labstack/echo/v4" -) - -func AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { - return func(context echo.Context) error { - authSettings := config.GetSettings().Server.Auth - - if authSettings.Key == "" && authSettings.AdminKey == "" { - return next(context) - } - - authHeader := context.Request().Header.Get("Authorization") - if strings.HasPrefix(authHeader, "Bearer ") { - token := strings.TrimPrefix(authHeader, "Bearer ") - - if token == authSettings.Key || token == authSettings.AdminKey { - return next(context) - } - } - - queryAuth := context.QueryParam("auth") - if queryAuth != "" && (queryAuth == authSettings.Key || queryAuth == authSettings.AdminKey) { - return next(context) - } - - return context.JSON(http.StatusUnauthorized, map[string]string{ - "error": "Unauthorized", - "message": "Valid authentication required", - }) - } -} - -func AdminAuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { - return func(context echo.Context) error { - authSettings := config.GetSettings().Server.Auth - - if authSettings.AdminKey == "" { - return next(context) - //return context.JSON(http.StatusForbidden, map[string]string{ - // "error": "Forbidden", - // "message": "Admin key not configured", - //}) - } - - authHeader := context.Request().Header.Get("Authorization") - if strings.HasPrefix(authHeader, "Bearer ") { - token := strings.TrimPrefix(authHeader, "Bearer ") - if token == authSettings.AdminKey { - return next(context) - } - } - - queryAuth := context.QueryParam("auth") - if queryAuth == authSettings.AdminKey { - return next(context) - } - - return context.JSON(http.StatusForbidden, map[string]string{ - "error": "Forbidden", - "message": "Admin authentication required", - }) - } -} +package middleware + +import ( + "crypto/subtle" + "net/http" + "nstudio/app/config" + "strings" + + "github.com/labstack/echo/v4" +) + +// secureEqual reports whether a and b are equal using a constant-time +// comparison. An empty configured key (b) never matches, so unset keys +// cannot be satisfied by an empty token. +func secureEqual(a, b string) bool { + if b == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +func AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(context echo.Context) error { + authSettings := config.GetSettings().Server.Auth + + // SECURITY: When neither Key nor AdminKey is configured, the server + // runs in an intentional unauthenticated mode so users can operate a + // local, keyless instance. This fail-open behavior applies ONLY to the + // non-admin middleware; AdminAuthMiddleware always requires a key. + if authSettings.Key == "" && authSettings.AdminKey == "" { + return next(context) + } + + authHeader := context.Request().Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + token := strings.TrimPrefix(authHeader, "Bearer ") + + if secureEqual(token, authSettings.Key) || secureEqual(token, authSettings.AdminKey) { + return next(context) + } + } + + queryAuth := context.QueryParam("auth") + if secureEqual(queryAuth, authSettings.Key) || secureEqual(queryAuth, authSettings.AdminKey) { + return next(context) + } + + return context.JSON(http.StatusUnauthorized, map[string]string{ + "error": "Unauthorized", + "message": "Valid authentication required", + }) + } +} + +func AdminAuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(context echo.Context) error { + authSettings := config.GetSettings().Server.Auth + + // SECURITY: Default-deny. Admin endpoints (e.g. /config, which reads and + // writes API keys and engine paths) must never be exposed without an + // explicitly configured admin key. + if authSettings.AdminKey == "" { + return context.JSON(http.StatusForbidden, map[string]string{ + "error": "Forbidden", + "message": "Admin key not configured", + }) + } + + authHeader := context.Request().Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + token := strings.TrimPrefix(authHeader, "Bearer ") + if secureEqual(token, authSettings.AdminKey) { + return next(context) + } + } + + queryAuth := context.QueryParam("auth") + if secureEqual(queryAuth, authSettings.AdminKey) { + return next(context) + } + + return context.JSON(http.StatusForbidden, map[string]string{ + "error": "Forbidden", + "message": "Admin authentication required", + }) + } +}