@@ -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+
158226type 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
223291func 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}
0 commit comments