Make ACL safe so that Mjolnir will not ban itself. (#213)

This commit is contained in:
Gnuxie 2022-02-07 17:02:06 +00:00 committed by GitHub
parent 813741c42c
commit ff9a7db159
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 92 additions and 10 deletions

View File

@ -19,7 +19,7 @@ import { ServerAcl } from "../models/ServerAcl";
import { RoomUpdateError } from "../models/RoomUpdateError"; import { RoomUpdateError } from "../models/RoomUpdateError";
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import config from "../config"; import config from "../config";
import { LogLevel } from "matrix-bot-sdk"; import { LogLevel, UserID } from "matrix-bot-sdk";
import { logMessage } from "../LogProxy"; import { logMessage } from "../LogProxy";
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache"; import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
@ -31,8 +31,10 @@ import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
* @param {Mjolnir} mjolnir The Mjolnir client to apply the ACLs with. * @param {Mjolnir} mjolnir The Mjolnir client to apply the ACLs with.
*/ */
export async function applyServerAcls(lists: BanList[], roomIds: string[], mjolnir: Mjolnir): Promise<RoomUpdateError[]> { export async function applyServerAcls(lists: BanList[], roomIds: string[], mjolnir: Mjolnir): Promise<RoomUpdateError[]> {
const serverName: string = new UserID(await config.RUNTIME.client!.getUserId()).domain;
// Construct a server ACL first // Construct a server ACL first
const acl = new ServerAcl().denyIpAddresses().allowServer("*"); const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*");
for (const list of lists) { for (const list of lists) {
for (const rule of list.serverRules) { for (const rule of list.serverRules) {
acl.denyServer(rule.entity); acl.denyServer(rule.entity);
@ -41,6 +43,10 @@ export async function applyServerAcls(lists: BanList[], roomIds: string[], mjoln
const finalAcl = acl.safeAclContent(); const finalAcl = acl.safeAclContent();
if (finalAcl.deny.length !== acl.literalAclContent().deny.length) {
logMessage(LogLevel.WARN, "ApplyAcl", `Mjölnir has detected and removed an ACL that would exclude itself. Please check the ACL lists.`);
}
if (config.verboseLogging) { if (config.verboseLogging) {
// We specifically use sendNotice to avoid having to escape HTML // We specifically use sendNotice to avoid having to escape HTML
await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Constructed server ACL:\n${JSON.stringify(finalAcl, null, 2)}`); await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Constructed server ACL:\n${JSON.stringify(finalAcl, null, 2)}`);

View File

@ -16,7 +16,7 @@ limitations under the License.
import { extractRequestError, LogService, MatrixClient } from "matrix-bot-sdk"; import { extractRequestError, LogService, MatrixClient } from "matrix-bot-sdk";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { ListRule } from "./ListRule"; import { ListRule, RECOMMENDATION_BAN } from "./ListRule";
export const RULE_USER = "m.policy.rule.user"; export const RULE_USER = "m.policy.rule.user";
export const RULE_ROOM = "m.policy.rule.room"; export const RULE_ROOM = "m.policy.rule.room";
@ -130,7 +130,7 @@ class BanList extends EventEmitter {
/** /**
* Return all the active rules of a given kind. * Return all the active rules of a given kind.
* @param kind e.g. RULE_SERVER (m.policy.rule.server) * @param kind e.g. RULE_SERVER (m.policy.rule.server). Rule types are always normalised when they are interned into the BanList.
* @returns The active ListRules for the ban list of that kind. * @returns The active ListRules for the ban list of that kind.
*/ */
private rulesOfKind(kind: string): ListRule[] { private rulesOfKind(kind: string): ListRule[] {
@ -139,7 +139,10 @@ class BanList extends EventEmitter {
if (stateKeyMap) { if (stateKeyMap) {
for (const event of stateKeyMap.values()) { for (const event of stateKeyMap.values()) {
const rule = event?.unsigned?.rule; const rule = event?.unsigned?.rule;
if (rule && rule.kind === kind) { // README! If you are refactoring this and/or introducing a mechanism to return the list of rules,
// please make sure that you *only* return rules with `m.ban` or create a different method
// (we don't want to accidentally ban entities).
if (rule && rule.kind === kind && rule.recommendation === RECOMMENDATION_BAN) {
rules.push(rule); rules.push(rule);
} }
} }

View File

@ -34,6 +34,7 @@ export class ListRule {
/** /**
* The recommendation for this rule, or `null` if there is no recommendation or the recommendation is invalid. * The recommendation for this rule, or `null` if there is no recommendation or the recommendation is invalid.
* Recommendations are normalised to their stable types.
*/ */
public get recommendation(): string|null { public get recommendation(): string|null {
if (RECOMMENDATION_BAN_TYPES.includes(this.action)) return RECOMMENDATION_BAN; if (RECOMMENDATION_BAN_TYPES.includes(this.action)) return RECOMMENDATION_BAN;

View File

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { MatrixGlob } from "matrix-bot-sdk";
import { setToArray } from "../utils"; import { setToArray } from "../utils";
export interface ServerAclContent { export interface ServerAclContent {
@ -27,6 +28,28 @@ export class ServerAcl {
private deniedServers: Set<string> = new Set<string>(); private deniedServers: Set<string> = new Set<string>();
private allowIps = false; private allowIps = false;
public constructor(public readonly homeserver: string) {
}
/**
* Checks the ACL for any entries that might ban ourself.
* @returns A list of deny entries that will not ban our own homeserver.
*/
public safeDeniedServers(): string[] {
// The reason we do this check here rather than in the `denyServer` method
// is because `literalAclContent` exists and also we want to be defensive about someone
// mutating `this.deniedServers` via another method in the future.
const entries: string[] = []
for (const server of this.deniedServers) {
const glob = new MatrixGlob(server);
if (!glob.test(this.homeserver)) {
entries.push(server);
}
}
return entries;
}
public allowIpAddresses(): ServerAcl { public allowIpAddresses(): ServerAcl {
this.allowIps = true; this.allowIps = true;
return this; return this;
@ -72,7 +95,7 @@ export class ServerAcl {
} }
return { return {
allow: allowed, allow: allowed,
deny: setToArray(this.deniedServers), deny: this.safeDeniedServers(),
allow_ip_literals: this.allowIps, allow_ip_literals: this.allowIps,
}; };
} }

View File

@ -2,8 +2,9 @@ import { strict as assert } from "assert";
import config from "../../src/config"; import config from "../../src/config";
import { newTestUser } from "./clientHelper"; import { newTestUser } from "./clientHelper";
import { MatrixClient } from "matrix-bot-sdk"; import { MatrixClient, UserID } from "matrix-bot-sdk";
import BanList, { ChangeType, ListRuleChange, RULE_USER } from "../../src/models/BanList"; import BanList, { ALL_RULE_TYPES, ChangeType, ListRuleChange, RULE_SERVER, RULE_USER } from "../../src/models/BanList";
import { ServerAcl, ServerAclContent } from "../../src/models/ServerAcl";
/** /**
* Create a policy rule in a policy room. * Create a policy rule in a policy room.
@ -12,13 +13,14 @@ import BanList, { ChangeType, ListRuleChange, RULE_USER } from "../../src/model
* @param policyType The type of policy to add e.g. m.policy.rule.user. (Use RULE_USER though). * @param policyType The type of policy to add e.g. m.policy.rule.user. (Use RULE_USER though).
* @param entity The entity to ban e.g. @foo:example.org * @param entity The entity to ban e.g. @foo:example.org
* @param reason A reason for the rule e.g. 'Wouldn't stop posting spam links' * @param reason A reason for the rule e.g. 'Wouldn't stop posting spam links'
* @param template The template to use for the policy rule event.
* @returns The event id of the newly created policy rule. * @returns The event id of the newly created policy rule.
*/ */
async function createPolicyRule(client: MatrixClient, policyRoomId: string, policyType: string, entity: string, reason: string) { async function createPolicyRule(client: MatrixClient, policyRoomId: string, policyType: string, entity: string, reason: string, template = {recommendation: 'm.ban'}) {
return await client.sendStateEvent(policyRoomId, policyType, `rule:${entity}`, { return await client.sendStateEvent(policyRoomId, policyType, `rule:${entity}`, {
entity, entity,
reason, reason,
recommendation: 'm.ban' ...template,
}); });
} }
@ -175,4 +177,51 @@ describe("Test: Updating the BanList", function () {
assert.equal(changes[0].previousState['event_id'], updatedEventId, 'There should be a previous state event for a modified rule'); assert.equal(changes[0].previousState['event_id'], updatedEventId, 'There should be a previous state event for a modified rule');
assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 0, 'The rule should no longer be stored.'); assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 0, 'The rule should no longer be stored.');
}) })
it('Test: BanList Supports all entity types.', async function () {
const mjolnir = config.RUNTIME.client!
const banListId = await mjolnir.createRoom();
const banList = new BanList(banListId, banListId, mjolnir);
for (let i = 0; i < ALL_RULE_TYPES.length; i++) {
await createPolicyRule(mjolnir, banListId, ALL_RULE_TYPES[i], `*${i}*`, '');
}
let changes: ListRuleChange[] = await banList.updateList();
assert.equal(changes.length, ALL_RULE_TYPES.length);
assert.equal(banList.allRules.length, ALL_RULE_TYPES.length);
})
}); });
describe('Test: We do not respond to recommendations other than m.ban in the banlist', function () {
it('Will not respond to a rule that has a different recommendation to m.ban (or the unstable equivalent).', async function () {
const mjolnir = config.RUNTIME.client!
const banListId = await mjolnir.createRoom();
const banList = new BanList(banListId, banListId, mjolnir);
await createPolicyRule(mjolnir, banListId, RULE_SERVER, 'exmaple.org', '', {recommendation: 'something that is not m.ban'});
let changes: ListRuleChange[] = await banList.updateList();
assert.equal(changes.length, 1, 'There should only be one change');
assert.equal(changes[0].changeType, ChangeType.Added);
assert.equal(changes[0].sender, await mjolnir.getUserId());
// We really don't want things that aren't m.ban to end up being accessible in these APIs.
assert.equal(banList.serverRules.length, 0);
assert.equal(banList.allRules.length, 0);
})
})
describe('Test: We will not be able to ban ourselves via ACL.', function () {
it('We do not ban ourselves when we put ourselves into the policy list.', async function () {
const mjolnir = config.RUNTIME.client!
const serverName = new UserID(await mjolnir.getUserId()).domain;
const banListId = await mjolnir.createRoom();
const banList = new BanList(banListId, banListId, mjolnir);
await createPolicyRule(mjolnir, banListId, RULE_SERVER, serverName, '');
await createPolicyRule(mjolnir, banListId, RULE_SERVER, 'evil.com', '');
await createPolicyRule(mjolnir, banListId, RULE_SERVER, '*', '');
// We should still intern the matching rules rule.
let changes: ListRuleChange[] = await banList.updateList();
assert.equal(banList.serverRules.length, 3);
// But when we construct an ACL, we should be safe.
const acl = new ServerAcl(serverName)
changes.forEach(change => acl.denyServer(change.rule.entity));
assert.equal(acl.safeAclContent().deny.length, 1);
assert.equal(acl.literalAclContent().deny.length, 3);
})
})