@@ -13,10 +13,18 @@ import (
1313
1414const oauthRedirectURICookiePrefix = "oauth_redirect_uri_"
1515
16- // oauthCallbackFromOrigin builds the OAuth redirect_uri (…/callback) for the given UI origin
17- // (scheme + host, no path). The path prefix comes from config.BaseUiUrl so deployments served
18- // from a subpath (e.g. https://host/ui) resolve to …/ui/callback instead of …/callback.
19- func oauthCallbackFromOrigin (origin * url.URL ) (string , error ) {
16+ // oauthCallbackURL builds the OAuth redirect_uri (…/callback) for the given UI origin scheme and host.
17+ // The path prefix comes from config.BaseUiUrl so deployments served from a subpath (e.g. https://host/ui)
18+ // resolve to …/ui/callback instead of …/callback.
19+ func oauthCallbackURL (scheme , host string ) (string , error ) {
20+ scheme = strings .ToLower (strings .TrimSpace (scheme ))
21+ if scheme != "http" && scheme != "https" {
22+ return "" , fmt .Errorf ("invalid origin scheme" )
23+ }
24+ host = strings .TrimSpace (host )
25+ if host == "" || strings .ContainsAny (host , "@\r \n \t " ) {
26+ return "" , fmt .Errorf ("invalid origin host" )
27+ }
2028 baseUI , err := url .Parse (config .BaseUiUrl )
2129 if err != nil {
2230 return "" , fmt .Errorf ("invalid BASE_UI_URL configuration: %w" , err )
@@ -26,12 +34,9 @@ func oauthCallbackFromOrigin(origin *url.URL) (string, error) {
2634 if basePath != "" {
2735 callbackPath = basePath + "/callback"
2836 }
29- out := & url.URL {
30- Scheme : origin .Scheme ,
31- Host : origin .Host ,
32- Path : callbackPath ,
33- }
34- return out .String (), nil
37+
38+ pathURL := & url.URL {Path : callbackPath }
39+ return scheme + "://" + host + pathURL .EscapedPath (), nil
3540}
3641
3742// ResolveOAuthRedirectURI returns the OAuth redirect_uri (callback URL) for this login attempt.
@@ -47,27 +52,22 @@ func ResolveOAuthRedirectURI(r *http.Request, redirectBase string) (string, erro
4752 if err != nil {
4853 return "" , fmt .Errorf ("invalid BASE_UI_URL configuration: %w" , err )
4954 }
50- origin := & url.URL {Scheme : baseUI .Scheme , Host : baseUI .Host }
51- return oauthCallbackFromOrigin (origin )
55+ return oauthCallbackURL (baseUI .Scheme , baseUI .Host )
5256 }
53- u , err := url . Parse ( strings . TrimSpace ( redirectBase ) )
57+ ok , err := isSameSchemeAndHost ( redirectBase , r )
5458 if err != nil {
55- return "" , fmt . Errorf ( "invalid redirect_base" )
59+ return "" , err
5660 }
57- if u . Scheme != "http" && u . Scheme != "https" {
58- return "" , fmt .Errorf ("invalid redirect_base: only http and https are allowed " )
61+ if ! ok {
62+ return "" , fmt .Errorf ("redirect_base does not match this UI origin " )
5963 }
60- if u .Hostname () == "" {
61- return "" , fmt .Errorf ("invalid redirect_base: host is required" )
62- }
63- if u .RawQuery != "" || u .Fragment != "" {
64- return "" , fmt .Errorf ("invalid redirect_base: query and fragment are not allowed" )
65- }
66- origin := & url.URL {Scheme : u .Scheme , Host : u .Host }
67- if err := redirectBaseMatchesRequest (r , origin ); err != nil {
64+ rs , rh := requestSchemeAndHost (r )
65+ canon := normalizeOrigin (rs , rh )
66+ scheme , host , err := splitCanonicalOriginURL (canon )
67+ if err != nil {
6868 return "" , err
6969 }
70- return oauthCallbackFromOrigin ( origin )
70+ return oauthCallbackURL ( scheme , host )
7171}
7272
7373// ResolveLogoutRedirectBase returns the UI base URL for OIDC post_logout_redirect_uri (no trailing slash).
@@ -82,6 +82,24 @@ func ResolveLogoutRedirectBase(r *http.Request, redirectBase string) (string, er
8282 return strings .TrimSuffix (callbackURI , "/callback" ), nil
8383}
8484
85+ // splitCanonicalOriginURL splits a string produced only by normalizeOrigin ("scheme://host[:port]").
86+ func splitCanonicalOriginURL (canon string ) (scheme , host string , err error ) {
87+ const sep = "://"
88+ i := strings .Index (canon , sep )
89+ if i < 0 {
90+ return "" , "" , fmt .Errorf ("invalid canonical origin" )
91+ }
92+ scheme = canon [:i ]
93+ host = canon [i + len (sep ):]
94+ if scheme != "http" && scheme != "https" {
95+ return "" , "" , fmt .Errorf ("invalid canonical origin scheme" )
96+ }
97+ if host == "" {
98+ return "" , "" , fmt .Errorf ("invalid canonical origin host" )
99+ }
100+ return scheme , host , nil
101+ }
102+
85103func requestSchemeAndHost (r * http.Request ) (scheme , host string ) {
86104 scheme = "http"
87105 if r .TLS != nil {
@@ -100,6 +118,37 @@ func requestSchemeAndHost(r *http.Request) (scheme, host string) {
100118 return scheme , host
101119}
102120
121+ // isSameSchemeAndHost parses redirectBase as an http(s) URL, validates it, and reports whether
122+ // its origin matches the effective request (see requestSchemeAndHost). Empty redirectBase
123+ // returns (false, nil). Invalid redirect_base returns (_, err).
124+ func isSameSchemeAndHost (redirectBase string , r * http.Request ) (bool , error ) {
125+ s := strings .TrimSpace (redirectBase )
126+ if s == "" {
127+ return false , nil
128+ }
129+ u , err := url .Parse (s )
130+ if err != nil {
131+ return false , fmt .Errorf ("invalid redirect_base" )
132+ }
133+ if u .Scheme != "http" && u .Scheme != "https" {
134+ return false , fmt .Errorf ("invalid redirect_base: only http and https are allowed" )
135+ }
136+ if u .Hostname () == "" {
137+ return false , fmt .Errorf ("invalid redirect_base: host is required" )
138+ }
139+ if u .RawQuery != "" || u .Fragment != "" {
140+ return false , fmt .Errorf ("invalid redirect_base: query and fragment are not allowed" )
141+ }
142+ if u .User != nil {
143+ return false , fmt .Errorf ("invalid redirect_base: user info is not allowed" )
144+ }
145+ rs , rh := requestSchemeAndHost (r )
146+ if normalizeOrigin (u .Scheme , u .Host ) != normalizeOrigin (rs , rh ) {
147+ return false , nil
148+ }
149+ return true , nil
150+ }
151+
103152// cookieSecureForRequest is true when the Set-Cookie Secure attribute should be set: TLS is
104153// configured on this proxy, or the effective request scheme is HTTPS (including when TLS
105154// terminates at a reverse proxy and X-Forwarded-Proto is trusted).
@@ -114,16 +163,6 @@ func cookieSecureForRequest(r *http.Request) bool {
114163 return strings .EqualFold (strings .TrimSpace (scheme ), "https" )
115164}
116165
117- func redirectBaseMatchesRequest (r * http.Request , u * url.URL ) error {
118- rs , rh := requestSchemeAndHost (r )
119- candidate := normalizeOrigin (u .Scheme , u .Host )
120- actual := normalizeOrigin (rs , rh )
121- if candidate != actual {
122- return fmt .Errorf ("redirect_base does not match this UI origin" )
123- }
124- return nil
125- }
126-
127166func normalizeOrigin (scheme , host string ) string {
128167 scheme = strings .ToLower (strings .TrimSpace (scheme ))
129168 host = strings .ToLower (strings .TrimSpace (host ))
0 commit comments