Skip to content

Limit /login-sso-profile response to fields used by the login UI #384

Description

@buger

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions