Skip to content

Commit 4d5447c

Browse files
authored
Rework the banning and unbanning of entities in PolicyLists. (#345)
* Rework the banning and unbanning of entities in PolicyLists. 1. We keep track of the event that created a list rule so that we can remove the rule by having a way to determine the original state key for the rule. This is because the state key of rules can be anything and should not be relied on by Mjolnir to unban things (which it was doing). 2. The old scheme for producing a state key was causing for some entities to escape bans #322. We could have used a hash or something similar, but we know that the reason for the `rule:${entity}` scheme existed was for ease of debugging and finding rules in devtools. So instead we have followed a scheme simalar to bridges where the first character of an mxid is replaced with an underscore. Everything else just gets put into the state key. Since domains can't have '@' and room ids, aliases can't either. 3. We have stopped the need for Mjolnir to wait for the next response from sync after banning, unbanning an entity so that we can apply ACL's sooner. * Use PolicyList's `banEntity` method to create imported rules.
1 parent 8bafa16 commit 4d5447c

File tree

6 files changed

+116
-60
lines changed

6 files changed

+116
-60
lines changed

src/Mjolnir.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1009,7 +1009,7 @@ export class Mjolnir {
10091009
const policyList = this.policyLists.find(list => list.roomId === roomId);
10101010
if (policyList !== undefined) {
10111011
if (ALL_BAN_LIST_RULE_TYPES.includes(event['type']) || event['type'] === 'm.room.redaction') {
1012-
policyList.updateForEvent(event)
1012+
policyList.updateForEvent(event.event_id)
10131013
}
10141014
}
10151015

src/commands/ImportCommand.ts

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ limitations under the License.
1616

1717
import { Mjolnir } from "../Mjolnir";
1818
import { RichReply } from "matrix-bot-sdk";
19-
import { EntityType, Recommendation } from "../models/ListRule";
19+
import { EntityType } from "../models/ListRule";
20+
import PolicyList from "../models/PolicyList";
2021

2122
// !mjolnir import <room ID> <shortcode>
2223
export async function execImportCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
2324
const importRoomId = await mjolnir.client.resolveRoom(parts[2]);
24-
const list = mjolnir.lists.find(b => b.listShortcode === parts[3]);
25+
const list = mjolnir.lists.find(b => b.listShortcode === parts[3]) as PolicyList;
2526
if (!list) {
2627
const errMessage = "Unable to find list - check your shortcode.";
2728
const errReply = RichReply.createFor(roomId, event, errMessage, errMessage);
@@ -43,17 +44,7 @@ export async function execImportCommand(roomId: string, event: any, mjolnir: Mjo
4344
const reason = content['reason'] || '<no reason>';
4445

4546
await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Adding user ${stateEvent['state_key']} to ban list`);
46-
47-
const ruleContent = {
48-
entity: stateEvent['state_key'],
49-
recommendation: Recommendation.Ban,
50-
reason: reason,
51-
};
52-
const stateKey = `rule:${ruleContent.entity}`;
53-
let stableRule = EntityType.RULE_USER;
54-
if (stableRule) {
55-
await mjolnir.client.sendStateEvent(list.roomId, stableRule, stateKey, ruleContent);
56-
}
47+
await list.banEntity(EntityType.RULE_USER, stateEvent['state_key'], reason);
5748
importedRules++;
5849
}
5950
} else if (stateEvent['type'] === 'm.room.server_acl' && stateEvent['state_key'] === '') {
@@ -64,16 +55,7 @@ export async function execImportCommand(roomId: string, event: any, mjolnir: Mjo
6455

6556
await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Adding server ${server} to ban list`);
6657

67-
const ruleContent = {
68-
entity: server,
69-
recommendation: Recommendation.Ban,
70-
reason: reason,
71-
};
72-
const stateKey = `rule:${ruleContent.entity}`;
73-
let stableRule = EntityType.RULE_SERVER;
74-
if (stableRule) {
75-
await mjolnir.client.sendStateEvent(list.roomId, stableRule, stateKey, ruleContent);
76-
}
58+
await list.banEntity(EntityType.RULE_SERVER, server, reason);
7759
importedRules++;
7860
}
7961
}

src/commands/UnbanBanCommand.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ limitations under the License.
1717
import { Mjolnir } from "../Mjolnir";
1818
import PolicyList from "../models/PolicyList";
1919
import { extractRequestError, LogLevel, LogService, MatrixGlob, RichReply } from "matrix-bot-sdk";
20-
import { Recommendation, RULE_ROOM, RULE_SERVER, RULE_USER, USER_RULE_TYPES } from "../models/ListRule";
20+
import { RULE_ROOM, RULE_SERVER, RULE_USER, USER_RULE_TYPES } from "../models/ListRule";
2121
import { DEFAULT_LIST_EVENT_TYPE } from "./SetDefaultBanListCommand";
2222

2323
interface Arguments {
@@ -118,14 +118,7 @@ export async function execBanCommand(roomId: string, event: any, mjolnir: Mjolni
118118
const bits = await parseArguments(roomId, event, mjolnir, parts);
119119
if (!bits) return; // error already handled
120120

121-
const ruleContent = {
122-
entity: bits.entity,
123-
recommendation: Recommendation.Ban,
124-
reason: bits.reason || '<no reason supplied>',
125-
};
126-
const stateKey = `rule:${bits.entity}`;
127-
128-
await mjolnir.client.sendStateEvent(bits.list!.roomId, bits.ruleType!, stateKey, ruleContent);
121+
await bits.list!.banEntity(bits.ruleType!, bits.entity, bits.reason);
129122
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅');
130123
}
131124

src/models/ListRule.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ const RECOMMENDATION_OPINION_VARIANTS: string[] = [
7878
export const OPINION_MIN = -100;
7979
export const OPINION_MAX = +100;
8080

81+
interface MatrixStateEvent {
82+
type: string,
83+
content: any,
84+
event_id: string,
85+
state_key: string,
86+
}
87+
8188
/**
8289
* Representation of a rule within a Policy List.
8390
*/
@@ -87,6 +94,10 @@ export abstract class ListRule {
8794
*/
8895
private glob: MatrixGlob;
8996
constructor(
97+
/**
98+
* The event source for the rule.
99+
*/
100+
public readonly sourceEvent: MatrixStateEvent,
90101
/**
91102
* The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain.
92103
*/
@@ -120,7 +131,7 @@ export abstract class ListRule {
120131
* @param event An *untrusted* event.
121132
* @returns null if the ListRule is invalid or not recognized by Mjölnir.
122133
*/
123-
public static parse(event: {type: string, content: any}): ListRule | null {
134+
public static parse(event: MatrixStateEvent): ListRule | null {
124135
// Parse common fields.
125136
// If a field is ill-formed, discard the rule.
126137
const content = event['content'];
@@ -155,17 +166,17 @@ export abstract class ListRule {
155166

156167
// From this point, we may need specific fields.
157168
if (RECOMMENDATION_BAN_VARIANTS.includes(recommendation)) {
158-
return new ListRuleBan(entity, reason, kind);
169+
return new ListRuleBan(event, entity, reason, kind);
159170
} else if (RECOMMENDATION_OPINION_VARIANTS.includes(recommendation)) {
160171
let opinion = content['opinion'];
161172
if (!Number.isInteger(opinion)) {
162173
return null;
163174
}
164-
return new ListRuleOpinion(entity, reason, kind, opinion);
175+
return new ListRuleOpinion(event, entity, reason, kind, opinion);
165176
} else {
166177
// As long as the `recommendation` is defined, we assume
167178
// that the rule is correct, just unknown.
168-
return new ListRuleUnknown(entity, reason, kind, content);
179+
return new ListRuleUnknown(event, entity, reason, kind, content);
169180
}
170181
}
171182
}
@@ -175,6 +186,10 @@ export abstract class ListRule {
175186
*/
176187
export class ListRuleBan extends ListRule {
177188
constructor(
189+
/**
190+
* The event source for the rule.
191+
*/
192+
sourceEvent: MatrixStateEvent,
178193
/**
179194
* The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain.
180195
*/
@@ -188,7 +203,7 @@ export class ListRuleBan extends ListRule {
188203
*/
189204
kind: EntityType,
190205
) {
191-
super(entity, reason, kind, Recommendation.Ban)
206+
super(sourceEvent, entity, reason, kind, Recommendation.Ban)
192207
}
193208
}
194209

@@ -197,6 +212,10 @@ export class ListRuleBan extends ListRule {
197212
*/
198213
export class ListRuleOpinion extends ListRule {
199214
constructor(
215+
/**
216+
* The event source for the rule.
217+
*/
218+
sourceEvent: MatrixStateEvent,
200219
/**
201220
* The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain.
202221
*/
@@ -216,7 +235,7 @@ export class ListRuleOpinion extends ListRule {
216235
*/
217236
public readonly opinion: number
218237
) {
219-
super(entity, reason, kind, Recommendation.Opinion);
238+
super(sourceEvent, entity, reason, kind, Recommendation.Opinion);
220239
if (!Number.isInteger(opinion)) {
221240
throw new TypeError(`The opinion must be an integer, got ${opinion}`);
222241
}
@@ -231,6 +250,10 @@ export class ListRuleOpinion extends ListRule {
231250
*/
232251
export class ListRuleUnknown extends ListRule {
233252
constructor(
253+
/**
254+
* The event source for the rule.
255+
*/
256+
sourceEvent: MatrixStateEvent,
234257
/**
235258
* The entity covered by this rule, e.g. a glob user ID, a room ID, a server domain.
236259
*/
@@ -248,6 +271,6 @@ export class ListRuleUnknown extends ListRule {
248271
*/
249272
public readonly content: any,
250273
) {
251-
super(entity, reason, kind, null);
274+
super(sourceEvent, entity, reason, kind, null);
252275
}
253276
}

src/models/PolicyList.ts

Lines changed: 70 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,28 @@ declare interface PolicyList {
6363
/**
6464
* The PolicyList caches all of the rules that are active in a policy room so Mjolnir can refer to when applying bans etc.
6565
* This cannot be used to update events in the modeled room, it is a readonly model of the policy room.
66+
*
67+
* The policy list needs to be updated manually, it has no way of knowing about new events in it's modelled matrix room on its own.
68+
* You can inform the PolicyList about new events in the matrix side of policy room with the `updateForEvent`, this will eventually
69+
* cause the PolicyList to update its view of the room (via `updateList`) if it doesn't know about that state event.
70+
* Each time the PolicyList has finished updating, it will emit the `'PolicyList.update'` event on itself as an EventEmitter.
71+
*
72+
* Implementation note: The reason why the PolicyList has to update via a call to `/state` is because
73+
* you cannot rely on the timeline portion of `/sync` to provide a consistent view of the room state as you
74+
* receive events in stream order.
6675
*/
6776
class PolicyList extends EventEmitter {
6877
private shortcode: string | null = null;
6978
// A map of state events indexed first by state type and then state keys.
7079
private state: Map<string, Map<string, any>> = new Map();
80+
/**
81+
* Allow us to detect whether we have updated the state for this event.
82+
*/
83+
private stateByEventId: Map<string /* event id */, any> = new Map();
7184
// Batches new events from sync together before starting the process to update the list.
7285
private readonly batcher: UpdateBatcher;
86+
// Events that we have already informed the batcher about, that we haven't loaded from the room state yet.
87+
private batchedEvents = new Set<string /* event id */>();
7388

7489
/**
7590
* Construct a PolicyList, does not synchronize with the room.
@@ -113,6 +128,7 @@ class PolicyList extends EventEmitter {
113128
} else {
114129
this.state.set(stateType, new Map().set(stateKey, event));
115130
}
131+
this.stateByEventId.set(event.event_id, event);
116132
}
117133

118134
/**
@@ -194,6 +210,23 @@ class PolicyList extends EventEmitter {
194210
}
195211
}
196212

213+
/**
214+
* Ban an entity with Recommendation.Ban from the list.
215+
* @param ruleType The type of rule e.g. RULE_USER.
216+
* @param entity The entity to ban.
217+
* @param reason A reason we are banning them.
218+
*/
219+
public async banEntity(ruleType: string, entity: string, reason?: string): Promise<void> {
220+
// '@' at the beginning of state keys is reserved.
221+
const stateKey = ruleType === RULE_USER ? '_' + entity.substring(1) : entity;
222+
const event_id = await this.client.sendStateEvent(this.roomId, ruleType, stateKey, {
223+
entity,
224+
recommendation: Recommendation.Ban,
225+
reason: reason || '<no reason supplied>',
226+
});
227+
this.updateForEvent(event_id);
228+
}
229+
197230
/**
198231
* Remove all rules in the banList for this entity that have the same state key (as when we ban them)
199232
* by searching for rules that have legacy state types.
@@ -202,7 +235,6 @@ class PolicyList extends EventEmitter {
202235
* @returns true if any rules were removed and the entity was unbanned, otherwise false because there were no rules.
203236
*/
204237
public async unbanEntity(ruleType: string, entity: string): Promise<boolean> {
205-
const stateKey = `rule:${entity}`;
206238
let typesToCheck = [ruleType];
207239
switch (ruleType) {
208240
case RULE_USER:
@@ -215,17 +247,26 @@ class PolicyList extends EventEmitter {
215247
typesToCheck = ROOM_RULE_TYPES;
216248
break;
217249
}
218-
// We can't cheat and check our state cache because we normalize the event types to the most recent version.
219-
const typesToRemove = (await Promise.all(
220-
typesToCheck.map(stateType => this.client.getRoomStateEvent(this.roomId, stateType, stateKey)
221-
.then(_ => stateType) // We need the state type as getRoomState only returns the content, not the top level.
222-
.catch(e => e.statusCode === 404 ? null : Promise.reject(e))))
223-
).filter(e => e); // remove nulls. I don't know why TS still thinks there can be nulls after this??
224-
if (typesToRemove.length === 0) {
225-
return false;
250+
const sendNullState = async (stateType: string, stateKey: string) => {
251+
const event_id = await this.client.sendStateEvent(this.roomId, stateType, stateKey, {});
252+
this.updateForEvent(event_id);
226253
}
227-
await Promise.all(typesToRemove.map(stateType => this.client.sendStateEvent(this.roomId, stateType!, stateKey, {})));
228-
return true;
254+
const removeRule = async (rule: ListRule): Promise<void> => {
255+
const stateKey = rule.sourceEvent.state_key;
256+
// We can't cheat and check our state cache because we normalize the event types to the most recent version.
257+
const typesToRemove = (await Promise.all(
258+
typesToCheck.map(stateType => this.client.getRoomStateEvent(this.roomId, stateType, stateKey)
259+
.then(_ => stateType) // We need the state type as getRoomState only returns the content, not the top level.
260+
.catch(e => e.statusCode === 404 ? null : Promise.reject(e))))
261+
).filter(e => e); // remove nulls. I don't know why TS still thinks there can be nulls after this??
262+
if (typesToRemove.length === 0) {
263+
return;
264+
}
265+
await Promise.all(typesToRemove.map(stateType => sendNullState(stateType!, stateKey)));
266+
}
267+
const rules = this.rulesMatchingEntity(entity, ruleType);
268+
await Promise.all(rules.map(removeRule));
269+
return rules.length > 0;
229270
}
230271

231272
/**
@@ -305,6 +346,11 @@ class PolicyList extends EventEmitter {
305346
}
306347
})();
307348

349+
// Clear out any events that we were informed about via updateForEvent.
350+
if (changeType !== null) {
351+
this.batchedEvents.delete(event.event_id)
352+
}
353+
308354
// If we haven't got any information about what the rule used to be, then it wasn't a valid rule to begin with
309355
// and so will not have been used. Removing a rule like this therefore results in no change.
310356
if (changeType === ChangeType.Removed && previousState?.unsigned?.rule) {
@@ -328,15 +374,26 @@ class PolicyList extends EventEmitter {
328374
}
329375
}
330376
this.emit('PolicyList.update', this, changes);
377+
if (this.batchedEvents.keys.length !== 0) {
378+
// The only reason why this isn't a TypeError is because we need to know about this when it happens, because it means
379+
// we're probably doing something wrong, on the other hand, if someone messes with a server implementation and
380+
// strange things happen where events appear in /sync sooner than they do in /state (which would be outrageous)
381+
// we don't want Mjolnir to stop working properly. Though, I am not confident a burried warning is going to alert us.
382+
LogService.warn("PolicyList", "The policy list is being informed about events that it cannot find in the room state, this is really bad and you should seek help.");
383+
}
331384
return changes;
332385
}
333386

334387
/**
335388
* Inform the `PolicyList` about a new event from the room it is modelling.
336389
* @param event An event from the room the `PolicyList` models to inform an instance about.
337390
*/
338-
public updateForEvent(event: { event_id: string }): void {
339-
this.batcher.addToBatch(event.event_id)
391+
public updateForEvent(eventId: string): void {
392+
if (this.stateByEventId.has(eventId) || this.batchedEvents.has(eventId)) {
393+
return; // we already know about this event.
394+
}
395+
this.batcher.addToBatch(eventId);
396+
this.batchedEvents.add(eventId);
340397
}
341398
}
342399

test/integration/banListTest.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ import { ALL_RULE_TYPES, RULE_SERVER, RULE_USER, SERVER_RULE_TYPES } from "../..
1818
* @param template The template to use for the policy rule event.
1919
* @returns The event id of the newly created policy rule.
2020
*/
21-
async function createPolicyRule(client: MatrixClient, policyRoomId: string, policyType: string, entity: string, reason: string, template = { recommendation: 'm.ban' }) {
22-
return await client.sendStateEvent(policyRoomId, policyType, `rule:${entity}`, {
21+
async function createPolicyRule(client: MatrixClient, policyRoomId: string, policyType: string, entity: string, reason: string, template = { recommendation: 'm.ban' }, stateKey = `rule:${entity}`) {
22+
return await client.sendStateEvent(policyRoomId, policyType, stateKey, {
2323
entity,
2424
reason,
2525
...template,
@@ -327,18 +327,19 @@ describe('Test: unbaning entities via the PolicyList.', function() {
327327
const banList = new PolicyList(banListId, banListId, moderator);
328328
// we need two because we need to test the case where an entity has all rule types in the list
329329
// and another one that only has one (so that we would hit 404 while looking up state)
330-
const olderBadServer = "old.evil.com"
331-
const newerBadServer = "new.evil.com"
330+
const olderBadServer = "old.evil.example"
331+
const newerBadServer = "new.evil.example"
332332
await Promise.all(SERVER_RULE_TYPES.map(type => createPolicyRule(moderator, banListId, type, olderBadServer, 'gregg rulz ok')));
333333
await createPolicyRule(moderator, banListId, RULE_SERVER, newerBadServer, 'this is bad sort it out.');
334+
await createPolicyRule(moderator, banListId, RULE_SERVER, newerBadServer, 'hidden with a non-standard state key', undefined, "rule_1");
334335
// Wait for the ACL event to be applied to our protected room.
335336
await this.mjolnir!.syncLists();
336337

337338
await banList.updateList();
338-
// rules are normalized, that's why there should only be 2.
339-
assert.equal(banList.allRules.length, 2);
339+
// rules are normalized by rule type, that's why there should only be 3.
340+
assert.equal(banList.allRules.length, 3);
340341

341-
// Check that we have setup our test properly and therefore evil.com is banned.
342+
// Check that we have setup our test properly and therefore evil.example is banned.
342343
const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*").denyServer(olderBadServer).denyServer(newerBadServer);
343344
const protectedAcl = await mjolnir.client.getRoomStateEvent(protectedRoom, "m.room.server_acl", "");
344345
if (!acl.matches(protectedAcl)) {

0 commit comments

Comments
 (0)