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
6 changes: 6 additions & 0 deletions pkg/auth/authproviders/registry_httphandler.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,11 +356,17 @@ func (r *registryImpl) providersHTTPHandler(w http.ResponseWriter, req *http.Req
clientState, false)
return
}
if callbackURL.Scheme != "http" && callbackURL.Scheme != "https" {
r.error(w, errox.InvalidArgs.New("roxctl authorization callback URL must use http or https"), typ,
clientState, false)
return
}
// Verify the callback URL again before doing the final redirect, ensuring we _only_ redirect to localhost and
// no unauthorized third-party.
if !netutil.IsLocalHost(callbackURL.Hostname()) {
r.error(w, errox.InvalidArgs.New("roxctl authorization has to specify localhost / "+
"127.0.0.1 as callback URL"), typ, clientState, false)
return
}
qp := callbackURL.Query()
qp.Set(TokenQueryParameter, tokenInfo.Token)
Expand Down
73 changes: 71 additions & 2 deletions pkg/auth/authproviders/registry_httphandler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,75 @@ func (s *registryProviderCallbackTestSuite) TestAuthenticationIssuesTokenForUser
"callback activated for user with valid roles should issue a token")
}

func (s *registryProviderCallbackTestSuite) setupRoxctlAuthenticatedUser() {
authRsp := generateAuthResponse(testUserWithAdminRole, nil)
testAuthProviderBackend.registerProcessHTTPResponse(authRsp, nil)
adminRole := roletest.NewResolvedRoleWithDenyAll(testUserWithAdminRole, nil)
rolemapping := make(map[string][]perm.ResolvedRole)
rolemapping[testUserWithAdminRole] = []perm.ResolvedRole{adminRole}
testRoleMapper.registerRoleMapping(rolemapping)
}

func (s *registryProviderCallbackTestSuite) TestRoxctlRejectsNonLocalhostCallbackURL() {
urlPrefix := s.registry.providersURLPrefix()
req, err := http.NewRequest(http.MethodGet, urlPrefix+dummyProviderType+"/callback", strings.NewReader(""))
s.Require().NoError(err)

maliciousState := fmt.Sprintf("%s#%s", idputil.AuthorizeRoxctlClientState, "https://evil.com/steal")
testAuthProviderBackendFactory.registerProcessResponse(dummyProviderType, maliciousState, nil)
s.setupRoxctlAuthenticatedUser()
s.registry.providersHTTPHandler(s.writer, req)

s.Equal(http.StatusSeeOther, s.writer.Code)
redirectURL, err := url.Parse(s.writer.Header().Get("Location"))
s.Require().NoError(err)
s.Equal(s.registry.redirectURL, redirectURL.Path,
"must redirect to the safe error page, not the attacker-controlled host")
redirectURLFragments, err := url.ParseQuery(redirectURL.Fragment)
s.Require().NoError(err)
s.Empty(redirectURLFragments.Get("token"), "must not leak token to attacker-controlled host")
s.NotEmpty(redirectURLFragments.Get("error"), "should include an error message")
}

func (s *registryProviderCallbackTestSuite) TestRoxctlRejectsJavascriptScheme() {
urlPrefix := s.registry.providersURLPrefix()
req, err := http.NewRequest(http.MethodGet, urlPrefix+dummyProviderType+"/callback", strings.NewReader(""))
s.Require().NoError(err)

maliciousState := fmt.Sprintf("%s#%s", idputil.AuthorizeRoxctlClientState, "javascript://localhost/alert(1)")
testAuthProviderBackendFactory.registerProcessResponse(dummyProviderType, maliciousState, nil)
s.setupRoxctlAuthenticatedUser()
s.registry.providersHTTPHandler(s.writer, req)

s.Equal(http.StatusSeeOther, s.writer.Code)
redirectURL, err := url.Parse(s.writer.Header().Get("Location"))
s.Require().NoError(err)
s.Equal(s.registry.redirectURL, redirectURL.Path,
"must redirect to the safe error page, not execute javascript")
redirectURLFragments, err := url.ParseQuery(redirectURL.Fragment)
s.Require().NoError(err)
s.Empty(redirectURLFragments.Get("token"), "must not leak token via javascript scheme")
s.NotEmpty(redirectURLFragments.Get("error"), "should include an error message")
}

func (s *registryProviderCallbackTestSuite) TestRoxctlAllowsLocalhostCallbackURL() {
urlPrefix := s.registry.providersURLPrefix()
req, err := http.NewRequest(http.MethodGet, urlPrefix+dummyProviderType+"/callback", strings.NewReader(""))
s.Require().NoError(err)

localhostState := fmt.Sprintf("%s#%s", idputil.AuthorizeRoxctlClientState, "http://localhost:12345/callback")
testAuthProviderBackendFactory.registerProcessResponse(dummyProviderType, localhostState, nil)
s.setupRoxctlAuthenticatedUser()
s.registry.providersHTTPHandler(s.writer, req)

s.Equal(http.StatusSeeOther, s.writer.Code)
redirectURL, err := url.Parse(s.writer.Header().Get("Location"))
s.Require().NoError(err)
s.Equal("localhost:12345", redirectURL.Host, "should redirect to the localhost callback")
qp := redirectURL.Query()
s.Equal(testDummyTokenData, qp.Get("token"), "should include the token for valid localhost callback")
}

