Add sanity checks to prevent users from ignoring themselves (#30079)

* Fix missing state

* Throw error if membership event changes

* Write test

* Fix broken tests

* Cache inviter when room is loaded

* Translate error message for dialog
This commit is contained in:
R Midhun Suresh
2025-06-04 23:09:19 +05:30
committed by GitHub
parent 9c0604f849
commit b9f319a9f5
4 changed files with 95 additions and 13 deletions

View File

@@ -370,6 +370,10 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
private unmounted = false; private unmounted = false;
private permalinkCreators: Record<string, RoomPermalinkCreator> = {}; private permalinkCreators: Record<string, RoomPermalinkCreator> = {};
// The userId from which we received this invite.
// Only populated if the membership of our user is invite.
private inviter?: string;
private roomView = createRef<HTMLDivElement>(); private roomView = createRef<HTMLDivElement>();
private searchResultsPanel = createRef<ScrollPanel>(); private searchResultsPanel = createRef<ScrollPanel>();
private messagePanel: TimelinePanel | null = null; private messagePanel: TimelinePanel | null = null;
@@ -1350,6 +1354,11 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
// after a successful peek, or after we join the room). // after a successful peek, or after we join the room).
private onRoomLoaded = (room: Room): void => { private onRoomLoaded = (room: Room): void => {
if (this.unmounted) return; if (this.unmounted) return;
// Store the inviter so that we can know who invited us to this room even if
// the membership event changes.
this.inviter = this.getInviterFromRoom(room);
// Attach a widget store listener only when we get a room // Attach a widget store listener only when we get a room
this.context.widgetLayoutStore.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); this.context.widgetLayoutStore.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange);
@@ -1729,8 +1738,20 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}); });
}; };
private getInviterFromRoom(room: Room): string | undefined {
const ownUserId = this.context.client?.getSafeUserId();
if (!ownUserId) return;
const myMember = room.getMember(ownUserId);
const memberEvent = myMember?.events.member;
const senderId = memberEvent?.getSender();
if (memberEvent?.getContent().membership === KnownMembership.Invite) return senderId;
}
private onDeclineAndBlockButtonClicked = async (): Promise<void> => { private onDeclineAndBlockButtonClicked = async (): Promise<void> => {
if (!this.state.room || !this.context.client) return; if (!this.state.room || !this.context.client) return;
const [shouldReject, ignoreUser, reportRoom] = await Modal.createDialog(DeclineAndBlockInviteDialog, { const [shouldReject, ignoreUser, reportRoom] = await Modal.createDialog(DeclineAndBlockInviteDialog, {
roomName: this.state.room.name, roomName: this.state.room.name,
}).finished; }).finished;
@@ -1745,11 +1766,20 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
const actions: Promise<unknown>[] = []; const actions: Promise<unknown>[] = [];
if (ignoreUser) { if (ignoreUser) {
const myMember = this.state.room.getMember(this.context.client!.getSafeUserId()); const doIgnore = async (): Promise<void> => {
const inviteEvent = myMember!.events.member; const ownUserId = this.context.client!.getSafeUserId();
const ignoredUsers = this.context.client.getIgnoredUsers(); if (!this.inviter || this.inviter === ownUserId) {
ignoredUsers.push(inviteEvent!.getSender()!); // de-duped internally in the js-sdk // This is unlikely to happen since we cache the inviter as early as possible.
actions.push(this.context.client.setIgnoredUsers(ignoredUsers)); // However, we still do this check here to be double sure.
throw new CannotDetermineUserError(
"Cannot determine which user to ignore since the member event has changed.",
);
}
const ignoredUsers = this.context.client!.getIgnoredUsers();
ignoredUsers.push(this.inviter); // de-duped internally in the js-sdk
await this.context.client!.setIgnoredUsers(ignoredUsers);
};
actions.push(doIgnore());
} }
if (reportRoom !== false) { if (reportRoom !== false) {
@@ -1766,7 +1796,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
} catch (error) { } catch (error) {
logger.error(`Failed to reject invite: ${error}`); logger.error(`Failed to reject invite: ${error}`);
const msg = error instanceof Error ? error.message : JSON.stringify(error); let msg: string = "";
if (error instanceof CannotDetermineUserError) {
msg = _t("room|failed_determine_user");
} else if (error instanceof Error) {
msg = error.message;
} else {
msg = JSON.stringify(error);
}
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: _t("room|failed_reject_invite"), title: _t("room|failed_reject_invite"),
description: msg, description: msg,
@@ -1783,6 +1820,9 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
return; return;
} }
try { try {
this.setState({
rejecting: true,
});
await this.context.client.leave(this.state.room.roomId); await this.context.client.leave(this.state.room.roomId);
defaultDispatcher.dispatch({ action: Action.ViewHomePage }); defaultDispatcher.dispatch({ action: Action.ViewHomePage });
this.setState({ this.setState({
@@ -2612,3 +2652,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
); );
} }
} }
class CannotDetermineUserError extends Error {
public name = "CannotDetermineUserError";
}

View File

@@ -1957,6 +1957,7 @@
}, },
"face_pile_tooltip_shortcut": "Including %(commaSeparatedMembers)s", "face_pile_tooltip_shortcut": "Including %(commaSeparatedMembers)s",
"face_pile_tooltip_shortcut_joined": "Including you, %(commaSeparatedMembers)s", "face_pile_tooltip_shortcut_joined": "Including you, %(commaSeparatedMembers)s",
"failed_determine_user": "Cannot determine which user to ignore since the member event has changed.",
"failed_reject_invite": "Failed to reject invite", "failed_reject_invite": "Failed to reject invite",
"forget_room": "Forget this room", "forget_room": "Forget this room",
"forget_space": "Forget this space", "forget_space": "Forget this space",

