Fix soft crash around inviting invalid MXIDs in start DM on first message flow (#9281)

* Fix soft crash around inviting invalid MXIDs

* Make ts --strict happier

* Prevent suggesting invalid MXIDs

* Add tests

* Fix coverage

* Fix coverage

* Make tsc --strict happier

* Fix test

* Add tests
This commit is contained in:
Michael Telatynski 2022-09-16 09:03:17 +01:00 committed by GitHub
parent 4fec436883
commit 4a23630e06
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 194 additions and 57 deletions

View file

@ -50,7 +50,7 @@ const checkDMRoom = () => {
const startDMWithBob = function(this: CryptoTestContext) { const startDMWithBob = function(this: CryptoTestContext) {
cy.get('.mx_RoomList [aria-label="Start chat"]').click(); cy.get('.mx_RoomList [aria-label="Start chat"]').click();
cy.get('[data-test-id="invite-dialog-input"]').type(this.bob.getUserId()); cy.get('[data-testid="invite-dialog-input"]').type(this.bob.getUserId());
cy.contains(".mx_InviteDialog_tile_nameStack_name", "Bob").click(); cy.contains(".mx_InviteDialog_tile_nameStack_name", "Bob").click();
cy.contains(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name", "Bob").should("exist"); cy.contains(".mx_InviteDialog_userTile_pill .mx_InviteDialog_userTile_name", "Bob").should("exist");
cy.get(".mx_InviteDialog_goButton").click(); cy.get(".mx_InviteDialog_goButton").click();

View file

@ -25,7 +25,12 @@ import { Icon as InfoIcon } from "../../../../res/img/element-icons/info.svg";
import { Icon as EmailPillAvatarIcon } from "../../../../res/img/icon-email-pill-avatar.svg"; import { Icon as EmailPillAvatarIcon } from "../../../../res/img/icon-email-pill-avatar.svg";
import { _t, _td } from "../../../languageHandler"; import { _t, _td } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { makeRoomPermalink, makeUserPermalink } from "../../../utils/permalinks/Permalinks"; import {
getHostnameFromMatrixServerName,
getServerName,
makeRoomPermalink,
makeUserPermalink,
} from "../../../utils/permalinks/Permalinks";
import DMRoomMap from "../../../utils/DMRoomMap"; import DMRoomMap from "../../../utils/DMRoomMap";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
import * as Email from "../../../email"; import * as Email from "../../../email";
@ -69,7 +74,7 @@ import {
startDmOnFirstMessage, startDmOnFirstMessage,
ThreepidMember, ThreepidMember,
} from "../../../utils/direct-messages"; } from "../../../utils/direct-messages";
import { AnyInviteKind, KIND_CALL_TRANSFER, KIND_DM, KIND_INVITE } from './InviteDialogTypes'; import { KIND_CALL_TRANSFER, KIND_DM, KIND_INVITE } from './InviteDialogTypes';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
@ -243,26 +248,37 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
} }
} }
interface IInviteDialogProps { interface BaseProps {
// Takes a boolean which is true if a user / users were invited / // Takes a boolean which is true if a user / users were invited /
// a call transfer was initiated or false if the dialog was cancelled // a call transfer was initiated or false if the dialog was cancelled
// with no action taken. // with no action taken.
onFinished: (success: boolean) => void; onFinished: (success: boolean) => void;
// The kind of invite being performed. Assumed to be KIND_DM if // Initial value to populate the filter with
// not provided. initialText?: string;
kind: AnyInviteKind; }
interface InviteDMProps extends BaseProps {
// The kind of invite being performed. Assumed to be KIND_DM if not provided.
kind?: typeof KIND_DM;
}
interface InviteRoomProps extends BaseProps {
kind: typeof KIND_INVITE;
// The room ID this dialog is for. Only required for KIND_INVITE. // The room ID this dialog is for. Only required for KIND_INVITE.
roomId: string; roomId: string;
}
interface InviteCallProps extends BaseProps {
kind: typeof KIND_CALL_TRANSFER;
// The call to transfer. Only required for KIND_CALL_TRANSFER. // The call to transfer. Only required for KIND_CALL_TRANSFER.
call: MatrixCall; call: MatrixCall;
// Initial value to populate the filter with
initialText: string;
} }
type Props = InviteDMProps | InviteRoomProps | InviteCallProps;
interface IInviteDialogState { interface IInviteDialogState {
targets: Member[]; // array of Member objects (see interface above) targets: Member[]; // array of Member objects (see interface above)
filterText: string; filterText: string;
@ -283,14 +299,13 @@ interface IInviteDialogState {
errorText: string; errorText: string;
} }
export default class InviteDialog extends React.PureComponent<IInviteDialogProps, IInviteDialogState> { export default class InviteDialog extends React.PureComponent<Props, IInviteDialogState> {
static defaultProps = { static defaultProps = {
kind: KIND_DM, kind: KIND_DM,
initialText: "", initialText: "",
}; };
private closeCopiedTooltip: () => void; private debounceTimer: number | null = null; // actually number because we're in the browser
private debounceTimer: number = null; // actually number because we're in the browser
private editorRef = createRef<HTMLInputElement>(); private editorRef = createRef<HTMLInputElement>();
private numberEntryFieldRef: React.RefObject<Field> = createRef(); private numberEntryFieldRef: React.RefObject<Field> = createRef();
private unmounted = false; private unmounted = false;
@ -316,7 +331,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
this.state = { this.state = {
targets: [], // array of Member objects (see interface above) targets: [], // array of Member objects (see interface above)
filterText: this.props.initialText, filterText: this.props.initialText || "",
recents: InviteDialog.buildRecents(alreadyInvited), recents: InviteDialog.buildRecents(alreadyInvited),
numRecentsShown: INITIAL_ROOMS_SHOWN, numRecentsShown: INITIAL_ROOMS_SHOWN,
suggestions: this.buildSuggestions(alreadyInvited), suggestions: this.buildSuggestions(alreadyInvited),
@ -343,9 +358,6 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
componentWillUnmount() { componentWillUnmount() {
this.unmounted = true; this.unmounted = true;
// if the Copied tooltip is open then get rid of it, there are ways to close the modal which wouldn't close
// the tooltip otherwise, such as pressing Escape or clicking X really quickly
if (this.closeCopiedTooltip) this.closeCopiedTooltip();
} }
private onConsultFirstChange = (ev) => { private onConsultFirstChange = (ev) => {
@ -466,6 +478,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
}; };
private inviteUsers = async () => { private inviteUsers = async () => {
if (this.props.kind !== KIND_INVITE) return;
this.setState({ busy: true }); this.setState({ busy: true });
this.convertFilter(); this.convertFilter();
const targets = this.convertFilter(); const targets = this.convertFilter();
@ -499,6 +512,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
}; };
private transferCall = async () => { private transferCall = async () => {
if (this.props.kind !== KIND_CALL_TRANSFER) return;
if (this.state.currentTabId == TabId.UserDirectory) { if (this.state.currentTabId == TabId.UserDirectory) {
this.convertFilter(); this.convertFilter();
const targets = this.convertFilter(); const targets = this.convertFilter();
@ -565,7 +579,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
this.props.onFinished(false); this.props.onFinished(false);
}; };
private updateSuggestions = async (term) => { private updateSuggestions = async (term: string) => {
MatrixClientPeg.get().searchUserDirectory({ term }).then(async r => { MatrixClientPeg.get().searchUserDirectory({ term }).then(async r => {
if (term !== this.state.filterText) { if (term !== this.state.filterText) {
// Discard the results - we were probably too slow on the server-side to make // Discard the results - we were probably too slow on the server-side to make
@ -595,13 +609,17 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
logger.warn("Non-fatal error trying to make an invite for a user ID"); logger.warn("Non-fatal error trying to make an invite for a user ID");
logger.warn(e); logger.warn(e);
// Add a result anyways, just without a profile. We stick it at the // Reuse logic from Permalinks as a basic MXID validity check
// top so it is most obviously presented to the user. const serverName = getServerName(term);
r.results.splice(0, 0, { const domain = getHostnameFromMatrixServerName(serverName);
user_id: term, if (domain) {
display_name: term, // Add a result anyways, just without a profile. We stick it at the
avatar_url: null, // top so it is most obviously presented to the user.
}); r.results.splice(0, 0, {
user_id: term,
display_name: term,
});
}
} }
} }
@ -940,7 +958,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
disabled={this.state.busy || (this.props.kind == KIND_CALL_TRANSFER && this.state.targets.length > 0)} disabled={this.state.busy || (this.props.kind == KIND_CALL_TRANSFER && this.state.targets.length > 0)}
autoComplete="off" autoComplete="off"
placeholder={hasPlaceholder ? _t("Search") : null} placeholder={hasPlaceholder ? _t("Search") : null}
data-test-id="invite-dialog-input" data-testid="invite-dialog-input"
/> />
); );
return ( return (
@ -1107,14 +1125,15 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
</CopyableText> </CopyableText>
</div>; </div>;
} else if (this.props.kind === KIND_INVITE) { } else if (this.props.kind === KIND_INVITE) {
const room = MatrixClientPeg.get()?.getRoom(this.props.roomId); const roomId = this.props.roomId;
const room = MatrixClientPeg.get()?.getRoom(roomId);
const isSpace = room?.isSpaceRoom(); const isSpace = room?.isSpaceRoom();
title = isSpace title = isSpace
? _t("Invite to %(spaceName)s", { ? _t("Invite to %(spaceName)s", {
spaceName: room.name || _t("Unnamed Space"), spaceName: room?.name || _t("Unnamed Space"),
}) })
: _t("Invite to %(roomName)s", { : _t("Invite to %(roomName)s", {
roomName: room.name || _t("Unnamed Room"), roomName: room?.name || _t("Unnamed Room"),
}); });
let helpTextUntranslated; let helpTextUntranslated;
@ -1140,14 +1159,14 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
userId: () => userId: () =>
<a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{ userId }</a>, <a href={makeUserPermalink(userId)} rel="noreferrer noopener" target="_blank">{ userId }</a>,
a: (sub) => a: (sub) =>
<a href={makeRoomPermalink(this.props.roomId)} rel="noreferrer noopener" target="_blank">{ sub }</a>, <a href={makeRoomPermalink(roomId)} rel="noreferrer noopener" target="_blank">{ sub }</a>,
}); });
buttonText = _t("Invite"); buttonText = _t("Invite");
goButtonFn = this.inviteUsers; goButtonFn = this.inviteUsers;
if (cli.isRoomEncrypted(this.props.roomId)) { if (cli.isRoomEncrypted(this.props.roomId)) {
const room = cli.getRoom(this.props.roomId); const room = cli.getRoom(this.props.roomId)!;
const visibilityEvent = room.currentState.getStateEvents( const visibilityEvent = room.currentState.getStateEvents(
"m.room.history_visibility", "", "m.room.history_visibility", "",
); );
@ -1185,8 +1204,6 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
{ _t("Transfer") } { _t("Transfer") }
</AccessibleButton> </AccessibleButton>
</div>; </div>;
} else {
logger.error("Unknown kind of InviteDialog: " + this.props.kind);
} }
const goButton = this.props.kind == KIND_CALL_TRANSFER ? null : <AccessibleButton const goButton = this.props.kind == KIND_CALL_TRANSFER ? null : <AccessibleButton

View file

@ -71,8 +71,6 @@ interface IState {
} }
export default class ShareDialog extends React.PureComponent<IProps, IState> { export default class ShareDialog extends React.PureComponent<IProps, IState> {
protected closeCopiedTooltip: () => void;
constructor(props) { constructor(props) {
super(props); super(props);
@ -100,12 +98,6 @@ export default class ShareDialog extends React.PureComponent<IProps, IState> {
}); });
}; };
componentWillUnmount() {
// if the Copied tooltip is open then get rid of it, there are ways to close the modal which wouldn't close
// the tooltip otherwise, such as pressing Escape or clicking X really quickly
if (this.closeCopiedTooltip) this.closeCopiedTooltip();
}
private getUrl() { private getUrl() {
let matrixToUrl; let matrixToUrl;

View file

@ -180,7 +180,7 @@ export class RoomPermalinkCreator {
if (plEvent) { if (plEvent) {
const content = plEvent.getContent(); const content = plEvent.getContent();
if (content) { if (content) {
const users = content.users; const users: Record<string, number> = content.users;
if (users) { if (users) {
const entries = Object.entries(users); const entries = Object.entries(users);
const allowedEntries = entries.filter(([userId]) => { const allowedEntries = entries.filter(([userId]) => {
@ -189,9 +189,11 @@ export class RoomPermalinkCreator {
return false; return false;
} }
const serverName = getServerName(userId); const serverName = getServerName(userId);
return !isHostnameIpAddress(serverName) &&
!isHostInRegex(serverName, this.bannedHostsRegexps) && const domain = getHostnameFromMatrixServerName(serverName) ?? serverName;
isHostInRegex(serverName, this.allowedHostsRegexps); return !isHostnameIpAddress(domain) &&
!isHostInRegex(domain, this.bannedHostsRegexps) &&
isHostInRegex(domain, this.allowedHostsRegexps);
}); });
const maxEntry = allowedEntries.reduce((max, entry) => { const maxEntry = allowedEntries.reduce((max, entry) => {
return (entry[1] > max[1]) ? entry : max; return (entry[1] > max[1]) ? entry : max;
@ -250,14 +252,15 @@ export class RoomPermalinkCreator {
.sort((a, b) => this.populationMap[b] - this.populationMap[a]); .sort((a, b) => this.populationMap[b] - this.populationMap[a]);
for (let i = 0; i < serversByPopulation.length && candidates.size < MAX_SERVER_CANDIDATES; i++) { for (let i = 0; i < serversByPopulation.length && candidates.size < MAX_SERVER_CANDIDATES; i++) {
const server = serversByPopulation[i]; const serverName = serversByPopulation[i];
const domain = getHostnameFromMatrixServerName(serverName) ?? "";
if ( if (
!candidates.has(server) && !candidates.has(serverName) &&
!isHostnameIpAddress(server) && !isHostnameIpAddress(domain) &&
!isHostInRegex(server, this.bannedHostsRegexps) && !isHostInRegex(domain, this.bannedHostsRegexps) &&
isHostInRegex(server, this.allowedHostsRegexps) isHostInRegex(domain, this.allowedHostsRegexps)
) { ) {
candidates.add(server); candidates.add(serverName);
} }
} }
@ -441,17 +444,21 @@ export function parsePermalink(fullUrl: string): PermalinkParts {
return null; // not a permalink we can handle return null; // not a permalink we can handle
} }
function getServerName(userId: string): string { export function getServerName(userId: string): string {
return userId.split(":").splice(1).join(":"); return userId.split(":").splice(1).join(":");
} }
function getHostnameFromMatrixDomain(domain: string): string { export function getHostnameFromMatrixServerName(serverName: string): string | null {
if (!domain) return null; if (!serverName) return null;
return new URL(`https://${domain}`).hostname; try {
return new URL(`https://${serverName}`).hostname;
} catch (e) {
console.error("Error encountered while extracting hostname from server name", e);
return null;
}
} }
function isHostInRegex(hostname: string, regexps: RegExp[]): boolean { function isHostInRegex(hostname: string, regexps: RegExp[]): boolean {
hostname = getHostnameFromMatrixDomain(hostname);
if (!hostname) return true; // assumed if (!hostname) return true; // assumed
if (regexps.length > 0 && !regexps[0].test) throw new Error(regexps[0].toString()); if (regexps.length > 0 && !regexps[0].test) throw new Error(regexps[0].toString());
@ -459,7 +466,6 @@ function isHostInRegex(hostname: string, regexps: RegExp[]): boolean {
} }
function isHostnameIpAddress(hostname: string): boolean { function isHostnameIpAddress(hostname: string): boolean {
hostname = getHostnameFromMatrixDomain(hostname);
if (!hostname) return false; if (!hostname) return false;
// is-ip doesn't want IPv6 addresses surrounded by brackets, so // is-ip doesn't want IPv6 addresses surrounded by brackets, so

View file

@ -0,0 +1,113 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import InviteDialog from "../../../../src/components/views/dialogs/InviteDialog";
import { KIND_INVITE } from "../../../../src/components/views/dialogs/InviteDialogTypes";
import { getMockClientWithEventEmitter, mkStubRoom } from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import SdkConfig from "../../../../src/SdkConfig";
import { ValidatedServerConfig } from "../../../../src/utils/ValidatedServerConfig";
import { IConfigOptions } from "../../../../src/IConfigOptions";
describe("InviteDialog", () => {
const roomId = "!111111111111111111:example.org";
const aliceId = "@alice:example.org";
const mockClient = getMockClientWithEventEmitter({
getUserId: jest.fn().mockReturnValue(aliceId),
isGuest: jest.fn().mockReturnValue(false),
getVisibleRooms: jest.fn().mockReturnValue([]),
getRoom: jest.fn(),
getRooms: jest.fn(),
getAccountData: jest.fn(),
getPushActionsForEvent: jest.fn(),
mxcUrlToHttp: jest.fn().mockReturnValue(''),
isRoomEncrypted: jest.fn().mockReturnValue(false),
getProfileInfo: jest.fn().mockRejectedValue({ errcode: "" }),
getIdentityServerUrl: jest.fn(),
searchUserDirectory: jest.fn().mockResolvedValue({}),
});
beforeEach(() => {
SdkConfig.put({ validated_server_config: {} as ValidatedServerConfig } as IConfigOptions);
DMRoomMap.makeShared();
jest.clearAllMocks();
mockClient.getUserId.mockReturnValue("@bob:example.org");
const room = mkStubRoom(roomId, "Room", mockClient);
mockClient.getRooms.mockReturnValue([room]);
mockClient.getRoom.mockReturnValue(room);
});
afterAll(() => {
jest.spyOn(MatrixClientPeg, 'get').mockRestore();
});
it("should label with space name", () => {
mockClient.getRoom(roomId).isSpaceRoom = jest.fn().mockReturnValue(true);
mockClient.getRoom(roomId).name = "Space";
render((
<InviteDialog
kind={KIND_INVITE}
roomId={roomId}
onFinished={jest.fn()}
/>
));
expect(screen.queryByText("Invite to Space")).toBeTruthy();
});
it("should label with room name", () => {
render((
<InviteDialog
kind={KIND_INVITE}
roomId={roomId}
onFinished={jest.fn()}
/>
));
expect(screen.queryByText("Invite to Room")).toBeTruthy();
});
it("should suggest valid MXIDs even if unknown", () => {
render((
<InviteDialog
kind={KIND_INVITE}
roomId={roomId}
onFinished={jest.fn()}
initialText="@localpart:server.tld"
/>
));
expect(screen.queryByText("@localpart:server.tld")).toBeFalsy();
});
it("should not suggest invalid MXIDs", () => {
render((
<InviteDialog
kind={KIND_INVITE}
roomId={roomId}
onFinished={jest.fn()}
initialText="@localpart:server:tld"
/>
));
expect(screen.queryByText("@localpart:server:tld")).toBeFalsy();
});
});

View file

@ -94,6 +94,15 @@ describe('Permalinks', function() {
expect(creator.serverCandidates.length).toBe(0); expect(creator.serverCandidates.length).toBe(0);
}); });
it('should gracefully handle invalid MXIDs', () => {
const roomId = "!fake:example.org";
const alice50 = makeMemberWithPL(roomId, "@alice:pl_50:org", 50);
const room = mockRoom(roomId, [alice50]);
const creator = new RoomPermalinkCreator(room);
creator.load();
expect(creator.serverCandidates).toBeTruthy();
});
it('should pick a candidate server for the highest power level user in the room', function() { it('should pick a candidate server for the highest power level user in the room', function() {
const roomId = "!fake:example.org"; const roomId = "!fake:example.org";
const alice50 = makeMemberWithPL(roomId, "@alice:pl_50", 50); const alice50 = makeMemberWithPL(roomId, "@alice:pl_50", 50);