Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions cmd/bridge/config/auth/authoptions.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ func (c *completedOptions) ApplyTo(
var err error
srv.Authenticator, err = c.getAuthenticator(
srv.BaseURL,
srv.AdditionalBaseURLs,
k8sEndpoint,
caCertFilePath,
srv.InternalProxiedK8SClientConfig,
Expand All @@ -242,12 +243,14 @@ func (c *completedOptions) ApplyTo(
return err
}

srv.CSRFVerifier = csrfverifier.NewCSRFVerifier(srv.BaseURL, useSecureCookies)
allBaseURLs := append([]*url.URL{srv.BaseURL}, srv.AdditionalBaseURLs...)
srv.CSRFVerifier = csrfverifier.NewCSRFVerifier(allBaseURLs, useSecureCookies)
return nil
}

func (c *completedOptions) getAuthenticator(
baseURL *url.URL,
additionalBaseURLs []*url.URL,
k8sEndpoint *url.URL,
caCertFilePath string,
k8sClientConfig *rest.Config,
Expand All @@ -273,13 +276,19 @@ func (c *completedOptions) getAuthenticator(
var (
err error
userAuthOIDCIssuerURL *url.URL
authLoginErrorEndpoint = proxy.SingleJoiningSlash(baseURL.String(), server.AuthLoginErrorEndpoint)
authLoginSuccessEndpoint = proxy.SingleJoiningSlash(baseURL.String(), server.AuthLoginSuccessEndpoint)
authLoginErrorEndpoint = proxy.SingleJoiningSlash(baseURL.Path, server.AuthLoginErrorEndpoint)
authLoginSuccessEndpoint = proxy.SingleJoiningSlash(baseURL.Path, server.AuthLoginSuccessEndpoint)
oidcClientSecret = c.ClientSecret
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Abstraction leak required by NewAuthenticator. We only want the browser to send the auth token for paths starting with basePath/api.
cookiePath = proxy.SingleJoiningSlash(baseURL.Path, "/api")
)

allowedRedirectHosts := make(map[string]bool, 1+len(additionalBaseURLs))
allowedRedirectHosts[baseURL.Host] = true
for _, u := range additionalBaseURLs {
allowedRedirectHosts[u.Host] = true
}

var scopes []string
authSource := oauth2.AuthSourceOIDC

Expand All @@ -294,8 +303,6 @@ func (c *completedOptions) getAuthenticator(

}

oidcClientSecret = c.ClientSecret

// Config for logging into console.
oidcClientConfig := &oauth2.Config{
AuthSource: authSource,
Expand All @@ -319,8 +326,9 @@ func (c *completedOptions) getAuthenticator(
CookieEncryptionKey: sessionConfig.CookieEncryptionKey,
CookieAuthenticationKey: sessionConfig.CookieAuthenticationKey,

K8sConfig: k8sClientConfig,
Metrics: authMetrics,
K8sConfig: k8sClientConfig,
Metrics: authMetrics,
AllowedRedirectHosts: allowedRedirectHosts,
}

if c.LogoutRedirectURL != nil {
Expand Down
21 changes: 21 additions & 0 deletions cmd/bridge/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ func main() {
fListen := fs.String("listen", "http://0.0.0.0:9000", "")

fBaseAddress := fs.String("base-address", "", "Format: <http | https>://domainOrIPAddress[:port]. Example: https://openshift.example.com.")
fAdditionalBaseAddresses := fs.String("additional-base-addresses", "", "Comma-separated additional console base URLs for multi-domain support.")
fBasePath := fs.String("base-path", "/", "")

// See https://github.com/openshift/service-serving-cert-signer
Expand Down Expand Up @@ -205,6 +206,25 @@ func main() {
}
baseURL.Path = *fBasePath

var additionalBaseURLs []*url.URL
if *fAdditionalBaseAddresses != "" {
for _, addr := range strings.Split(*fAdditionalBaseAddresses, ",") {
addr = strings.TrimSpace(addr)
if addr == "" {
continue
}
u, err := url.Parse(addr)
if err != nil {
klog.Fatalf("invalid additional base address %q: %v", addr, err)
}
if u.Scheme == "" || u.Host == "" {
klog.Fatalf("additional base address %q must be an absolute URL with scheme and host", addr)
}
u.Path = *fBasePath
additionalBaseURLs = append(additionalBaseURLs, u)
}
}

documentationBaseURL := &url.URL{}
if *fDocumentationBaseURL != "" {
if !strings.HasSuffix(*fDocumentationBaseURL, "/") {
Expand Down Expand Up @@ -324,6 +344,7 @@ func main() {
srv := &server.Server{
PublicDir: *fPublicDir,
BaseURL: baseURL,
AdditionalBaseURLs: additionalBaseURLs,
Branding: branding,
CustomProductName: *fCustomProductName,
CustomLogoFiles: customLogoFlags,
Expand Down
69 changes: 68 additions & 1 deletion pkg/auth/csrfverifier/csfr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ func testReferer(t *testing.T, referer string, accept bool) {
refererURL, err := url.Parse(validReferer)
require.NoError(t, err)

a := CSRFVerifier{refererURL: refererURL}
a := CSRFVerifier{refererURLs: []*url.URL{refererURL}}

r, err := http.NewRequest("POST", "/some-path", nil)

Expand Down Expand Up @@ -62,6 +62,73 @@ func TestReferer(t *testing.T) {
testReferer(t, "https://google.com/asdf/", false)
}

func TestRefererMultipleURLs(t *testing.T) {
primaryURL, err := url.Parse("https://console.example.com/")
require.NoError(t, err)
secondaryURL, err := url.Parse("https://console-alt.example.com/")
require.NoError(t, err)
thirdURL, err := url.Parse("https://console.internal.example.com:8443/")
require.NoError(t, err)

a := CSRFVerifier{refererURLs: []*url.URL{primaryURL, secondaryURL, thirdURL}}

tests := []struct {
name string
referer string
accept bool
}{
{"primary URL accepted", "https://console.example.com/", true},
{"primary URL with path accepted", "https://console.example.com/k8s/cluster/nodes", true},
{"secondary URL accepted", "https://console-alt.example.com/", true},
{"secondary URL with path accepted", "https://console-alt.example.com/dashboards", true},
{"third URL with port accepted", "https://console.internal.example.com:8443/", true},
{"third URL with port and path accepted", "https://console.internal.example.com:8443/overview", true},
{"unknown host rejected", "https://evil.example.com/", false},
{"wrong scheme rejected", "http://console.example.com/", false},
{"wrong port rejected", "https://console.example.com:9999/", false},
{"empty referer rejected", "", false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, err := http.NewRequest("POST", "/some-path", nil)
require.NoError(t, err)
if tt.referer != "" {
r.Header.Set("Referer", tt.referer)
}
err = a.verifySourceOrigin(r)
if tt.accept {
require.NoError(t, err, "expected referer %q to be accepted", tt.referer)
} else {
require.Error(t, err, "expected referer %q to be rejected", tt.referer)
}
})
}
}

func TestRefererOriginHeaderMultipleURLs(t *testing.T) {
primaryURL, err := url.Parse("https://console.example.com/")
require.NoError(t, err)
secondaryURL, err := url.Parse("https://console-alt.example.com/")
require.NoError(t, err)

a := CSRFVerifier{refererURLs: []*url.URL{primaryURL, secondaryURL}}

t.Run("Origin header for secondary URL accepted", func(t *testing.T) {
r, err := http.NewRequest("POST", "/some-path", nil)
require.NoError(t, err)
r.Header.Set("Origin", "https://console-alt.example.com")
require.NoError(t, a.verifySourceOrigin(r))
})

t.Run("Origin header for unknown URL rejected", func(t *testing.T) {
r, err := http.NewRequest("POST", "/some-path", nil)
require.NoError(t, err)
r.Header.Set("Origin", "https://evil.example.com")
require.Error(t, a.verifySourceOrigin(r))
})
}

func testCSRF(t *testing.T, token string, cookie string, accept bool) {
a := CSRFVerifier{secureCookies: false}

Expand Down
25 changes: 13 additions & 12 deletions pkg/auth/csrfverifier/csrf.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ const (
)

type CSRFVerifier struct {
refererURL *url.URL
refererURLs []*url.URL
secureCookies bool
}

func NewCSRFVerifier(refererURL *url.URL, secureCookies bool) *CSRFVerifier {
func NewCSRFVerifier(refererURLs []*url.URL, secureCookies bool) *CSRFVerifier {
return &CSRFVerifier{
secureCookies: secureCookies,
refererURL: refererURL,
refererURLs: refererURLs,
}
}

Expand Down Expand Up @@ -90,16 +90,17 @@ func (c *CSRFVerifier) verifySourceOrigin(r *http.Request) (err error) {
return err
}

isValid := c.refererURL.Hostname() == u.Hostname() &&
c.refererURL.Port() == u.Port() &&
c.refererURL.Scheme == u.Scheme &&
// The Origin header does not have a path
(u.Path == "" || strings.HasPrefix(u.Path, c.refererURL.Path))

if !isValid {
return fmt.Errorf("invalid Origin or Referer: %v expected `%v`", source, c.refererURL)
for _, refererURL := range c.refererURLs {
isValid := refererURL.Hostname() == u.Hostname() &&
refererURL.Port() == u.Port() &&
refererURL.Scheme == u.Scheme &&
// The Origin header does not have a path
(u.Path == "" || strings.HasPrefix(u.Path, refererURL.Path))
if isValid {
return nil
}
}
return nil
return fmt.Errorf("invalid Origin or Referer: %v expected one of %v", source, c.refererURLs)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func (c *CSRFVerifier) verifyCSRFToken(r *http.Request) error {
Expand Down
45 changes: 36 additions & 9 deletions pkg/auth/oauth2/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ type OAuth2Authenticator struct {

// Custom login command to display in the console
ocLoginCommand string

// allowedRedirectHosts maps host (or host:port) strings that are
// allowed for dynamic OAuth redirect_uri selection.
allowedRedirectHosts map[string]bool
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// loginMethod is used to handle OAuth2 responses and associate bearer tokens
Expand Down Expand Up @@ -128,6 +132,10 @@ type Config struct {

// Custom login command to display in the console
OCLoginCommand string

// AllowedRedirectHosts maps host (or host:port) strings that are allowed
// for dynamic OAuth redirect_uri rewriting (multi-domain console support).
AllowedRedirectHosts map[string]bool
}

type completedConfig struct {
Expand Down Expand Up @@ -256,6 +264,24 @@ func (a *OAuth2Authenticator) oauth2ConfigConstructor(endpointConfig oauth2.Endp
return &baseOAuth2Config
}

// oauth2ConfigForHost returns an oauth2.Config with the redirect URL rewritten
// to use the given host, if that host is in the allowed set. Otherwise it
// returns the default config with the original redirect URL.
func (a *OAuth2Authenticator) oauth2ConfigForHost(host string) *oauth2.Config {
cfg := a.oauth2Config()
if host == "" || !a.allowedRedirectHosts[host] {
return cfg
}
u, err := url.Parse(a.redirectURL)
if err != nil {
klog.Errorf("failed to parse redirect URL %q: %v", a.redirectURL, err)
return cfg
}
u.Host = host
cfg.RedirectURL = u.String()
return cfg
}

func newUnstartedAuthenticator(c *completedConfig) *OAuth2Authenticator {
return &OAuth2Authenticator{
clientFunc: c.clientFunc,
Expand All @@ -264,13 +290,14 @@ func newUnstartedAuthenticator(c *completedConfig) *OAuth2Authenticator {
clientSecret: c.ClientSecret,
scopes: c.Scope,

redirectURL: c.RedirectURL,
errorURL: c.ErrorURL,
successURL: c.SuccessURL,
secureCookies: c.SecureCookies,
k8sConfig: c.K8sConfig,
metrics: c.Metrics,
ocLoginCommand: c.OCLoginCommand,
redirectURL: c.RedirectURL,
errorURL: c.ErrorURL,
successURL: c.SuccessURL,
secureCookies: c.SecureCookies,
k8sConfig: c.K8sConfig,
metrics: c.Metrics,
ocLoginCommand: c.OCLoginCommand,
allowedRedirectHosts: c.AllowedRedirectHosts,
}
}

Expand All @@ -293,7 +320,7 @@ func (a *OAuth2Authenticator) LoginFunc(w http.ResponseWriter, r *http.Request)
Secure: a.secureCookies,
}
http.SetCookie(w, &cookie)
http.Redirect(w, r, a.oauth2Config().AuthCodeURL(state), http.StatusSeeOther)
http.Redirect(w, r, a.oauth2ConfigForHost(r.Host).AuthCodeURL(state), http.StatusSeeOther)
}

// LogoutFunc cleans up session cookies.
Expand Down Expand Up @@ -346,7 +373,7 @@ func (a *OAuth2Authenticator) CallbackFunc(fn func(loginInfo sessions.LoginJSON,
return
}
ctx := oidc.ClientContext(r.Context(), a.clientFunc())
oauthConfig := a.oauth2Config()
oauthConfig := a.oauth2ConfigForHost(r.Host)
token, err := oauthConfig.Exchange(ctx, code)
if err != nil {
klog.Errorf("unable to verify auth code with issuer: %v", err)
Expand Down
Loading