RedactionSynchronisationProtection invite retraction (#788)

- The _Redaction Synchronisation Protection_ has been improved in a few ways:
  - Invitations in protected rooms will be rejected as part of the redaction
    process when they are sent from users being redacted (e.g. as a brigading
    tactic).
  - User redaction will now be triggered on bans and the reason will be scanned
    for `automaticallyRedactForReasons` from Draupnir's config.

* Update RedactionSynchronisation for new protection apis.

* Rerwrite redaction synchronisation protection

* Reject invitations on ban.

* Add renderer and simulated redaction synchornisation capability.

* Reduce dependencies of redaction synchronisation protection.

* Allow RedactionSynchronisation to be unit tested.

* Update to MPS 3.1.0.

---------

Signed-off-by: Rory& <root@rory.gay>
Co-authored-by: Rory& <root@rory.gay>
This commit is contained in:
Gnuxie
2025-03-28 17:48:57 +00:00
committed by GitHub
parent d7df58101c
commit ff4f78ee65
6 changed files with 427 additions and 144 deletions

View File

@@ -12,6 +12,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Changed
- The _Redaction Synchronisation Protection_ has been improved in a few ways:
- Invitations in protected rooms will be rejected as part of the redaction
process when they are sent from users being redacted (e.g. as a brigading
tactic).
- User redaction will now be triggered on bans and the reason will be scanned
for `automaticallyRedactForReasons` from Draupnir's config.
## [v2.3.0-beta.0]
In this update we want feedback on new

View File

@@ -63,8 +63,8 @@
"jsdom": "^24.0.0",
"matrix-appservice-bridge": "^10.3.1",
"matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@^0.7.1-element.6",
"matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@3.0.0",
"matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@3.0.0",
"matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@3.1.0",
"matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@3.1.0",
"pg": "^8.8.0",
"yaml": "^2.3.2"
},

View File

@@ -9,7 +9,6 @@
*/
// keep alphabetical please.
import "./RoomTakedown/RoomTakedownProtection";
import "./BanPropagation";
import "./BasicFlooding";
import "./FirstMessageIsImage";

View File