View File

@@ -77,6 +77,7 @@ import { type ViewUserPayload } from "../../../../src/dispatcher/payloads/ViewUs
import { CallStore } from "../../../../src/stores/CallStore.ts"; import { CallStore } from "../../../../src/stores/CallStore.ts";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../../src/MediaDeviceHandler.ts"; import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../../src/MediaDeviceHandler.ts";
import Modal from "../../../../src/Modal.tsx"; import Modal from "../../../../src/Modal.tsx";
import ErrorDialog from "../../../../src/components/views/dialogs/ErrorDialog.tsx";
// Used by group calls // Used by group calls
jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({ jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({
@@ -237,6 +238,7 @@ describe("RoomView", () => {
member.membership = KnownMembership.Invite; member.membership = KnownMembership.Invite;
member.events.member = new MatrixEvent({ member.events.member = new MatrixEvent({
sender: "@bob:example.org", sender: "@bob:example.org",
content: { membership: KnownMembership.Invite },
}); });
room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Invite); room.getMyMembership = jest.fn().mockReturnValue(KnownMembership.Invite);
room.getMember = jest.fn().mockReturnValue(member); room.getMember = jest.fn().mockReturnValue(member);
@@ -271,10 +273,45 @@ describe("RoomView", () => {
finished: Promise.resolve([true, true, false]), finished: Promise.resolve([true, true, false]),
close: jest.fn(), close: jest.fn(),
}); });
await fireEvent.click(getByRole("button", { name: "Decline and block" })); await act(() => fireEvent.click(getByRole("button", { name: "Decline and block" })));
expect(cli.leave).toHaveBeenCalledWith(room.roomId); expect(cli.leave).toHaveBeenCalledWith(room.roomId);
expect(cli.setIgnoredUsers).toHaveBeenCalledWith(["@carol:example.org", "@bob:example.org"]); expect(cli.setIgnoredUsers).toHaveBeenCalledWith(["@carol:example.org", "@bob:example.org"]);
}); });
it("prevents ignoring own user", async () => {
const member = new RoomMember(room.roomId, cli.getSafeUserId());
member.membership = KnownMembership.Invite;
member.events.member = new MatrixEvent({
/*
It doesn't matter that this is an invite event coming from own user, we just
want to simulate a situation where the sender of the membership event somehow
ends up being own user.
*/
sender: cli.getSafeUserId(),
content: { membership: KnownMembership.Invite },
});
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Invite);
jest.spyOn(room, "getMember").mockReturnValue(member);
const { getByRole } = await mountRoomView();
cli.getIgnoredUsers.mockReturnValue(["@carol:example.org"]);
jest.spyOn(Modal, "createDialog").mockReturnValue({
finished: Promise.resolve([true, true, false]),
close: jest.fn(),
});
await act(() => fireEvent.click(getByRole("button", { name: "Decline and block" })));
// Should show error in a modal dialog
await waitFor(() => {
expect(Modal.createDialog).toHaveBeenLastCalledWith(ErrorDialog, {
title: "Failed to reject invite",
description: "Cannot determine which user to ignore since the member event has changed.",
});
});
// The ignore call should not go through
expect(cli.setIgnoredUsers).not.toHaveBeenCalled();
});
it("handles declining an invite and reporting the room", async () => { it("handles declining an invite and reporting the room", async () => {
const { getByRole } = await mountRoomView(); const { getByRole } = await mountRoomView();
jest.spyOn(Modal, "createDialog").mockReturnValue({ jest.spyOn(Modal, "createDialog").mockReturnValue({

View File

@@ -1363,7 +1363,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
aria-label="Open room settings" aria-label="Open room settings"
aria-live="off" aria-live="off"
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52" class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="4" data-color="5"
data-testid="avatar-img" data-testid="avatar-img"
data-type="round" data-type="round"
role="button" role="button"
@@ -1390,7 +1390,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
<span <span
class="mx_RoomHeader_truncated mx_lineClamp" class="mx_RoomHeader_truncated mx_lineClamp"
> >
!11:example.org !12:example.org
</span> </span>
</div> </div>
</div> </div>
@@ -1571,7 +1571,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
aria-label="Open room settings" aria-label="Open room settings"
aria-live="off" aria-live="off"
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52" class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="4" data-color="5"
data-testid="avatar-img" data-testid="avatar-img"
data-type="round" data-type="round"
role="button" role="button"
@@ -1598,7 +1598,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
<span <span
class="mx_RoomHeader_truncated mx_lineClamp" class="mx_RoomHeader_truncated mx_lineClamp"
> >
!11:example.org !12:example.org
</span> </span>
</div> </div>
</div> </div>
@@ -1952,7 +1952,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
aria-label="Open room settings" aria-label="Open room settings"
aria-live="off" aria-live="off"
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52" class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="3" data-color="4"
data-testid="avatar-img" data-testid="avatar-img"
data-type="round" data-type="round"
role="button" role="button"
@@ -1979,7 +1979,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
<span <span
class="mx_RoomHeader_truncated mx_lineClamp" class="mx_RoomHeader_truncated mx_lineClamp"
> >
!16:example.org !17:example.org
</span> </span>
</div> </div>
</div> </div>