Skip to content

Commit 7e9a2ae

Browse files
kleshKlesh WongCopilot
authored
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 <kleshwong@gmail.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c057341 commit 7e9a2ae

4 files changed

Lines changed: 177 additions & 7 deletions

File tree

backend/server/api/api.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,8 @@ func CreateApiServer() *gin.Engine {
107107

108108
// Auth chain order matters: REST API key first (its own short-circuit),
109109
// then the push API key gate, then OIDC session, then oauth2-proxy header
110-
// (only sets USER if not yet set), then the terminal 401 gate, finally
111-
// CSRF on unsafe methods.
110+
// (only sets USER if not yet set and the forwarded secret matches), then the terminal 401 gate,
111+
// finally CSRF on unsafe methods.
112112
router.Use(RestAuthentication(router, basicRes))
113113
router.Use(RequirePushAuthentication(basicRes))
114114
router.Use(auth.OIDCAuthentication())

backend/server/api/middlewares.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ limitations under the License.
1818
package api
1919

2020
import (
21+
"crypto/subtle"
2122
"encoding/base64"
2223
"fmt"
2324
"net/http"
@@ -35,12 +36,28 @@ import (
3536
"github.com/gin-gonic/gin"
3637
)
3738

38-
func getOAuthUserInfo(c *gin.Context) (*common.User, error) {
39+
const (
40+
forwardedUserHeader = "X-Forwarded-User"
41+
forwardedEmailHeader = "X-Forwarded-Email"
42+
forwardedUserSecretHeader = "X-Forwarded-User-Secret"
43+
)
44+
45+
func getOAuthUserInfo(c *gin.Context, forwardedUserSecret string) (*common.User, error) {
3946
if c == nil {
4047
return nil, errors.Default.New("request is nil")
4148
}
42-
user := c.GetHeader("X-Forwarded-User")
43-
email := c.GetHeader("X-Forwarded-Email")
49+
user := strings.TrimSpace(c.GetHeader(forwardedUserHeader))
50+
if user == "" {
51+
return nil, nil
52+
}
53+
if forwardedUserSecret == "" {
54+
return nil, errors.Default.New("ignoring forwarded user headers because FORWARDED_USER_SECRET is not configured")
55+
}
56+
providedSecret := strings.TrimSpace(c.GetHeader(forwardedUserSecretHeader))
57+
if subtle.ConstantTimeCompare([]byte(providedSecret), []byte(forwardedUserSecret)) != 1 {
58+
return nil, errors.Default.New("ignoring forwarded user headers because X-Forwarded-User-Secret did not match")
59+
}
60+
email := strings.TrimSpace(c.GetHeader(forwardedEmailHeader))
4461
return &common.User{
4562
Name: user,
4663
Email: email,
@@ -75,12 +92,13 @@ func getBasicAuthUserInfo(c *gin.Context, basicRes context.BasicRes) (*common.Us
7592

7693
func OAuth2ProxyAuthentication(basicRes context.BasicRes) gin.HandlerFunc {
7794
logger := basicRes.GetLogger()
95+
forwardedUserSecret := strings.TrimSpace(basicRes.GetConfigReader().GetString("FORWARDED_USER_SECRET"))
7896
return func(c *gin.Context) {
7997
_, exist := c.Get(common.USER)
8098
if !exist {
81-
user, err := getOAuthUserInfo(c)
99+
user, err := getOAuthUserInfo(c, forwardedUserSecret)
82100
if err != nil {
83-
logger.Error(err, "getOAuthUserInfo")
101+
logger.Warn(err, "rejected forwarded user headers")
84102
}
85103
if user == nil || user.Name == "" {
86104
// fetch with basic auth header
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/*
2+
Licensed to the Apache Software Foundation (ASF) under one or more
3+
contributor license agreements. See the NOTICE file distributed with
4+
this work for additional information regarding copyright ownership.
5+
The ASF licenses this file to You under the Apache License, Version 2.0
6+
(the "License"); you may not use this file except in compliance with
7+
the License. You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package api
19+
20+
import (
21+
"encoding/json"
22+
"net/http"
23+
"net/http/httptest"
24+
"testing"
25+
26+
"github.com/gin-gonic/gin"
27+
"github.com/spf13/viper"
28+
29+
"github.com/apache/incubator-devlake/core/config"
30+
corecontext "github.com/apache/incubator-devlake/core/context"
31+
"github.com/apache/incubator-devlake/core/dal"
32+
"github.com/apache/incubator-devlake/core/log"
33+
"github.com/apache/incubator-devlake/impls/logruslog"
34+
"github.com/apache/incubator-devlake/server/api/shared"
35+
)
36+
37+
type proxyAuthTestBasicRes struct {
38+
cfg config.ConfigReader
39+
logger log.Logger
40+
}
41+
42+
func (b *proxyAuthTestBasicRes) GetConfigReader() config.ConfigReader { return b.cfg }
43+
func (b *proxyAuthTestBasicRes) GetConfig(name string) string { return b.cfg.GetString(name) }
44+
func (b *proxyAuthTestBasicRes) GetLogger() log.Logger { return b.logger }
45+
func (b *proxyAuthTestBasicRes) NestedLogger(name string) corecontext.BasicRes {
46+
return &proxyAuthTestBasicRes{cfg: b.cfg, logger: b.logger.Nested(name)}
47+
}
48+
func (b *proxyAuthTestBasicRes) ReplaceLogger(logger log.Logger) corecontext.BasicRes {
49+
return &proxyAuthTestBasicRes{cfg: b.cfg, logger: logger}
50+
}
51+
func (b *proxyAuthTestBasicRes) GetDal() dal.Dal { return nil }
52+
53+
type proxyAuthResponse struct {
54+
Authenticated bool `json:"authenticated"`
55+
Name string `json:"name"`
56+
Email string `json:"email"`
57+
}
58+
59+
func newProxyAuthRouter(secret string) *gin.Engine {
60+
gin.SetMode(gin.TestMode)
61+
cfg := viper.New()
62+
cfg.Set("FORWARDED_USER_SECRET", secret)
63+
basicRes := &proxyAuthTestBasicRes{
64+
cfg: cfg,
65+
logger: logruslog.Global,
66+
}
67+
r := gin.New()
68+
r.Use(OAuth2ProxyAuthentication(basicRes))
69+
r.GET("/me", func(c *gin.Context) {
70+
user, ok := shared.GetUser(c)
71+
if !ok {
72+
c.JSON(http.StatusOK, proxyAuthResponse{})
73+
return
74+
}
75+
c.JSON(http.StatusOK, proxyAuthResponse{
76+
Authenticated: true,
77+
Name: user.Name,
78+
Email: user.Email,
79+
})
80+
})
81+
return r
82+
}
83+
84+
func performProxyAuthRequest(t *testing.T, router *gin.Engine, headers map[string]string) proxyAuthResponse {
85+
t.Helper()
86+
req := httptest.NewRequest(http.MethodGet, "/me", nil)
87+
for key, value := range headers {
88+
req.Header.Set(key, value)
89+
}
90+
recorder := httptest.NewRecorder()
91+
router.ServeHTTP(recorder, req)
92+
if recorder.Code != http.StatusOK {
93+
t.Fatalf("expected 200, got %d: %s", recorder.Code, recorder.Body.String())
94+
}
95+
var body proxyAuthResponse
96+
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
97+
t.Fatalf("decode response: %v", err)
98+
}
99+
return body
100+
}
101+
102+
func TestOAuth2ProxyAuthenticationRejectsUntrustedForwardedHeaders(t *testing.T) {
103+
router := newProxyAuthRouter("shared-secret")
104+
cases := map[string]map[string]string{
105+
"missing secret header": {
106+
forwardedUserHeader: "admin@example.com",
107+
forwardedEmailHeader: "admin@example.com",
108+
},
109+
"mismatched secret header": {
110+
forwardedUserHeader: "admin@example.com",
111+
forwardedEmailHeader: "admin@example.com",
112+
forwardedUserSecretHeader: "wrong-secret",
113+
},
114+
}
115+
for name, headers := range cases {
116+
t.Run(name, func(t *testing.T) {
117+
body := performProxyAuthRequest(t, router, headers)
118+
if body.Authenticated {
119+
t.Fatalf("expected forwarded headers to be rejected, got %+v", body)
120+
}
121+
})
122+
}
123+
}
124+
125+
func TestOAuth2ProxyAuthenticationRequiresConfiguredSecret(t *testing.T) {
126+
router := newProxyAuthRouter("")
127+
body := performProxyAuthRequest(t, router, map[string]string{
128+
forwardedUserHeader: "admin@example.com",
129+
forwardedEmailHeader: "admin@example.com",
130+
forwardedUserSecretHeader: "shared-secret",
131+
})
132+
if body.Authenticated {
133+
t.Fatalf("expected forwarded headers to be ignored without FORWARDED_USER_SECRET, got %+v", body)
134+
}
135+
}
136+
137+
func TestOAuth2ProxyAuthenticationAcceptsTrustedForwardedHeaders(t *testing.T) {
138+
router := newProxyAuthRouter("shared-secret")
139+
body := performProxyAuthRequest(t, router, map[string]string{
140+
forwardedUserHeader: "admin@example.com",
141+
forwardedEmailHeader: "admin@example.com",
142+
forwardedUserSecretHeader: "shared-secret",
143+
})
144+
if !body.Authenticated || body.Name != "admin@example.com" || body.Email != "admin@example.com" {
145+
t.Fatalf("expected trusted forwarded user, got %+v", body)
146+
}
147+
}

env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ AUTH_ENABLED=true
103103
# OIDC user login. Requires AUTH_ENABLED=true.
104104
OIDC_ENABLED=false
105105

106+
# Shared secret required before DevLake trusts X-Forwarded-User and
107+
# X-Forwarded-Email from an upstream proxy. Configure the proxy to send the
108+
# same value as X-Forwarded-User-Secret on each forwarded request.
109+
FORWARDED_USER_SECRET=
110+
106111
# Comma-separated provider identifiers. Each name <NAME> binds to the env
107112
# vars OIDC_<NAME>_ISSUER_URL, OIDC_<NAME>_CLIENT_ID, etc. Add a name and a
108113
# matching block of vars to onboard another IdP.

0 commit comments

Comments
 (0)