This is an automated email from the ASF dual-hosted git repository.

Startrekzky pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git


The following commit(s) were added to refs/heads/main by this push:
     new 7e9a2aec6 fix: require secret for forwarded user auth (#8880)
7e9a2aec6 is described below

commit 7e9a2aec67695f8ddc7fe4442be8e02b831a7566
Author: Klesh Wong <[email protected]>
AuthorDate: Wed May 20 11:21:39 2026 +0800

    fix: require secret for forwarded user auth (#8880)
    
    Reject spoofed X-Forwarded-User headers unless a configured shared secret 
matches the forwarded secret header.
    
    Co-authored-by: Klesh Wong <[email protected]>
    Co-authored-by: Copilot <[email protected]>
---
 backend/server/api/api.go                          |   4 +-
 backend/server/api/middlewares.go                  |  28 +++-
 .../server/api/middlewares_forwardsecret_test.go   | 147 +++++++++++++++++++++
 env.example                                        |   5 +
 4 files changed, 177 insertions(+), 7 deletions(-)

diff --git a/backend/server/api/api.go b/backend/server/api/api.go
index 32ca49bd8..cd66b765a 100644
--- a/backend/server/api/api.go
+++ b/backend/server/api/api.go
@@ -107,8 +107,8 @@ func CreateApiServer() *gin.Engine {
 
        // Auth chain order matters: REST API key first (its own short-circuit),
        // then the push API key gate, then OIDC session, then oauth2-proxy 
header
-       // (only sets USER if not yet set), then the terminal 401 gate, finally
-       // CSRF on unsafe methods.
+       // (only sets USER if not yet set and the forwarded secret matches), 
then the terminal 401 gate,
+       // finally CSRF on unsafe methods.
        router.Use(RestAuthentication(router, basicRes))
        router.Use(RequirePushAuthentication(basicRes))
        router.Use(auth.OIDCAuthentication())
diff --git a/backend/server/api/middlewares.go 
b/backend/server/api/middlewares.go
index b6e517847..a988c4128 100644
--- a/backend/server/api/middlewares.go
+++ b/backend/server/api/middlewares.go
@@ -18,6 +18,7 @@ limitations under the License.
 package api
 
 import (
+       "crypto/subtle"
        "encoding/base64"
        "fmt"
        "net/http"
@@ -35,12 +36,28 @@ import (
        "github.com/gin-gonic/gin"
 )
 
-func getOAuthUserInfo(c *gin.Context) (*common.User, error) {
+const (
+       forwardedUserHeader       = "X-Forwarded-User"
+       forwardedEmailHeader      = "X-Forwarded-Email"
+       forwardedUserSecretHeader = "X-Forwarded-User-Secret"
+)
+
+func getOAuthUserInfo(c *gin.Context, forwardedUserSecret string) 
(*common.User, error) {
        if c == nil {
                return nil, errors.Default.New("request is nil")
        }
-       user := c.GetHeader("X-Forwarded-User")
-       email := c.GetHeader("X-Forwarded-Email")
+       user := strings.TrimSpace(c.GetHeader(forwardedUserHeader))
+       if user == "" {
+               return nil, nil
+       }
+       if forwardedUserSecret == "" {
+               return nil, errors.Default.New("ignoring forwarded user headers 
because FORWARDED_USER_SECRET is not configured")
+       }
+       providedSecret := 
strings.TrimSpace(c.GetHeader(forwardedUserSecretHeader))
+       if subtle.ConstantTimeCompare([]byte(providedSecret), 
[]byte(forwardedUserSecret)) != 1 {
+               return nil, errors.Default.New("ignoring forwarded user headers 
because X-Forwarded-User-Secret did not match")
+       }
+       email := strings.TrimSpace(c.GetHeader(forwardedEmailHeader))
        return &common.User{
                Name:  user,
                Email: email,
@@ -75,12 +92,13 @@ func getBasicAuthUserInfo(c *gin.Context, basicRes 
context.BasicRes) (*common.Us
 
 func OAuth2ProxyAuthentication(basicRes context.BasicRes) gin.HandlerFunc {
        logger := basicRes.GetLogger()
+       forwardedUserSecret := 
strings.TrimSpace(basicRes.GetConfigReader().GetString("FORWARDED_USER_SECRET"))
        return func(c *gin.Context) {
                _, exist := c.Get(common.USER)
                if !exist {
-                       user, err := getOAuthUserInfo(c)
+                       user, err := getOAuthUserInfo(c, forwardedUserSecret)
                        if err != nil {
-                               logger.Error(err, "getOAuthUserInfo")
+                               logger.Warn(err, "rejected forwarded user 
headers")
                        }
                        if user == nil || user.Name == "" {
                                // fetch with basic auth header
diff --git a/backend/server/api/middlewares_forwardsecret_test.go 
b/backend/server/api/middlewares_forwardsecret_test.go
new file mode 100644
index 000000000..b5e13ed83
--- /dev/null
+++ b/backend/server/api/middlewares_forwardsecret_test.go
@@ -0,0 +1,147 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package api
+
+import (
+       "encoding/json"
+       "net/http"
+       "net/http/httptest"
+       "testing"
+
+       "github.com/gin-gonic/gin"
+       "github.com/spf13/viper"
+
+       "github.com/apache/incubator-devlake/core/config"
+       corecontext "github.com/apache/incubator-devlake/core/context"
+       "github.com/apache/incubator-devlake/core/dal"
+       "github.com/apache/incubator-devlake/core/log"
+       "github.com/apache/incubator-devlake/impls/logruslog"
+       "github.com/apache/incubator-devlake/server/api/shared"
+)
+
+type proxyAuthTestBasicRes struct {
+       cfg    config.ConfigReader
+       logger log.Logger
+}
+
+func (b *proxyAuthTestBasicRes) GetConfigReader() config.ConfigReader { return 
b.cfg }
+func (b *proxyAuthTestBasicRes) GetConfig(name string) string         { return 
b.cfg.GetString(name) }
+func (b *proxyAuthTestBasicRes) GetLogger() log.Logger                { return 
b.logger }
+func (b *proxyAuthTestBasicRes) NestedLogger(name string) corecontext.BasicRes 
{
+       return &proxyAuthTestBasicRes{cfg: b.cfg, logger: b.logger.Nested(name)}
+}
+func (b *proxyAuthTestBasicRes) ReplaceLogger(logger log.Logger) 
corecontext.BasicRes {
+       return &proxyAuthTestBasicRes{cfg: b.cfg, logger: logger}
+}
+func (b *proxyAuthTestBasicRes) GetDal() dal.Dal { return nil }
+
+type proxyAuthResponse struct {
+       Authenticated bool   `json:"authenticated"`
+       Name          string `json:"name"`
+       Email         string `json:"email"`
+}
+
+func newProxyAuthRouter(secret string) *gin.Engine {
+       gin.SetMode(gin.TestMode)
+       cfg := viper.New()
+       cfg.Set("FORWARDED_USER_SECRET", secret)
+       basicRes := &proxyAuthTestBasicRes{
+               cfg:    cfg,
+               logger: logruslog.Global,
+       }
+       r := gin.New()
+       r.Use(OAuth2ProxyAuthentication(basicRes))
+       r.GET("/me", func(c *gin.Context) {
+               user, ok := shared.GetUser(c)
+               if !ok {
+                       c.JSON(http.StatusOK, proxyAuthResponse{})
+                       return
+               }
+               c.JSON(http.StatusOK, proxyAuthResponse{
+                       Authenticated: true,
+                       Name:          user.Name,
+                       Email:         user.Email,
+               })
+       })
+       return r
+}
+
+func performProxyAuthRequest(t *testing.T, router *gin.Engine, headers 
map[string]string) proxyAuthResponse {
+       t.Helper()
+       req := httptest.NewRequest(http.MethodGet, "/me", nil)
+       for key, value := range headers {
+               req.Header.Set(key, value)
+       }
+       recorder := httptest.NewRecorder()
+       router.ServeHTTP(recorder, req)
+       if recorder.Code != http.StatusOK {
+               t.Fatalf("expected 200, got %d: %s", recorder.Code, 
recorder.Body.String())
+       }
+       var body proxyAuthResponse
+       if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
+               t.Fatalf("decode response: %v", err)
+       }
+       return body
+}
+
+func TestOAuth2ProxyAuthenticationRejectsUntrustedForwardedHeaders(t 
*testing.T) {
+       router := newProxyAuthRouter("shared-secret")
+       cases := map[string]map[string]string{
+               "missing secret header": {
+                       forwardedUserHeader:  "[email protected]",
+                       forwardedEmailHeader: "[email protected]",
+               },
+               "mismatched secret header": {
+                       forwardedUserHeader:       "[email protected]",
+                       forwardedEmailHeader:      "[email protected]",
+                       forwardedUserSecretHeader: "wrong-secret",
+               },
+       }
+       for name, headers := range cases {
+               t.Run(name, func(t *testing.T) {
+                       body := performProxyAuthRequest(t, router, headers)
+                       if body.Authenticated {
+                               t.Fatalf("expected forwarded headers to be 
rejected, got %+v", body)
+                       }
+               })
+       }
+}
+
+func TestOAuth2ProxyAuthenticationRequiresConfiguredSecret(t *testing.T) {
+       router := newProxyAuthRouter("")
+       body := performProxyAuthRequest(t, router, map[string]string{
+               forwardedUserHeader:       "[email protected]",
+               forwardedEmailHeader:      "[email protected]",
+               forwardedUserSecretHeader: "shared-secret",
+       })
+       if body.Authenticated {
+               t.Fatalf("expected forwarded headers to be ignored without 
FORWARDED_USER_SECRET, got %+v", body)
+       }
+}
+
+func TestOAuth2ProxyAuthenticationAcceptsTrustedForwardedHeaders(t *testing.T) 
{
+       router := newProxyAuthRouter("shared-secret")
+       body := performProxyAuthRequest(t, router, map[string]string{
+               forwardedUserHeader:       "[email protected]",
+               forwardedEmailHeader:      "[email protected]",
+               forwardedUserSecretHeader: "shared-secret",
+       })
+       if !body.Authenticated || body.Name != "[email protected]" || 
body.Email != "[email protected]" {
+               t.Fatalf("expected trusted forwarded user, got %+v", body)
+       }
+}
diff --git a/env.example b/env.example
index 460d01b24..843df6752 100755
--- a/env.example
+++ b/env.example
@@ -103,6 +103,11 @@ AUTH_ENABLED=true
 # OIDC user login. Requires AUTH_ENABLED=true.
 OIDC_ENABLED=false
 
+# Shared secret required before DevLake trusts X-Forwarded-User and
+# X-Forwarded-Email from an upstream proxy. Configure the proxy to send the
+# same value as X-Forwarded-User-Secret on each forwarded request.
+FORWARDED_USER_SECRET=
+
 # Comma-separated provider identifiers. Each name <NAME> binds to the env
 # vars OIDC_<NAME>_ISSUER_URL, OIDC_<NAME>_CLIENT_ID, etc. Add a name and a
 # matching block of vars to onboard another IdP.

Reply via email to