Skip to content

Commit 1345957

Browse files
committed
Dealing with untrusted content - resolves #456
1 parent 9693149 commit 1345957

File tree

7 files changed

+426
-8
lines changed

7 files changed

+426
-8
lines changed

src/Mjolnir.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { RoomMemberManager } from "./RoomMembers";
3939
import ProtectedRoomsConfig from "./ProtectedRoomsConfig";
4040
import { MatrixEmitter, MatrixSendClient } from "./MatrixEmitter";
4141
import { OpenMetrics } from "./webapis/OpenMetrics";
42+
import * as UntrustedContent from "./UntrustedContent";
4243

4344
export const STATE_NOT_STARTED = "not_started";
4445
export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
@@ -50,6 +51,9 @@ export const STATE_RUNNING = "running";
5051
* to store that for pagination on further polls
5152
*/
5253
export const REPORT_POLL_EVENT_TYPE = "org.matrix.mjolnir.report_poll";
54+
const REPORT_POLL_EXPECTED_CONTENT = new UntrustedContent.SubTypeObjectContent({
55+
"from": UntrustedContent.NUMBER_CONTENT
56+
});
5357

5458
export class Mjolnir {
5559
private displayName: string;
@@ -279,6 +283,12 @@ export class Mjolnir {
279283
let reportPollSetting: { from: number } = { from: 0 };
280284
try {
281285
reportPollSetting = await this.client.getAccountData(REPORT_POLL_EVENT_TYPE);
286+
reportPollSetting = REPORT_POLL_EXPECTED_CONTENT.fallback(reportPollSetting,
287+
() => {
288+
this.managementRoomOutput.logMessage(LogLevel.INFO, "Mjolnir@startup", "invalid report poll settings, ignoring");
289+
return ({ from: 0 })
290+
}
291+
);
282292
} catch (err) {
283293
if (err.body?.errcode !== "M_NOT_FOUND") {
284294
throw err;

src/ProtectedRoomsConfig.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ import AwaitLock from 'await-lock';
1818
import { extractRequestError, LogService, Permalinks } from "matrix-bot-sdk";
1919
import { IConfig } from "./config";
2020
import { MatrixSendClient } from './MatrixEmitter';
21+
import * as UntrustedContent from './UntrustedContent';
22+
2123
const PROTECTED_ROOMS_EVENT_TYPE = "org.matrix.mjolnir.protected_rooms";
24+
const PROTECTED_ROOMS_EXPECTED_CONTENT = new UntrustedContent.SubTypeObjectContent({
25+
rooms: UntrustedContent.STRING_CONTENT.array().optional()
26+
});
2227

2328
/**
2429
* Manages the set of rooms that the user has EXPLICITLY asked to be protected.
@@ -65,7 +70,12 @@ export default class ProtectedRoomsConfig {
6570
public async loadProtectedRoomsFromAccountData(): Promise<void> {
6671
LogService.debug("ProtectedRoomsConfig", "Loading protected rooms...");
6772
try {
68-
const data: { rooms?: string[] } | null = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
73+
let data: { rooms?: string[] } | null = PROTECTED_ROOMS_EXPECTED_CONTENT.fallback(
74+
await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE),
75+
() => {
76+
LogService.warn("ProtectedRoomsConfig", "Invalid data, assuming empty data");
77+
return null;
78+
});
6979
if (data && data['rooms']) {
7080
for (const roomId of data['rooms']) {
7181
this.explicitlyProtectedRooms.add(roomId);
@@ -116,10 +126,19 @@ export default class ProtectedRoomsConfig {
116126
// but it doesn't stop a third party client on the same account racing with us instead.
117127
await this.accountDataLock.acquireAsync();
118128
try {
119-
const additionalProtectedRooms: string[] = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE)
120-
.then((rooms: {rooms?: string[]}) => Array.isArray(rooms?.rooms) ? rooms.rooms : [])
121-
.catch(e => (LogService.warn("ProtectedRoomsConfig", "Could not load protected rooms from account data", extractRequestError(e)), []));
122-
129+
const untrustedAdditionalProtectedRooms = await
130+
this
131+
.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE)
132+
.catch(e => {
133+
LogService.warn("ProtectedRoomsConfig", "Could not load protected rooms from account data", extractRequestError(e));
134+
return [];
135+
});
136+
let additionalProtectedRooms = PROTECTED_ROOMS_EXPECTED_CONTENT.fallback(untrustedAdditionalProtectedRooms,
137+
() => {
138+
LogService.warn("ProtectedRoomsConfig", "Invalid list of protected rooms, restarting with an empty list");
139+
return [];
140+
}
141+
);
123142
const roomsToSave = new Set([...this.explicitlyProtectedRooms.keys(), ...additionalProtectedRooms]);
124143
excludeRooms.forEach(roomsToSave.delete, roomsToSave);
125144
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, { rooms: Array.from(roomsToSave.keys()) });

src/UntrustedContent.ts

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/*
2+
Copyright 2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
/**
18+
* Utilities to deal with untrusted values coming from Matrix events.
19+
*
20+
* e.g. to confirm that a value `foo` has type `{ bar: string[]}`,
21+
* run
22+
* ```ts
23+
* new SubTypeObjectContent({
24+
* bar: STRING_CONTENT.array()
25+
* }).check_type(value)
26+
* ```
27+
*/
28+
29+
/**
30+
* The abstract root class for all content we wish to validate against.
31+
*/
32+
abstract class AbstractContent {
33+
/**
34+
* Validate the type of a value against `this`.
35+
* @param value
36+
*/
37+
abstract checkType(value: any): boolean;
38+
39+
/**
40+
* If `value` has `this` type, return `value`, otherwise
41+
* return `defaults()`.
42+
*/
43+
fallback(value: any, defaults: () => any): any {
44+
if (this.checkType(value)) {
45+
return value;
46+
}
47+
return defaults();
48+
}
49+
50+
/**
51+
* Return an `AbstractContent` for values of type `this | null | undefined`.
52+
*/
53+
optional(): AbstractContent {
54+
return new OptionalContent(this);
55+
}
56+
57+
/**
58+
* Return a `AbstractContent` for values of type `this[]`.
59+
*
60+
* This is a shortcut for `new OptionalContent(this)`
61+
*/
62+
array(): AbstractContent {
63+
return new ArrayContent(this);
64+
}
65+
};
66+
67+
/**
68+
* A content validator for numbers.
69+
*/
70+
class StringContent extends AbstractContent {
71+
/**
72+
* Check that `value` is a string.
73+
*/
74+
checkType(value: any): boolean {
75+
return typeof value === "string";
76+
}
77+
};
78+
/**
79+
* A content validator for strings (singleton).
80+
*/
81+
export const STRING_CONTENT = new StringContent();
82+
83+
84+
/**
85+
* A content validator for numbers.
86+
*/
87+
class NumberContent extends AbstractContent {
88+
checkType(value: any): boolean {
89+
return typeof value === "number";
90+
}
91+
};
92+
93+
/**
94+
* A content validator for numbers (singleton).
95+
*/
96+
export const NUMBER_CONTENT = new NumberContent();
97+
98+
/**
99+
* A content validator for arrays.
100+
*/
101+
class ArrayContent extends AbstractContent {
102+
constructor(public readonly content: AbstractContent) {
103+
super()
104+
}
105+
/**
106+
* Check that `value` is an array and that each value it contains
107+
* has type `type.content`.
108+
*/
109+
checkType(value: any): boolean {
110+
if (!Array.isArray(value)) {
111+
return false;
112+
}
113+
for (let item of value) {
114+
if (!this.content.checkType(item)) {
115+
return false;
116+
}
117+
}
118+
return true;
119+
}
120+
}
121+
class OptionalContent extends AbstractContent {
122+
constructor(public readonly content: AbstractContent) {
123+
super()
124+
}
125+
optional(): AbstractContent {
126+
return this;
127+
}
128+
/**
129+
* Check that value either has type `this.content` or is `null` or `undefined`.
130+
*/
131+
checkType(value: any): boolean {
132+
if (typeof value === "undefined") {
133+
return true;
134+
}
135+
if (value === null) {
136+
return true;
137+
}
138+
if (this.content.checkType(value)) {
139+
return true;
140+
}
141+
return false;
142+
}
143+
}
144+
export class SubTypeObjectContent extends AbstractContent {
145+
constructor(public readonly fields: Record<string, AbstractContent>) {
146+
super()
147+
}
148+
/**
149+
* Check that `value` contains **at least** the fields of `this.fields`
150+
* and that each field specified in `this.fields` holds a value that
151+
* matches the type specified in `this.fields`.
152+
*/
153+
checkType(value: any): boolean {
154+
if (typeof value !== "object") {
155+
return false;
156+
}
157+
if (value === null) {
158+
// Let's not forget that `typeof null === "object"`
159+
return false;
160+
}
161+
if (Array.isArray(value)) {
162+
// Let's not forget that `typeof [...] === "object"`
163+
return false;
164+
}
165+
for (let [k, expected] of Object.entries(this.fields)) {
166+
if (!expected.checkType(value[k])) {
167+
return false;
168+
}
169+
}
170+
return true;
171+
}
172+
}
173+
174+
export class ExactTypeObjectContent extends SubTypeObjectContent {
175+
constructor(public readonly fields: Record<string, AbstractContent>) {
176+
super(fields)
177+
}
178+
/**
179+
* Check that `value` contains **exactly** the fields of `this.fields`
180+
* and that each field specified in `this.fields` holds a value that
181+
* matches the type specified in `this.fields`.
182+
*/
183+
checkType(value: any): boolean {
184+
if (!super.checkType(value)) {
185+
return false;
186+
}
187+
// Check that we don't have any field we're not expecting.
188+
for (let k of Object.keys(value)) {
189+
if (!(k in this.fields)) {
190+
return false;
191+
}
192+
}
193+
return true;
194+
}
195+
}

src/commands/UnbanBanCommand.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import PolicyList from "../models/PolicyList";
1919
import { extractRequestError, LogLevel, LogService, MatrixGlob, RichReply } from "matrix-bot-sdk";
2020
import { RULE_ROOM, RULE_SERVER, RULE_USER, USER_RULE_TYPES } from "../models/ListRule";
2121
import { DEFAULT_LIST_EVENT_TYPE } from "./SetDefaultBanListCommand";
22+
import * as UntrustedContent from "../UntrustedContent";
23+
const DEFAULT_LIST_EXPECTED_CONTENT = new UntrustedContent.SubTypeObjectContent({
24+
shortcode: UntrustedContent.STRING_CONTENT
25+
});
2226

2327
interface Arguments {
2428
list: PolicyList | null;
@@ -31,7 +35,8 @@ interface Arguments {
3135
export async function parseArguments(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]): Promise<Arguments | null> {
3236
let defaultShortcode: string | null = null;
3337
try {
34-
const data: { shortcode: string } = await mjolnir.client.getAccountData(DEFAULT_LIST_EVENT_TYPE);
38+
const untrustedData = await mjolnir.client.getAccountData(DEFAULT_LIST_EVENT_TYPE);
39+
const data: { shortcode: string | null } = DEFAULT_LIST_EXPECTED_CONTENT.fallback(untrustedData, () => ({ shortcode: null }));
3540
defaultShortcode = data['shortcode'];
3641
} catch (e) {
3742
LogService.warn("UnbanBanCommand", "Non-fatal error getting default ban list");

src/models/PolicyList.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { MatrixSendClient } from "../MatrixEmitter";
2121
import AwaitLock from "await-lock";
2222
import { monotonicFactory } from "ulidx";
2323
import { Mjolnir } from "../Mjolnir";
24+
import * as UntrustedContent from "../UntrustedContent";
2425

2526
/**
2627
* Account data event type used to store the permalinks to each of the policylists.
@@ -33,6 +34,9 @@ import { Mjolnir } from "../Mjolnir";
3334
* ```
3435
*/
3536
export const WATCHED_LISTS_EVENT_TYPE = "org.matrix.mjolnir.watched_lists";
37+
const WATCHED_LISTS_EXPECTED_CONTENT = new UntrustedContent.SubTypeObjectContent({
38+
references: UntrustedContent.STRING_CONTENT.array()
39+
});
3640

3741
/**
3842
* A prefix used to record that we have already warned at least once that a PolicyList room is unprotected.
@@ -707,7 +711,13 @@ export class PolicyListManager {
707711

708712
let watchedListsEvent: { references?: string[] } | null = null;
709713
try {
710-
watchedListsEvent = await this.mjolnir.client.getAccountData(WATCHED_LISTS_EVENT_TYPE);
714+
watchedListsEvent = WATCHED_LISTS_EXPECTED_CONTENT.fallback(
715+
await this.mjolnir.client.getAccountData(WATCHED_LISTS_EVENT_TYPE),
716+
() => {
717+
LogService.warn('Mjolnir', "Invalid account data for Mjolnir's watched lists, assuming first start.");
718+
return null;
719+
}
720+
);
711721
} catch (e) {
712722
if (e.statusCode === 404) {
713723
LogService.warn('Mjolnir', "Couldn't find account data for Mjolnir's watched lists, assuming first start.", extractRequestError(e));

src/protections/ProtectionManager.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { htmlEscape } from "../utils";
3131
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
3232
import { RoomUpdateError } from "../models/RoomUpdateError";
3333
import { LocalAbuseReports } from "./LocalAbuseReports";
34+
import * as UntrustedContent from "../UntrustedContent";
3435

3536
const PROTECTIONS: Protection[] = [
3637
new FirstMessageIsImage(),
@@ -45,6 +46,10 @@ const PROTECTIONS: Protection[] = [
4546
];
4647

4748
const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections";
49+
const ENABLED_PROTECTIONS_EXPECTED_CONTENT = new UntrustedContent.SubTypeObjectContent({
50+
enabled: UntrustedContent.STRING_CONTENT.array(),
51+
}).optional();
52+
4853
const CONSEQUENCE_EVENT_DATA = "org.matrix.mjolnir.consequence";
4954

5055
/**
@@ -87,7 +92,10 @@ export class ProtectionManager {
8792

8893
let enabledProtections: { enabled: string[] } | null = null;
8994
try {
90-
enabledProtections = await this.mjolnir.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE);
95+
enabledProtections = ENABLED_PROTECTIONS_EXPECTED_CONTENT.fallback(
96+
await this.mjolnir.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE),
97+
() => null
98+
);
9199
} catch {
92100
// this setting either doesn't exist, or we failed to read it (bad network?)
93101
// TODO: retry on certain failures?

0 commit comments

Comments
 (0)