From b92f2ed6407e8d80b5073e30eb66957d5d92fa70 Mon Sep 17 00:00:00 2001 From: eGames Date: Fri, 11 Sep 2026 12:16:29 +0500 Subject: [PATCH 1/4] feat: support X-Api-Key header for basic authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-Api-Key takes priority over Authorization when present: a client can carry TinyAuth basic credentials (Basic base64(user:pass)) alongside an application token (Authorization: Bearer ...) in the same request — the collision that made bearer-token APIs behind TinyAuth impossible to protect. A malformed or non-Basic X-Api-Key is rejected without fallback so a half-configured client fails loudly. Without the header the behaviour is unchanged. Semantics mirror the production-tested implementation from the maposia/tinyauth fork (commit 2e94981) referenced by the official Remnawave nginx guide. --- internal/middleware/context_middleware.go | 75 +++++++++++++++---- .../middleware/context_middleware_test.go | 50 +++++++++++++ 2 files changed, 112 insertions(+), 13 deletions(-) diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index 3884013d..b746ea41 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -2,6 +2,7 @@ package middleware import ( "context" + "encoding/base64" "errors" "fmt" "net/http" @@ -94,23 +95,28 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { } } - username, password, ok := c.Request.BasicAuth() - - if ok { - userContext, headers, err := m.basicAuth(username, password) - - if err != nil { - m.log.App.Error().Msgf("Error authenticating basic auth: %v", err) - c.Next() + // X-Api-Key takes priority when present: it lets a client carry + // TinyAuth basic credentials alongside an application token in the + // Authorization header (e.g. "Authorization: Bearer ..." APIs behind + // the proxy). A malformed or non-Basic X-Api-Key is rejected WITHOUT + // falling back to Authorization — a half-configured client must fail + // loudly instead of silently degrading. + if apiKey := c.Request.Header.Get("X-Api-Key"); apiKey != "" { + username, password, ok := parseAPIKeyBasicAuth(apiKey) + if !ok { + m.log.App.Debug().Msg("Invalid basic auth in X-Api-Key header") + c.AbortWithStatus(http.StatusUnauthorized) return } - for k, v := range headers { - c.Header(k, v) - } + m.handleBasicAuth(c, username, password) + return + } - c.Set("context", userContext) - c.Next() + username, password, ok := c.Request.BasicAuth() + + if ok { + m.handleBasicAuth(c, username, password) return } @@ -359,3 +365,46 @@ func (m *ContextMiddleware) tailscaleWhois(ip string) (*model.TailscaleContext, return &uctx, nil } + +// handleBasicAuth authenticates via the shared basic auth path and, on a +// lock or error, still continues the chain with headers set (matching the +// previous inline behaviour). +func (m *ContextMiddleware) handleBasicAuth(c *gin.Context, username string, password string) { + userContext, headers, err := m.basicAuth(username, password) + + if err != nil { + m.log.App.Error().Msgf("Error authenticating basic auth: %v", err) + c.Next() + return + } + + for k, v := range headers { + c.Header(k, v) + } + + c.Set("context", userContext) + c.Next() +} + +// parseAPIKeyBasicAuth parses an X-Api-Key value in the form +// "Basic base64(username:password)". ok is false for a wrong scheme or a +// malformed payload — callers treat that as a hard reject without fallback. +func parseAPIKeyBasicAuth(header string) (username string, password string, ok bool) { + const prefix = "Basic " + + if len(header) < len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) { + return "", "", false + } + + payload, err := base64.StdEncoding.DecodeString(header[len(prefix):]) + if err != nil { + return "", "", false + } + + username, password, ok = strings.Cut(string(payload), ":") + if !ok { + return "", "", false + } + + return username, password, true +} diff --git a/internal/middleware/context_middleware_test.go b/internal/middleware/context_middleware_test.go index 9a2df892..cde3ea10 100644 --- a/internal/middleware/context_middleware_test.go +++ b/internal/middleware/context_middleware_test.go @@ -246,6 +246,56 @@ func TestContextMiddleware(t *testing.T) { assert.True(t, userCtx.Authenticated) }, }, + { + description: "Valid X-Api-Key sets authenticated local context", + run: func(t *testing.T, args runArgs) { + req := httptest.NewRequest("GET", "/api/test", nil) + req.Header.Set("X-Api-Key", basicAuthHeader("testuser", "password")) + userCtx, _ := args.do(req) + + require.NotNil(t, userCtx) + assert.Equal(t, model.ProviderLocal, userCtx.Provider) + assert.Equal(t, "testuser", userCtx.GetUsername()) + assert.True(t, userCtx.Authenticated) + }, + }, + { + description: "X-Api-Key takes priority over Authorization", + run: func(t *testing.T, args runArgs) { + req := httptest.NewRequest("GET", "/api/test", nil) + req.Header.Set("X-Api-Key", basicAuthHeader("testuser", "password")) + req.Header.Set("Authorization", basicAuthHeader("testuser", "wrongpassword")) + userCtx, _ := args.do(req) + + require.NotNil(t, userCtx) + assert.Equal(t, "testuser", userCtx.GetUsername()) + assert.True(t, userCtx.Authenticated) + }, + }, + { + description: "Malformed X-Api-Key is rejected without fallback to Authorization", + run: func(t *testing.T, args runArgs) { + req := httptest.NewRequest("GET", "/api/test", nil) + req.Header.Set("X-Api-Key", "Basic !!!not-base64!!!") + req.Header.Set("Authorization", basicAuthHeader("testuser", "password")) + userCtx, recorder := args.do(req) + + assert.Nil(t, userCtx) + assert.Equal(t, http.StatusUnauthorized, recorder.Code) + }, + }, + { + description: "Non-Basic scheme in X-Api-Key is rejected without fallback", + run: func(t *testing.T, args runArgs) { + req := httptest.NewRequest("GET", "/api/test", nil) + req.Header.Set("X-Api-Key", "Bearer some-token") + req.Header.Set("Authorization", basicAuthHeader("testuser", "password")) + userCtx, recorder := args.do(req) + + assert.Nil(t, userCtx) + assert.Equal(t, http.StatusUnauthorized, recorder.Code) + }, + }, } ctx := context.TODO() From 4f27aeba68211a7f9f009c4c45e59ab70847251f Mon Sep 17 00:00:00 2001 From: eGames Date: Fri, 11 Sep 2026 13:24:54 +0500 Subject: [PATCH 2/4] fix: detect an explicitly empty X-Api-Key and drop the gin-context helper Header.Get cannot tell an absent header from an explicitly empty one, so an empty X-Api-Key silently fell back to Authorization instead of rejecting. Presence is now checked via the header map. The inline basic auth path replaces the handleBasicAuth helper to keep gin.Context at the middleware boundary per AGENTS.md. Address review feedback. --- internal/middleware/context_middleware.go | 57 +++++++++++-------- .../middleware/context_middleware_test.go | 12 ++++ 2 files changed, 44 insertions(+), 25 deletions(-) diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index b746ea41..9d45774f 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -100,23 +100,50 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { // Authorization header (e.g. "Authorization: Bearer ..." APIs behind // the proxy). A malformed or non-Basic X-Api-Key is rejected WITHOUT // falling back to Authorization — a half-configured client must fail - // loudly instead of silently degrading. - if apiKey := c.Request.Header.Get("X-Api-Key"); apiKey != "" { - username, password, ok := parseAPIKeyBasicAuth(apiKey) + // loudly instead of silently degrading. Presence is checked via the + // header map, because Get cannot tell an absent header from an + // explicitly empty one. + if apiKeyHeaders := c.Request.Header["X-Api-Key"]; len(apiKeyHeaders) > 0 { + username, password, ok := parseAPIKeyBasicAuth(apiKeyHeaders[0]) if !ok { m.log.App.Debug().Msg("Invalid basic auth in X-Api-Key header") c.AbortWithStatus(http.StatusUnauthorized) return } - m.handleBasicAuth(c, username, password) + userContext, headers, err := m.basicAuth(username, password) + if err != nil { + m.log.App.Error().Msgf("Error authenticating basic auth: %v", err) + c.Next() + return + } + + for k, v := range headers { + c.Header(k, v) + } + + c.Set("context", userContext) + c.Next() return } username, password, ok := c.Request.BasicAuth() if ok { - m.handleBasicAuth(c, username, password) + userContext, headers, err := m.basicAuth(username, password) + + if err != nil { + m.log.App.Error().Msgf("Error authenticating basic auth: %v", err) + c.Next() + return + } + + for k, v := range headers { + c.Header(k, v) + } + + c.Set("context", userContext) + c.Next() return } @@ -366,26 +393,6 @@ func (m *ContextMiddleware) tailscaleWhois(ip string) (*model.TailscaleContext, return &uctx, nil } -// handleBasicAuth authenticates via the shared basic auth path and, on a -// lock or error, still continues the chain with headers set (matching the -// previous inline behaviour). -func (m *ContextMiddleware) handleBasicAuth(c *gin.Context, username string, password string) { - userContext, headers, err := m.basicAuth(username, password) - - if err != nil { - m.log.App.Error().Msgf("Error authenticating basic auth: %v", err) - c.Next() - return - } - - for k, v := range headers { - c.Header(k, v) - } - - c.Set("context", userContext) - c.Next() -} - // parseAPIKeyBasicAuth parses an X-Api-Key value in the form // "Basic base64(username:password)". ok is false for a wrong scheme or a // malformed payload — callers treat that as a hard reject without fallback. diff --git a/internal/middleware/context_middleware_test.go b/internal/middleware/context_middleware_test.go index cde3ea10..b814f18c 100644 --- a/internal/middleware/context_middleware_test.go +++ b/internal/middleware/context_middleware_test.go @@ -292,6 +292,18 @@ func TestContextMiddleware(t *testing.T) { req.Header.Set("Authorization", basicAuthHeader("testuser", "password")) userCtx, recorder := args.do(req) + assert.Nil(t, userCtx) + assert.Equal(t, http.StatusUnauthorized, recorder.Code) + }, + }, + { + description: "Explicitly empty X-Api-Key is rejected without fallback", + run: func(t *testing.T, args runArgs) { + req := httptest.NewRequest("GET", "/api/test", nil) + req.Header["X-Api-Key"] = []string{""} + req.Header.Set("Authorization", basicAuthHeader("testuser", "password")) + userCtx, recorder := args.do(req) + assert.Nil(t, userCtx) assert.Equal(t, http.StatusUnauthorized, recorder.Code) }, From 92ca2a253e55892077fe89e01452cb76ddabe2da Mon Sep 17 00:00:00 2001 From: eGames Date: Fri, 11 Sep 2026 13:46:37 +0500 Subject: [PATCH 3/4] docs: add the missing docstring to basicAuth --- internal/middleware/context_middleware.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index 9d45774f..74f2860e 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -270,6 +270,9 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip stri return userContext, cookie, nil } +// basicAuth authenticates a local user by username and password, handles +// account lockout bookkeeping, and returns the user context plus any +// response headers (e.g. lock hints) to set on the request. func (m *ContextMiddleware) basicAuth(username string, password string) (*model.UserContext, map[string]string, error) { headers := make(map[string]string) userContext := new(model.UserContext) From 2936e82452f2cb6e3767c68768a04ae8675f8816 Mon Sep 17 00:00:00 2001 From: eGames Date: Sat, 12 Sep 2026 11:59:36 +0500 Subject: [PATCH 4/4] chore: replace em-dashes with ASCII in comments GitHub flags non-ASCII punctuation in diffs as potentially hidden or bidirectional Unicode text. --- internal/middleware/context_middleware.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index 74f2860e..26e5fdd4 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -99,7 +99,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { // TinyAuth basic credentials alongside an application token in the // Authorization header (e.g. "Authorization: Bearer ..." APIs behind // the proxy). A malformed or non-Basic X-Api-Key is rejected WITHOUT - // falling back to Authorization — a half-configured client must fail + // falling back to Authorization: a half-configured client must fail // loudly instead of silently degrading. Presence is checked via the // header map, because Get cannot tell an absent header from an // explicitly empty one. @@ -398,7 +398,7 @@ func (m *ContextMiddleware) tailscaleWhois(ip string) (*model.TailscaleContext, // parseAPIKeyBasicAuth parses an X-Api-Key value in the form // "Basic base64(username:password)". ok is false for a wrong scheme or a -// malformed payload — callers treat that as a hard reject without fallback. +// malformed payload: callers treat that as a hard reject without fallback. func parseAPIKeyBasicAuth(header string) (username string, password string, ok bool) { const prefix = "Basic "