@@ -1,16 +1,15 @@
// SPDX-FileCopyrightText: 2024 Gnuxie <Gnuxie@protonmail.com>
// SPDX-FileCopyrightText: 2024 - 2025 Gnuxie <Gnuxie@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
// README: This protection really exists as a stop gap to bring over redaction
// functionality over from Draupnir, while we figure out how to add redaction
// policies that operate on a timeline cache, which removes the painfull process
// that is currently used to repeatedly fetch `/messages`.
import {
AbstractProtection,
ActionException,
ActionExceptionKind,
ActionResult,
CapabilitySet,
Capability,
CapabilityMethodSchema,
Membership,
MembershipChange,
MembershipChangeType,
Ok,
@@ -26,161 +25,353 @@ import {
ProtectionDescription,
Recommendation,
RoomMembershipRevision,
SetMembershipPolicyRevisionIssuer,
SetRoomMembership,
Task,
UnknownConfig,
WatchedPolicyRooms,
describeCapabilityInterface,
describeCapabilityProvider,
describeCapabilityRenderer,
describeProtection,
} from "matrix-protection-suite";
import { Draupnir } from "../Draupnir";
import { redactUserMessagesIn } from "../utils";
import {
MatrixGlob,
StringRoomID,
StringUserID,
} from "@the-draupnir-project/matrix-basic-types";
import { Result } from "@gnuxie/typescript-result";
import { Type } from "@sinclair/typebox";
import { revisionRulesMatchingUser } from "../commands/unban/UnbanUsers";
import { redactUserMessagesIn } from "../utils";
type RedactionSynchronisationProtectionDescription =
ProtectionDescription<Draupnir>;
export type RedactionSynchronisationProtectionCapabilitiesSet = {
consequences: RedactionSynchronisationConsequences;
};
type RedactionSynchronisationProtectionDescription = ProtectionDescription<
Draupnir,
UnknownConfig,
RedactionSynchronisationProtectionCapabilitiesSet
>;
// FIXME: We really need to design a capability interface for this protection...
// TBH doing so, would probably just be the UserConsequences capability...
// There shouldn't be much difference between this protection and MemberBanSynchronisation
// except this one applies when redaction reasons are given on policies and ban events.
// FIXME: Need to decide whether to use two consequences...
// the reason why we might need two is if calling redaction when there are invited
// users will cause issues,, we could just add a second method though.
// FIXME: Just add the invited users thing, we need that code here or there
// so may aswell do it and test it.
// FIXME: We should consider updating both SetMembership and SetMembershipPolicies
// to understand parted members...
export interface RedactionSynchronisationConsequences extends Capability {
redactMessagesIn(
userIDOrGlob: StringUserID,
reason: string | undefined,
roomIDs: StringRoomID[]
): Promise<Result<void>>;
rejectInvite(
roomID: StringRoomID,
sender: StringUserID,
reciever: StringUserID,
reason: string | undefined
): Promise<Result<void>>;
}
describeCapabilityInterface({
name: "RedactionSynchronisationConsequences",
description: "Consequences for the RedactionSynchronisationProtection",
schema: Type.Object({
redactMessagesIn: CapabilityMethodSchema,
}),
});
describeCapabilityProvider({
name: "StandardRedactionSynchronisationConsequences",
interface: "RedactionSynchronisationConsequences",
description: "redacts events and rejects invitations send by the target",
factory(description, draupnir: Draupnir) {
return Object.freeze({
requiredPermissions: [
PowerLevelPermission.Redact,
PowerLevelPermission.Kick,
],
requiredStatePermissions: [],
requiredEventPermissions: [],
async redactMessagesIn(userIDOrGlob, reason, roomIDs) {
const redactionResult = await redactUserMessagesIn(
draupnir.client,
draupnir.managementRoomOutput,
userIDOrGlob,
roomIDs
).then(
(_) => Ok(undefined),
(error) =>
ActionException.Result(
`Error redacting messages for ${userIDOrGlob}`,
{
exception: error,
exceptionKind: ActionExceptionKind.Unknown,
}
)
);
return redactionResult;
},
async rejectInvite(roomID, _sender, target, reason) {
return await draupnir.clientPlatform
.toRoomKicker()
.kickUser(roomID, target, reason);
},
} satisfies RedactionSynchronisationConsequences);
},
});
// FIXME: We really need capability rendering to be configurable.
describeCapabilityProvider({
name: "SimulatedRedactionSynchronisationConsequences",
interface: "RedactionSynchronisationConsequences",
description: "Simulated redaction consequences",
factory(_description, _context) {
return Object.freeze({
requiredPermissions: [],
requiredStatePermissions: [],
requiredEventPermissions: [],
isSimulated: true,
async redactMessagesIn() {
return Ok(undefined);
},
async rejectInvite() {
return Ok(undefined);
},
} satisfies RedactionSynchronisationConsequences);
},
});
describeCapabilityRenderer({
name: "StandardRedactionSynchronisationConsequencesRenderer",
interface: "RedactionSynchronisationConsequences",
description: "Doesn't render anything tbh, because it would be too annoying",
isDefaultForInterface: true,
factory(
_protectionDescription,
_context,
provider: RedactionSynchronisationConsequences
) {
return Object.freeze({
...(provider.isSimulated ? { isSimulated: true } : {}),
requiredPermissions: provider.requiredPermissions,
requiredStatePermissions: provider.requiredStatePermissions,
requiredEventPermissions: provider.requiredEventPermissions,
async redactMessagesIn(userIDOrGlob, reason, roomIDs) {
return await provider.redactMessagesIn(userIDOrGlob, reason, roomIDs);
},
async rejectInvite(roomID, sender, reciever, reason) {
return await provider.rejectInvite(roomID, sender, reciever, reason);
},
} satisfies RedactionSynchronisationConsequences);
},
});
interface RedactionSynchronisation {
// Used to check matching policies at startup.
// Not used for applying redactions on match, since the new policy
// hook is used for that.
handlePermissionRequirementsMet(roomID: StringRoomID): void;
// Used to check for when someone is trying to trigger draupnir to cleanup
// or new policy was issued
handlePolicyChange(policyChange: PolicyRuleChange): void;
// Used to handle redactions/reject invitations as a user is banned
handleMembershipChange(change: MembershipChange): void;
}
export class StandardRedactionSynchronisation
implements RedactionSynchronisation
{
public constructor(
private readonly automaticRedactionReasons: MatrixGlob[],
private readonly consequences: RedactionSynchronisationConsequences,
private readonly watchedPolicyRooms: WatchedPolicyRooms,
private readonly setRoomMembership: SetRoomMembership,
private readonly setPoliciesMatchingMembership: SetMembershipPolicyRevisionIssuer
) {
// nothing to do.
}
handlePermissionRequirementsMet(roomID: StringRoomID): void {
const membershipRevision = this.setRoomMembership.getRevision(roomID);
if (membershipRevision !== undefined) {
this.checkRoomInvitations(membershipRevision);
}
for (const match of this.setPoliciesMatchingMembership.currentRevision.allMembersWithRules()) {
if (membershipRevision?.membershipForUser(match.userID)) {
const policyRequiringRedaction = match.policies.find((policy) =>
this.isPolicyRequiringRedaction(policy)
);
if (policyRequiringRedaction !== undefined) {
void Task(
this.consequences.redactMessagesIn(
match.userID,
policyRequiringRedaction.reason,
[roomID]
)
);
}
}
}
}
handlePolicyChange(change: PolicyRuleChange): void {
const policy = change.rule;
if (change.changeType === PolicyRuleChangeType.Removed) {
return;
}
if (policy.kind !== PolicyRuleType.User) {
return;
}
if (policy.matchType === PolicyRuleMatchType.HashedLiteral) {
return;
}
if (!this.isPolicyRequiringRedaction(policy)) {
return;
}
const roomsRequiringRedaction =
policy.matchType === PolicyRuleMatchType.Literal
? this.setRoomMembership.allRooms.filter((revision) =>
revision.membershipForUser(StringUserID(policy.entity))
)
: this.setRoomMembership.allRooms;
void Task(
this.consequences.redactMessagesIn(
StringUserID(policy.entity),
policy.reason,
roomsRequiringRedaction.map((revision) =>
revision.room.toRoomIDOrAlias()
)
)
);
for (const revision of roomsRequiringRedaction) {
this.checkRoomInvitations(revision);
}
}
handleMembershipChange(change: MembershipChange): void {
if (
change.membershipChangeType === MembershipChangeType.Banned &&
this.automaticRedactionReasons.some((reason) =>
reason.test(change.content.reason ?? "<no reason supplied>")
)
) {
void Task(
this.consequences.redactMessagesIn(change.userID, undefined, [
change.roomID,
])
);
const membershipRevision = this.setRoomMembership.getRevision(
change.roomID
);
if (membershipRevision) {
this.checkRoomInvitations(membershipRevision);
}
}
}
private isPolicyRequiringRedaction(policy: PolicyRule): boolean {
return this.automaticRedactionReasons.some((reason) =>
reason.test(policy.reason ?? "<no reason supplied>")
);
}
private checkRoomInvitations(
membershipRevision: RoomMembershipRevision
): void {
const invites = membershipRevision.membersOfMembership(Membership.Invite);
for (const invite of invites) {
const relevantRules = revisionRulesMatchingUser(
invite.sender,
[Recommendation.Takedown, Recommendation.Ban],
this.watchedPolicyRooms.currentRevision
).filter((policy) => this.isPolicyRequiringRedaction(policy));
if (relevantRules.length > 0) {
void Task(
this.consequences.rejectInvite(
invite.roomID,
invite.sender,
invite.userID,
relevantRules.find((policy) => policy.reason !== undefined)?.reason
)
);
}
}
}
}
export class RedactionSynchronisationProtection
extends AbstractProtection<RedactionSynchronisationProtectionDescription>
implements Protection<RedactionSynchronisationProtectionDescription>
{
private automaticRedactionReasons: MatrixGlob[] = [];
private readonly redactionSynchronisation: RedactionSynchronisation;
public constructor(
description: RedactionSynchronisationProtectionDescription,
capabilities: CapabilitySet,
capabilities: RedactionSynchronisationProtectionCapabilitiesSet,
protectedRoomsSet: ProtectedRoomsSet,
private readonly draupnir: Draupnir
automaticallyRedactForReasons: string[]
) {
super(description, capabilities, protectedRoomsSet, {
requiredPermissions: [PowerLevelPermission.Redact],
});
for (const reason of draupnir.config.automaticallyRedactForReasons) {
this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase()));
}
}
public redactForNewUserPolicy(policy: PolicyRule): void {
const rooms: StringRoomID[] = [];
if (policy.matchType === PolicyRuleMatchType.HashedLiteral) {
return; // wait for them to be reversed and placed into the model as clear text.
}
if (policy.matchType === PolicyRuleMatchType.Glob) {
this.protectedRoomsSet.allProtectedRooms.forEach((room) =>
rooms.push(room.toRoomIDOrAlias())
);
} else {
for (const roomMembership of this.protectedRoomsSet.setRoomMembership
.allRooms) {
const membership = roomMembership.membershipForUser(
policy.entity as StringUserID
);
if (membership !== undefined) {
rooms.push(roomMembership.room.toRoomIDOrAlias());
}
}
}
void Task(
redactUserMessagesIn(
this.draupnir.client,
this.draupnir.managementRoomOutput,
policy.entity,
rooms
)
super(description, capabilities, protectedRoomsSet, {});
this.redactionSynchronisation = new StandardRedactionSynchronisation(
automaticallyRedactForReasons.map((reason) => new MatrixGlob(reason)),
capabilities.consequences,
protectedRoomsSet.watchedPolicyRooms,
protectedRoomsSet.setRoomMembership,
protectedRoomsSet.setPoliciesMatchingMembership
);
}
public async handlePolicyChange(
revision: PolicyListRevision,
_revision: PolicyListRevision,
changes: PolicyRuleChange[]
): Promise<ActionResult<void>> {
const relevantChanges = changes.filter(
(change) =>
change.changeType !== PolicyRuleChangeType.Removed &&
change.rule.kind === PolicyRuleType.User &&
this.automaticRedactionReasons.some((reason) =>
reason.test(change.rule.reason ?? "<no reason supplied>")
)
);
// Can't see this fucking up at all when watching a new list :skull:.
// So instead, we employ a genius big brain move.
// Basically, this stops us from overwhelming draupnir with redaction
// requests if the user watches a new list. Very unideal.
// however, please see the comment at the top of the file which explains
// how this protection **should** work, if it wasn't a stop gap.
if (relevantChanges.length > 5) {
return Ok(undefined);
} else if (relevantChanges.length === 0) {
return Ok(undefined);
} else {
relevantChanges.forEach((change) => {
this.redactForNewUserPolicy(change.rule);
});
return Ok(undefined);
}
changes.forEach((change) => {
this.redactionSynchronisation.handlePolicyChange(change);
});
return Ok(undefined);
}
// Scan again on ban to make sure we mopped everything up.
public async handleMembershipChange(
revision: RoomMembershipRevision,
_revision: RoomMembershipRevision,
changes: MembershipChange[]
): Promise<ActionResult<void>> {
const isUserJoiningWithPolicyRequiringRedaction = (
change: MembershipChange
) => {
if (
change.membershipChangeType === MembershipChangeType.Joined ||
change.membershipChangeType === MembershipChangeType.Rejoined
) {
const policyRevision =
this.protectedRoomsSet.watchedPolicyRooms.currentRevision;
const matchingPolicy =
policyRevision.findRuleMatchingEntity(change.userID, {
type: PolicyRuleType.User,
recommendation: Recommendation.Ban,
searchHashedRules: false,
}) ??
policyRevision.findRuleMatchingEntity(change.userID, {
type: PolicyRuleType.User,
recommendation: Recommendation.Takedown,
searchHashedRules: false,
});
return (
matchingPolicy !== undefined &&
this.automaticRedactionReasons.some((reason) =>
reason.test(matchingPolicy.reason ?? "<no reason supplied>")
)
);
} else {
return false;
}
};
const relevantChanges = changes.filter(
isUserJoiningWithPolicyRequiringRedaction
);
for (const change of relevantChanges) {
void Task(
redactUserMessagesIn(
this.draupnir.client,
this.draupnir.managementRoomOutput,
change.userID,
[revision.room.toRoomIDOrAlias()]
)
);
}
changes.forEach((change) => {
this.redactionSynchronisation.handleMembershipChange(change);
});
return Ok(undefined);
}
}
describeProtection<Record<never, never>, Draupnir>({
name: RedactionSynchronisationProtection.name,
description:
"Redacts messages when a new ban policy has been issued that matches config.automaticallyRedactForReasons. Work in progress.",
capabilityInterfaces: {},
defaultCapabilities: {},
factory(description, protectedRoomsSet, draupnir, capabilities) {
return Ok(
new RedactionSynchronisationProtection(
description,
capabilities,
protectedRoomsSet,
draupnir
)
);
},
});
describeProtection<RedactionSynchronisationProtectionCapabilitiesSet, Draupnir>(
{
name: RedactionSynchronisationProtection.name,
description:
"Redacts messages when a new ban policy has been issued that matches config.automaticallyRedactForReasons. Work in progress.",
capabilityInterfaces: {
consequences: "RedactionSynchronisationConsequences",
},
defaultCapabilities: {
consequences: "StandardRedactionSynchronisationConsequences",
},
factory(description, protectedRoomsSet, draupnir, capabilities) {
return Ok(
new RedactionSynchronisationProtection(
description,
capabilities,
protectedRoomsSet,
draupnir.config.automaticallyRedactForReasons
)
);
},
}
);

