Skip to content
Merged
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
25 changes: 25 additions & 0 deletions static/app/utils/intercom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface IntercomJwtResponse {

let hasBooted = false;
let bootPromise: Promise<void> | null = null;
let bootedOrgSlug: string | null = null;

/**
* Initialize Intercom with identity verification.
Expand Down Expand Up @@ -69,6 +70,7 @@ async function initIntercom(orgSlug: string): Promise<void> {
});

hasBooted = true;
bootedOrgSlug = orgSlug;
} catch (error) {
// Reset so user can retry on next click
bootPromise = null;
Expand All @@ -79,11 +81,34 @@ async function initIntercom(orgSlug: string): Promise<void> {
return bootPromise;
}

/**
* Shutdown Intercom and clear session data.
* Call this when user logs out or switches organizations.
*/
export async function shutdownIntercom(): Promise<void> {
if (!hasBooted) {
return;
}

const {shutdown} = await import('@intercom/messenger-js-sdk');
shutdown();

hasBooted = false;
bootPromise = null;
bootedOrgSlug = null;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shutdown ignores in-flight boot causing cross-org session leak

High Severity

shutdownIntercom only checks hasBooted but ignores a pending bootPromise. If a user switches orgs while Intercom is still booting (e.g., JWT fetch in progress), hasBooted is false, so shutdown is a no-op. The boot then completes for the old org, creating the exact cross-org session leak this PR intends to prevent. The same gap exists in showIntercom: the guard hasBooted && bootedOrgSlug !== orgSlug misses the in-progress case, and initIntercom would reuse the stale bootPromise for the wrong org.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 27a607c. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@scttcper is this actually possible? I would think that hasBooted is always true if we got a bootPromise.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i guess. Start the request -> switch orgs -> jwtData comes back for the previous org or something

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't really seem like it would be that bad, i'm not sure switching orgs and contacting support is the most common thing

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sentaur-athena actually thinking about this more, there's no way for this to happen for us because we make them switch subdomains and its a full page reload

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for checking. I can address this but it would make the code extremely confusing and dirty. Will merge as is.


/**
* Show the Intercom Messenger.
* Lazily initializes Intercom on first call.
* If already booted for a different org, shuts down first and re-initializes.
*/
export async function showIntercom(orgSlug: string): Promise<void> {
// If booted for a different org, shutdown first to re-initialize with new org context
if (hasBooted && bootedOrgSlug !== orgSlug) {
await shutdownIntercom();
}

await initIntercom(orgSlug);
const {show} = await import('@intercom/messenger-js-sdk');
show();
Expand Down
3 changes: 3 additions & 0 deletions static/app/views/organizationContext.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {OrganizationStore} from 'sentry/stores/organizationStore';
import {ProjectsStore} from 'sentry/stores/projectsStore';
import {TeamStore} from 'sentry/stores/teamStore';
import type {Organization} from 'sentry/types/organization';
import * as intercom from 'sentry/utils/intercom';
import {useOrganization} from 'sentry/utils/useOrganization';

import {OrganizationContextProvider} from './organizationContext';
Expand Down Expand Up @@ -114,6 +115,7 @@ describe('OrganizationContext', () => {
const {orgMock, projectMock, teamMock} = setupOrgMocks(anotherOrg);

const switchOrganization = jest.spyOn(orgsActionCreators, 'switchOrganization');
const shutdownIntercom = jest.spyOn(intercom, 'shutdownIntercom');

// re-render with another-org
testRouter.navigate(`/organizations/${anotherOrg.slug}/`);
Expand All @@ -123,6 +125,7 @@ describe('OrganizationContext', () => {
expect(projectMock).toHaveBeenCalled();
expect(teamMock).toHaveBeenCalled();
expect(switchOrganization).toHaveBeenCalled();
expect(shutdownIntercom).toHaveBeenCalled();
expect(JSON.stringify(OrganizationStore.getState().organization)).toEqual(
JSON.stringify(anotherOrg)
);
Expand Down
3 changes: 3 additions & 0 deletions static/app/views/organizationContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {TeamStore} from 'sentry/stores/teamStore';
import {useLegacyStore} from 'sentry/stores/useLegacyStore';
import type {Organization} from 'sentry/types/organization';
import type {User} from 'sentry/types/user';
import {shutdownIntercom} from 'sentry/utils/intercom';
import {useParams} from 'sentry/utils/useParams';

interface Props {
Expand Down Expand Up @@ -147,6 +148,8 @@ export function OrganizationContextProvider({children}: Props) {
// Also avoid: org1 -> undefined -> org1
if (lastOrgId.current) {
switchOrganization();
// Shutdown Intercom so it re-initializes with new org context on next use
shutdownIntercom();
}

lastOrgId.current = orgSlug;
Expand Down
Loading