Skip to content

Commit 4f30ac6

Browse files
authored
fix(controlplane): validate the OIDC login callback URL (CP-N2) (#3322)
Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
1 parent 9e000fb commit 4f30ac6

4 files changed

Lines changed: 147 additions & 9 deletions

File tree

app/controlplane/cmd/wire_gen.go

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/controlplane/internal/service/auth.go

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@ import (
2121
"encoding/base64"
2222
"errors"
2323
"fmt"
24+
"net"
2425
"net/http"
2526
"net/mail"
2627
"net/url"
28+
"slices"
29+
"strings"
2730
"time"
2831

2932
pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
@@ -115,16 +118,18 @@ type AuthService struct {
115118
AuthURLs *AuthURLs
116119
auditorUseCase *biz.AuditorUseCase
117120
devMode bool
121+
// scheme://host destinations the post-login redirect may target
122+
allowedCallbackOrigins []string
118123
}
119124

120-
func NewAuthService(userUC *biz.UserUseCase, orgUC *biz.OrganizationUseCase, mUC *biz.MembershipUseCase, inviteUC *biz.OrgInvitationUseCase, authConfig *conf.Auth, serverConfig *conf.Server, auc *biz.AuditorUseCase, opts ...NewOpt) (*AuthService, error) {
125+
func NewAuthService(userUC *biz.UserUseCase, orgUC *biz.OrganizationUseCase, mUC *biz.MembershipUseCase, inviteUC *biz.OrgInvitationUseCase, authConfig *conf.Auth, bootstrapConfig *conf.Bootstrap, auc *biz.AuditorUseCase, opts ...NewOpt) (*AuthService, error) {
121126
oidcConfig := authConfig.GetOidc()
122127
if oidcConfig == nil {
123128
return nil, errors.New("oauth configuration missing")
124129
}
125130

126131
// Craft Auth related endpoints
127-
authURLs, err := getAuthURLs(serverConfig.GetHttp(), authConfig.GetOidc().GetLoginUrlOverride())
132+
authURLs, err := getAuthURLs(bootstrapConfig.GetServer().GetHttp(), authConfig.GetOidc().GetLoginUrlOverride())
128133
if err != nil {
129134
return nil, fmt.Errorf("failed to get auth URLs: %w", err)
130135
}
@@ -152,9 +157,72 @@ func NewAuthService(userUC *biz.UserUseCase, orgUC *biz.OrganizationUseCase, mUC
152157
membershipUseCase: mUC,
153158
orgInvitesUseCase: inviteUC,
154159
auditorUseCase: auc,
160+
allowedCallbackOrigins: originsOf(authURLs.Login, bootstrapConfig.GetServer().GetHttp().GetExternalUrl(),
161+
bootstrapConfig.GetUiDashboardUrl()),
155162
}, nil
156163
}
157164

165+
var errInvalidCallback = errors.New("invalid callback URL")
166+
167+
// originsOf extracts the origin of the given URLs, skipping empty or malformed ones
168+
func originsOf(urls ...string) []string {
169+
origins := make([]string, 0, len(urls))
170+
for _, raw := range urls {
171+
u, err := url.Parse(raw)
172+
if err != nil || u.Scheme == "" || u.Host == "" {
173+
continue
174+
}
175+
176+
origins = append(origins, originOf(u))
177+
}
178+
179+
return origins
180+
}
181+
182+
// originOf normalizes the URL down to scheme://host. Hosts are case insensitive, so a
183+
// config typo like "https://App.Example.com" still matches the browser-sent origin
184+
func originOf(u *url.URL) string {
185+
return strings.ToLower(u.Scheme + "://" + u.Host)
186+
}
187+
188+
// callbackAllowed rejects post-login redirect targets that would hand the user JWT over to
189+
// a third party. Relative paths (CAS download redirect) and loopback (CLI login) are always
190+
// allowed, anything else must match a known origin.
191+
func callbackAllowed(callback string, allowedOrigins []string) error {
192+
if callback == "" {
193+
return nil
194+
}
195+
196+
// Browsers fold "\" into "/", so "/\evil.example" would escape a seemingly relative path
197+
if strings.Contains(callback, `\`) {
198+
return errInvalidCallback
199+
}
200+
201+
u, err := url.Parse(callback)
202+
if err != nil {
203+
return errInvalidCallback
204+
}
205+
206+
// Relative path. Both parts must be empty, otherwise "//evil.example" would pass as a path
207+
if u.Scheme == "" && u.Host == "" {
208+
return nil
209+
}
210+
211+
if u.Scheme != "http" && u.Scheme != "https" {
212+
return errInvalidCallback
213+
}
214+
215+
if host := u.Hostname(); host == "localhost" || net.ParseIP(host).IsLoopback() {
216+
return nil
217+
}
218+
219+
if slices.Contains(allowedOrigins, originOf(u)) {
220+
return nil
221+
}
222+
223+
return fmt.Errorf("callback URL not allowed: %s", originOf(u))
224+
}
225+
158226
type AuthURLs struct {
159227
Login, callback string
160228
loginIsOverridden bool
@@ -221,6 +289,13 @@ func (h oauthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
221289
}
222290

223291
func loginHandler(svc *AuthService, w http.ResponseWriter, r *http.Request) *oauthResp {
292+
// The final destination where the auth token will be pushed to, i.e the CLI.
293+
// Rejected upfront so a crafted callback never starts the OIDC dance
294+
callback := r.URL.Query().Get(oauth.QueryParamCallback)
295+
if err := callbackAllowed(callback, svc.allowedCallbackOrigins); err != nil {
296+
return newOauthResp(http.StatusBadRequest, err, true)
297+
}
298+
224299
b := make([]byte, 16)
225300
_, err := rand.Read(b)
226301
if err != nil {
@@ -231,8 +306,7 @@ func loginHandler(svc *AuthService, w http.ResponseWriter, r *http.Request) *oau
231306
state := base64.URLEncoding.EncodeToString(b)
232307
svc.setOauthCookie(w, cookieOauthStateName, state)
233308

234-
// Store the final destination where the auth token will be pushed to, i.e the CLI
235-
svc.setOauthCookie(w, cookieCallback, r.URL.Query().Get(oauth.QueryParamCallback))
309+
svc.setOauthCookie(w, cookieCallback, callback)
236310

237311
// Wether the token should be short lived or not
238312
svc.setOauthCookie(w, cookieLongLived, r.URL.Query().Get(oauth.QueryParamLongLived))
@@ -362,12 +436,18 @@ func callbackHandler(svc *AuthService, w http.ResponseWriter, r *http.Request) *
362436
return newOauthResp(http.StatusOK, nil, false)
363437
}
364438

365-
// Redirect to the callback URL
439+
// Redirect to the callback URL. The cookie was validated at login time, re-check it here
440+
// so a stale or tampered cookie can't turn this into an open redirect either
441+
if err := callbackAllowed(callbackValue, svc.allowedCallbackOrigins); err != nil {
442+
return newOauthResp(http.StatusBadRequest, err, true)
443+
}
444+
366445
callbackURL, err := crafCallbackURL(callbackValue, userToken)
367446
if err != nil {
368447
return newOauthResp(http.StatusInternalServerError, fmt.Errorf("failed to craft callback URL: %w", err), false)
369448
}
370449

450+
setTokenLeakHeaders(w)
371451
http.Redirect(w, r, callbackURL, http.StatusFound)
372452
return newOauthResp(http.StatusTemporaryRedirect, nil, false)
373453
}

app/controlplane/internal/service/auth_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
package service
1717

1818
import (
19+
"net/http"
20+
"net/http/httptest"
1921
"testing"
2022

2123
conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1"
@@ -124,3 +126,54 @@ func TestGetPreferredEmail(t *testing.T) {
124126
assert.Equal(t, tc.want, got)
125127
}
126128
}
129+
130+
func TestCallbackAllowed(t *testing.T) {
131+
// mixed case on purpose, origins are matched case insensitively
132+
allowed := originsOf("https://app.chainloop.dev/login", "https://CP.Chainloop.dev", "https://app.chainloop.dev")
133+
134+
testCases := []struct {
135+
name string
136+
callback string
137+
wantErr bool
138+
}{
139+
{name: "empty, token is rendered in a page", callback: ""},
140+
{name: "relative path, CAS download redirect", callback: "/download/sha256:deadbeef?foo=bar"},
141+
{name: "loopback with random port, CLI login", callback: "http://127.0.0.1:41337/auth/callback"},
142+
{name: "localhost with random port, CLI login", callback: "http://localhost:41337/auth/callback"},
143+
{name: "IPv6 loopback", callback: "http://[::1]:41337/auth/callback"},
144+
{name: "dashboard origin", callback: "https://app.chainloop.dev/login/callback?returnTo=%2Fprojects"},
145+
{name: "control plane origin, config declared it in a different case", callback: "https://cp.chainloop.dev/foo"},
146+
{name: "dashboard origin, browser sent a different case", callback: "https://APP.chainloop.dev/login/callback"},
147+
{name: "third party origin", callback: "https://evil.example/collect", wantErr: true},
148+
{name: "protocol relative", callback: "//evil.example/collect", wantErr: true},
149+
{name: "backslash escaping a relative path", callback: "/\\evil.example/collect", wantErr: true},
150+
{name: "non http scheme", callback: "javascript:alert(1)", wantErr: true},
151+
{name: "loopback lookalike host", callback: "http://localhost.evil.example/collect", wantErr: true},
152+
{name: "allowed host as a subdomain", callback: "https://app.chainloop.dev.evil.example/collect", wantErr: true},
153+
{name: "allowed host with a different scheme", callback: "http://app.chainloop.dev/collect", wantErr: true},
154+
}
155+
156+
for _, tc := range testCases {
157+
t.Run(tc.name, func(t *testing.T) {
158+
err := callbackAllowed(tc.callback, allowed)
159+
if tc.wantErr {
160+
assert.Error(t, err)
161+
return
162+
}
163+
164+
assert.NoError(t, err)
165+
})
166+
}
167+
}
168+
169+
// The callback cookie must not be set for a destination we would refuse to redirect to
170+
func TestLoginHandlerRejectsForeignCallback(t *testing.T) {
171+
svc := &AuthService{allowedCallbackOrigins: originsOf("https://app.chainloop.dev")}
172+
173+
w := httptest.NewRecorder()
174+
r := httptest.NewRequest(http.MethodGet, "/auth/login?callback=https%3A%2F%2Fevil.example%2Fcollect&long-lived=true", nil)
175+
176+
resp := loginHandler(svc, w, r)
177+
assert.Equal(t, http.StatusBadRequest, resp.code)
178+
assert.Empty(t, w.Result().Cookies())
179+
}

app/controlplane/internal/service/auth_token_page.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,20 @@ var tokenPageTemplate = template.Must(template.New("tokenPage").Parse(tokenPageH
2929
// leakage so the bearer token does not escape the page.
3030
func renderTokenPage(w http.ResponseWriter, token string) error {
3131
w.Header().Set("Content-Type", "text/html; charset=utf-8")
32-
w.Header().Set("Cache-Control", "no-store")
33-
w.Header().Set("Referrer-Policy", "no-referrer")
32+
setTokenLeakHeaders(w)
3433

3534
if err := tokenPageTemplate.Execute(w, struct{ Token string }{Token: token}); err != nil {
3635
return fmt.Errorf("failed to render token page: %w", err)
3736
}
3837
return nil
3938
}
4039

40+
// setTokenLeakHeaders keeps a response carrying a user token out of caches and Referer headers
41+
func setTokenLeakHeaders(w http.ResponseWriter) {
42+
w.Header().Set("Cache-Control", "no-store")
43+
w.Header().Set("Referrer-Policy", "no-referrer")
44+
}
45+
4146
// #nosec G101 -- HTML template, not a credential
4247
const tokenPageHTML = `<!DOCTYPE html>
4348
<html lang="en">

0 commit comments

Comments
 (0)