View File

@@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
import {
describeProtectedRoomsSet,
Membership,
Ok,
PolicyRuleType,
randomRoomID,
randomUserID,
Recommendation,
} from "matrix-protection-suite";
import {
RedactionSynchronisationConsequences,
StandardRedactionSynchronisation,
} from "../../../src/protections/RedactionSynchronisation";
import { MatrixGlob } from "@the-draupnir-project/matrix-basic-types";
import { createMock } from "ts-auto-mock";
import expect from "expect";
describe("RedactionSynchronisation", function () {
it("Attempts to retract invitations on permission requirements met", async function () {
const room = randomRoomID([]);
const targetUser = randomUserID();
const { protectedRoomsSet } = await describeProtectedRoomsSet({
rooms: [
{
room,
membershipDescriptions: [
{
sender: targetUser,
membership: Membership.Leave,
},
{
sender: targetUser,
membership: Membership.Invite,
target: randomUserID(),
},
],
},
],
lists: [
{
policyDescriptions: [
{
recommendation: Recommendation.Ban,
entity: targetUser,
reason: "spam",
type: PolicyRuleType.User,
},
],
},
],
});
let mockMethodCalls = 0;
const mockConsequences = createMock<RedactionSynchronisationConsequences>({
async redactMessagesIn(userIDOrGlob, _reason, _roomIDs) {
expect(userIDOrGlob).toBe(targetUser);
mockMethodCalls += 1;
return Ok(undefined);
},
async rejectInvite(roomID, sender, _reciever, _reason) {
expect(roomID).toBe(room.toRoomIDOrAlias());
expect(sender).toBe(targetUser);
mockMethodCalls += 1;
return Ok(undefined);
},
});
const redactionSynronisationService = new StandardRedactionSynchronisation(
[new MatrixGlob("spam")],
mockConsequences,
protectedRoomsSet.watchedPolicyRooms,
protectedRoomsSet.setRoomMembership,
protectedRoomsSet.setPoliciesMatchingMembership
);
redactionSynronisationService.handlePermissionRequirementsMet(
room.toRoomIDOrAlias()
);
expect(mockMethodCalls).toBe(1);
});
});

