Summary
The public login bootstrap endpoint returns the full SSO profile response, although the login page only uses data.attributes.login_url. The response currently includes backend-only configuration such as ProviderConfig and IdentityHandlerConfig.
Related report: Zerocopter #135847. Credential values and production-specific evidence are intentionally omitted here.
Attack surface
The endpoint is unauthenticated by design because it is called before a user signs in. As a result, every field in its response is available to any network client that can reach AI Studio.
The demonstrated impact is limited to disclosure of the configured OIDC client credentials and the ability to request a short-lived client_credentials token. Validation found that this token:
- contains no user subject, email, groups, ID token, or refresh token;
- is rejected by OneLogin userinfo and the OneLogin Users API;
- is rejected by AI Studio session and protected API endpoints.
No AI Studio login bypass, OneLogin user login, admin access, or user impersonation was demonstrated. A remaining configuration-dependent question is whether a separate custom API accepts client-credentials tokens issued to this OIDC client.
Cause
- serializeProfile copies ProviderConfig into the response:
|
func serializeProfile(profile *models.Profile) ProfileResponse { |
|
accessor := helpers.NewJSONMapAccessor(profile.ProviderConfig) |
|
callbackBaseURL := accessor.GetString("CallbackBaseURL", "") |
|
|
|
if profile.SelectedProviderType == provSAML { |
|
callbackBaseURL = accessor.GetString("SAMLBaseURL", "") |
|
} |
|
|
|
if callbackBaseURL != "" && !strings.HasSuffix(callbackBaseURL, "/") { |
|
callbackBaseURL += "/" |
|
} |
|
|
|
failureRedirect := accessor.GetString("FailureRedirect", "") |
|
|
|
resp := ProfileResponse{ |
|
Type: "sso-profiles", |
|
ID: profile.Model.ID, |
|
} |
|
|
|
resp.Attributes.Name = profile.Name |
|
resp.Attributes.OrgID = profile.OrgID |
|
resp.Attributes.ActionType = profile.ActionType |
|
resp.Attributes.MatchedPolicyID = profile.MatchedPolicyID |
|
resp.Attributes.Type = profile.Type |
|
resp.Attributes.ProviderName = profile.ProviderName |
|
resp.Attributes.CustomEmailField = profile.CustomEmailField |
|
resp.Attributes.CustomUserIDField = profile.CustomUserIDField |
|
resp.Attributes.ProviderConfig = profile.ProviderConfig |
|
resp.Attributes.IdentityHandlerConfig = profile.IdentityHandlerConfig |
|
resp.Attributes.ProviderConstraintsDomain = profile.ProviderConstraintsDomain |
|
resp.Attributes.ProviderConstraintsGroup = profile.ProviderConstraintsGroup |
|
resp.Attributes.ReturnURL = profile.ReturnURL |
|
resp.Attributes.DefaultUserGroupID = profile.DefaultUserGroupID |
|
resp.Attributes.CustomUserGroupField = profile.CustomUserGroupField |
|
resp.Attributes.UserGroupMapping = profile.UserGroupMapping |
|
resp.Attributes.UserGroupSeparator = profile.UserGroupSeparator |
|
resp.Attributes.SSOOnlyForRegisteredUsers = profile.SSOOnlyForRegisteredUsers |
|
resp.Attributes.ProfileID = profile.ProfileID |
|
resp.Attributes.SelectedProviderType = profile.SelectedProviderType |
|
|
|
urlFormat := "%sauth/%s/%s" |
|
callbackUrlFormat := "%sauth/%s/%s/callback" |
|
|
|
resp.Attributes.LoginURL = fmt.Sprintf(urlFormat, callbackBaseURL, profile.ProfileID, profile.SelectedProviderType) |
|
resp.Attributes.CallbackURL = fmt.Sprintf(callbackUrlFormat, callbackBaseURL, profile.ProfileID, profile.SelectedProviderType) |
|
resp.Attributes.FailureRedirectURL = failureRedirect |
|
resp.Attributes.UseInLoginPage = profile.UseInLoginPage |
|
|
|
return resp |
- getLoginPageProfile reuses that serializer on the public route:
|
// @Summary Get the profile used in the login page |
|
// @Description Get the profile that has UseInLoginPage set to true |
|
// @Tags sso-profiles |
|
// @Accept json |
|
// @Produce json |
|
// @Success 200 {object} ProfileResponse |
|
// @Failure 404 {object} ErrorResponse |
|
// @Failure 500 {object} ErrorResponse |
|
// @Router /login-sso-profile [get] |
|
func (a *API) getLoginPageProfile(c *gin.Context) { |
|
profile, err := a.service.GetLoginPageProfile() |
|
if err != nil { |
|
helpers.SendErrorResponse(c, err) |
|
return |
|
} |
|
|
|
c.JSON(http.StatusOK, gin.H{"data": serializeProfile(profile)}) |
|
} |
- The login page reads only data.attributes.login_url:
|
{ssoEnabled && ssoProfile && ( |
|
<> |
|
<Box |
|
sx={{ |
|
display: 'flex', |
|
alignItems: 'center', |
|
mt: 5, |
|
mb: 3, |
|
"&::before, &::after": { |
|
content: '""', |
|
flex: 1, |
|
borderBottom: `1px solid ${theme.palette.background.buttonPrimaryOutlineHover}` |
|
} |
|
}} |
|
> |
|
<Typography |
|
variant="headingSmall" |
|
color={theme.palette.custom.white} |
|
sx={{ mx: 2 }} |
|
> |
|
OR |
|
</Typography> |
|
</Box> |
|
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', width: '100%' }}> |
|
<PrimaryButton |
|
component="a" |
|
href={ssoProfile.attributes.login_url |
|
} |
|
variant="contained" |
|
> |
|
Log in with SSO |
|
</PrimaryButton> |
|
</Box> |
|
</> |
|
)} |
- The SSO start handler reloads the complete profile server-side:
|
func (a *API) handleTIBAuth(c *gin.Context) { |
|
user := a.auth.GetAuthenticatedUser(c) |
|
if user != nil { |
|
c.Redirect(http.StatusFound, "/") |
|
return |
|
} |
|
|
|
id := c.Param("id") |
|
if id == "" { |
|
helpers.SendErrorResponse(c, helpers.NewBadRequestError("Identity provider ID is required")) |
|
return |
|
} |
|
|
|
provider := c.Param("provider") |
|
if provider == "" { |
|
helpers.SendErrorResponse(c, helpers.NewBadRequestError("Provider name is required")) |
|
return |
|
} |
|
|
|
identityProvider, profile, err := a.ssoService.GetTapProfile(id) |
|
if err != nil { |
|
helpers.SendErrorResponse(c, err) |
|
return |
|
} |
|
|
|
params := map[string]string{ |
|
"id": id, |
|
"provider": provider, |
|
} |
|
|
|
identityProvider.Handle(c.Writer, c.Request, params, *profile) |
|
} |
Suggested change
Use a dedicated public response type for getLoginPageProfile. Keep the existing envelope and fields needed by the login UI:
- data.type
- data.id
- data.attributes.name
- data.attributes.profile_id
- data.attributes.selected_provider_type
- data.attributes.login_url
Exclude ProviderConfig, IdentityHandlerConfig, group and provisioning configuration, and other admin-only fields. A dedicated allowlisted type is preferable to removing individual secret keys from the generic map.
The endpoint should remain unauthenticated. The complete profile should remain in server-side storage for the authorization-code exchange. This should therefore be a backend-only response change and should not affect the SSO redirect or callback flow.
Acceptance criteria
- Anonymous requests still receive a working data.attributes.login_url.
- The public response contains no provider credentials or backend-only configuration.
- The SSO button redirects to the configured IdP and callback login succeeds.
- Existing-user and JIT-provisioned-user SSO behavior is unchanged.
- Authenticated admin profile CRUD remains functional.
- A unit test uses a sentinel secret and verifies that it is absent from the public response.
Operational follow-up
After the response change is deployed, rotate the disclosed client secret and check whether this client is assigned to any custom API authorization resources. Disable client_credentials for the app if it is not needed and OneLogin supports that restriction without changing the authorization-code flow.
Summary
The public login bootstrap endpoint returns the full SSO profile response, although the login page only uses data.attributes.login_url. The response currently includes backend-only configuration such as ProviderConfig and IdentityHandlerConfig.
Related report: Zerocopter #135847. Credential values and production-specific evidence are intentionally omitted here.
Attack surface
The endpoint is unauthenticated by design because it is called before a user signs in. As a result, every field in its response is available to any network client that can reach AI Studio.
The demonstrated impact is limited to disclosure of the configured OIDC client credentials and the ability to request a short-lived client_credentials token. Validation found that this token:
No AI Studio login bypass, OneLogin user login, admin access, or user impersonation was demonstrated. A remaining configuration-dependent question is whether a separate custom API accepts client-credentials tokens issued to this OIDC client.
Cause
ai-studio/api/profile_handlers_enterprise.go
Lines 24 to 72 in 063c22b
ai-studio/api/profile_handlers_enterprise.go
Lines 377 to 394 in 063c22b
ai-studio/ui/admin-frontend/src/portal/pages/Login.js
Lines 156 to 191 in 063c22b
ai-studio/api/sso_handlers_enterprise.go
Lines 27 to 58 in 063c22b
Suggested change
Use a dedicated public response type for getLoginPageProfile. Keep the existing envelope and fields needed by the login UI:
Exclude ProviderConfig, IdentityHandlerConfig, group and provisioning configuration, and other admin-only fields. A dedicated allowlisted type is preferable to removing individual secret keys from the generic map.
The endpoint should remain unauthenticated. The complete profile should remain in server-side storage for the authorization-code exchange. This should therefore be a backend-only response change and should not affect the SSO redirect or callback flow.
Acceptance criteria
Operational follow-up
After the response change is deployed, rotate the disclosed client secret and check whether this client is assigned to any custom API authorization resources. Disable client_credentials for the app if it is not needed and OneLogin supports that restriction without changing the authorization-code flow.