func (s *registryProviderCallbackTestSuite) TestAuthenticationVerifyRequiredAttributes() {
urlPrefix := s.registry.providersURLPrefix()
req, err := http.NewRequest(http.MethodGet, urlPrefix+dummyAttributeVerifierProviderType+"/callback", strings.NewReader(""))
Expand Down Expand Up @@ -567,10 +636,10 @@ func (*tstAuthProviderStore) RemoveAuthProvider(_ context.Context, _ string, _ b

type tstTokenIssuer struct{}

func (*tstTokenIssuer) Issue(_ context.Context, _ tokens.RoxClaims, _ ...tokens.Option) (*tokens.TokenInfo, error) {
func (*tstTokenIssuer) Issue(_ context.Context, roxClaims tokens.RoxClaims, _ ...tokens.Option) (*tokens.TokenInfo, error) {
token := &tokens.TokenInfo{
Token: testDummyTokenData,
Claims: nil,
Claims: &tokens.Claims{RoxClaims: roxClaims},
Sources: []tokens.Source{},
}
return token, nil
Expand Down
11 changes: 10 additions & 1 deletion ui/apps/platform/src/sagas/authSagas.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,14 +227,23 @@ function* handleAuthorizeRoxctlLoginResponse(result) {
errorDescription: result?.error_description || null,
token: result?.token || null,
};
// Verify that the callback URL is pointing to localhost.
// Verify that the callback URL is pointing to localhost with an allowed protocol.
const parsedCallbackURL = new URL(result.clientState);
if (parsedCallbackURL.protocol !== 'http:' && parsedCallbackURL.protocol !== 'https:') {
yield call(
handleErrAuthResponse,
result,
'Invalid callback URL protocol for roxctl authorization. Only http and https are allowed'
);
return;
}
if (parsedCallbackURL.hostname !== 'localhost' && parsedCallbackURL.hostname !== '127.0.0.1') {
yield call(
handleErrAuthResponse,
result,
'Invalid callback URL given for roxctl authorization. Only localhost is allowed as callback'
);
return;
}
// Redirect to the callback URL (i.e. the server opened by roxctl central login) with the token as query parameter
// or any error that may have occurred.
Expand Down
67 changes: 67 additions & 0 deletions ui/apps/platform/src/sagas/authSagas.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,73 @@ describe('Auth Sagas', () => {
});
});

it('should reject roxctl callback URL pointing to non-localhost host', () => {
delete window.location;
window.location = { assign: vi.fn() };
const token = 'my-token';
const maliciousURL = 'https://evil.com/steal';
const serverState = `provider-id:2ed17ca6-4b3c-4279-8317-f26f8ba01c52#${maliciousURL}`;

return expectSaga(saga)
.provide([
...createStateSelectors(),
[call(authServiceFetchLoginAuthProviders), { response: [] }],
[
call(authServiceExchangeAuthToken, `#id_token=${token}`, 'oidc', serverState),
{ token, clientState: maliciousURL },
],
[call(authServiceGetAndClearRequestedLocation), null],
[call(authServiceLogout), null],
[call(fetchUserRolePermissions), { response: {} }],
[call(authServiceFetchAvailableProviderTypes), { response: [] }],
])
.dispatch(
createLocationChange(
'/auth/response/oidc',
null,
`#id_token=${token}&state=${serverState}`
)
)
.silentRun()
.then(() => {
expect(window.location.assign).not.toHaveBeenCalled();
});
});

it('should reject roxctl callback URL with javascript: protocol', () => {
delete window.location;
window.location = { assign: vi.fn() };
const token = 'my-token';
// eslint-disable-next-line no-script-url
const maliciousURL = 'javascript://localhost/%0aalert(1)';
const serverState = `provider-id:2ed17ca6-4b3c-4279-8317-f26f8ba01c52#${maliciousURL}`;

return expectSaga(saga)
.provide([
...createStateSelectors(),
[call(authServiceFetchLoginAuthProviders), { response: [] }],
[
call(authServiceExchangeAuthToken, `#id_token=${token}`, 'oidc', serverState),
{ token, clientState: maliciousURL },
],
[call(authServiceGetAndClearRequestedLocation), null],
[call(authServiceLogout), null],
[call(fetchUserRolePermissions), { response: {} }],
[call(authServiceFetchAvailableProviderTypes), { response: [] }],
])
.dispatch(
createLocationChange(
'/auth/response/oidc',
null,
`#id_token=${token}&state=${serverState}`
)
)
.silentRun()
.then(() => {
expect(window.location.assign).not.toHaveBeenCalled();
});
});

it('should handle SAML response with test mode', () => {
const storeLocationMock = vi.fn();
const user =
Expand Down
Loading