View File

@@ -2593,18 +2593,18 @@ matrix-appservice@^2.0.0:
request-promise "^4.2.6"
sanitize-html "^2.11.0"
"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-3.0.0.tgz#eb4eaf07b040d223d18e949dfbe8d6712563974e"
integrity sha512-Rv7Ouxc11z08RvJ6d+W1v8pTxWUsQoJztMR53YQaRV1Q2YBkRAPJxIm/xIAfAf15j32LYJR+2TKrs1HxwFMybQ==
"matrix-protection-suite-for-matrix-bot-sdk@npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-3.1.0.tgz#36ed640f6425f568e85c880b6e3da87019e06db5"
integrity sha512-dgagHqAupTj7L/MAdXxwXFxNNceGALuMUr05WfnfBUfkbeI0xiC2rPtwKzh3xO2crjSYx52UE+59V73koHzDpQ==
dependencies:
"@gnuxie/typescript-result" "^1.0.0"
await-lock "^2.2.2"
"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-3.0.0.tgz#6e679f1f0e3b9a16f046c095d1ed1416a078e376"
integrity sha512-X06uzOKN/fwOW2Vm3AKL2HKLbjU1PrqhUsYTzZGGQnEQYkAvoFnuK/TyKF8QaVoMJ/PF4Ybkkj6t5GbZGsGajg==
"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-3.1.0.tgz#5969adb791e28b5ed985cb82cf6d5bd0b2d2740c"
integrity sha512-8w1uLsH1Q18qcl/vyGru+ckywEicg7JqPMgi9VGaHPhz9+JB5JmT+GoYXgyI4lEpqpiPv0uI+2bs2RkplWyseQ==
dependencies:
"@gnuxie/typescript-result" "^1.0.0"
await-lock "^2.2.2"