Merge pull request #758 from the-draupnir-project/gnuxie/synapse-http-antispam

synapse-http-antispam support
This commit is contained in:
Gnuxie
2025-03-14 17:42:57 +00:00
committed by GitHub
14 changed files with 595 additions and 8 deletions
+12
View File
@@ -273,6 +273,18 @@ web:
abuseReporting:
# Whether to enable this feature.
enabled: false
# Whether to setup a endpoints for synapse-http-antispam
# https://github.com/maunium/synapse-http-antispam
# this is required for some features of Draupnir,
# such as support for room takedown policies.
#
# Please FOLLOW the instructions here:
# https://the-draupnir-project.github.io/draupnir-documentation/bot/synapse-http-antispam
synapseHTTPAntispam:
enabled: false
# This is a secret that you must place into your synapse module config
# https://github.com/maunium/synapse-http-antispam?tab=readme-ov-file#configuration
authorization: REPLACE_ME
# Whether or not to actively poll synapse for abuse reports, to be used
# instead of intercepting client calls to synapse's abuse endpoint, when that
+3
View File
@@ -186,3 +186,6 @@ web:
abuseReporting:
# Whether to enable this feature.
enabled: true
synapseHTTPAntispam:
enabled: true
authorization: DEFAULT
+15 -4
View File
@@ -37,12 +37,23 @@ down:
- docker stop mjolnir-test-reverse-proxy || true
modules:
- name: mjolnir
- name: HTTPAntispam
build:
- cp -r synapse_antispam $MX_TEST_MODULE_DIR/
- git clone https://github.com/maunium/synapse-http-antispam.git
$MX_TEST_MODULE_DIR/
config:
module: mjolnir.Module
config: {}
module: synapse_http_antispam.HTTPAntispam
config:
base_url: http://host.docker.internal:8082/api/1/spam_check
authorization: DEFAULT
enabled_callbacks:
- user_may_invite
- user_may_join_room
- check_event_for_spam
fail_open:
user_may_invite: true
user_may_join_room: true
check_event_for_spam: true
homeserver:
# Basic configuration.
+7 -3
View File
@@ -81,6 +81,7 @@ import {
COMMAND_CONFIRMATION_LISTENER,
makeConfirmationPromptListener,
} from "./commands/interface-manager/MatrixPromptForConfirmation";
import { SynapseHttpAntispam } from "./webapis/SynapseHTTPAntispam/SynapseHttpAntispam";
const log = new Logger("Draupnir");
// webAPIS should not be included on the Draupnir class.
@@ -144,7 +145,8 @@ export class Draupnir implements Client, MatrixAdaptorContext {
public readonly acceptInvitesFromRoom: MatrixRoomID,
public readonly acceptInvitesFromRoomIssuer: RoomMembershipRevisionIssuer,
public readonly safeModeToggle: SafeModeToggle,
public readonly synapseAdminClient?: SynapseAdminClient
public readonly synapseAdminClient: SynapseAdminClient | undefined,
public readonly synapseHTTPAntispam: SynapseHttpAntispam | undefined
) {
this.managementRoomOutput = new ManagementRoomOutput(
this.managementRoomDetail,
@@ -209,7 +211,8 @@ export class Draupnir implements Client, MatrixAdaptorContext {
roomMembershipManager: RoomMembershipManager,
config: IConfig,
loggableConfigTracker: LoggableConfigTracker,
safeModeToggle: SafeModeToggle
safeModeToggle: SafeModeToggle,
synapseHTTPAntispam: SynapseHttpAntispam | undefined
): Promise<ActionResult<Draupnir>> {
const acceptInvitesFromRoom = await (async () => {
if (config.autojoinOnlyIfManager) {
@@ -267,7 +270,8 @@ export class Draupnir implements Client, MatrixAdaptorContext {
acceptInvitesFromRoom.ok,
acceptInvitesFromRoomIssuer.ok,
safeModeToggle,
new SynapseAdminClient(client, clientUserID)
new SynapseAdminClient(client, clientUserID),
synapseHTTPAntispam
);
const loadResult = await protectedRoomsSet.protections.loadProtections(
protectedRoomsSet,
+8
View File
@@ -48,6 +48,7 @@ import { SafeModeDraupnir } from "./safemode/DraupnirSafeMode";
import { ResultError } from "@gnuxie/typescript-result";
import { SafeModeCause, SafeModeReason } from "./safemode/SafeModeCause";
import { SafeModeBootOption } from "./safemode/BootOption";
import { SynapseHttpAntispam } from "./webapis/SynapseHTTPAntispam/SynapseHttpAntispam";
const log = new Logger("DraupnirBotMode");
@@ -73,6 +74,9 @@ interface BotModeTogle extends SafeModeToggle {
error: ResultError,
options?: SafeModeToggleOptions
): Promise<Result<SafeModeDraupnir>>;
// The SynapseHTTPAntispam listeners, if available.
// Which they won't be for some bot mode and all application service users.
readonly synapseHTTPAntispam: SynapseHttpAntispam | undefined;
}
export class DraupnirBotModeToggle implements BotModeTogle {
@@ -80,6 +84,10 @@ export class DraupnirBotModeToggle implements BotModeTogle {
private safeModeDraupnir: SafeModeDraupnir | null = null;
private webAPIs: WebAPIs | null = null;
public get synapseHTTPAntispam() {
return this.webAPIs?.synapseHTTPAntispam ?? undefined;
}
private constructor(
private readonly clientUserID: StringUserID,
private readonly managementRoom: MatrixRoomID,
+19
View File
@@ -40,6 +40,17 @@ export function getNonDefaultConfigProperties(
) {
nonDefault.pantalaimon.password = "REDACTED";
}
if (
"web" in nonDefault &&
typeof nonDefault["web"] === "object" &&
nonDefault["web"] !== null &&
"synapseHTTPAntispam" in nonDefault["web"] &&
typeof nonDefault["web"]["synapseHTTPAntispam"] === "object"
) {
if (nonDefault["web"]["synapseHTTPAntispam"] !== null) {
nonDefault["web"]["synapseHTTPAntispam"].authorization = "REDACTED";
}
}
return nonDefault;
}
@@ -147,6 +158,10 @@ export interface IConfig {
abuseReporting: {
enabled: boolean;
};
synapseHTTPAntispam: {
enabled: boolean;
authorization: string;
};
};
// Store room state using sqlite to improve startup time when Synapse responds
// slowly to requests for `/state`.
@@ -242,6 +257,10 @@ const defaultConfig: IConfig = {
abuseReporting: {
enabled: false,
},
synapseHTTPAntispam: {
enabled: false,
authorization: "DEFAULT",
},
},
roomStateBackingStore: {
enabled: true,
+6 -1
View File
@@ -29,6 +29,7 @@ import { SafeModeDraupnir } from "../safemode/DraupnirSafeMode";
import { SafeModeCause } from "../safemode/SafeModeCause";
import { SafeModeToggle } from "../safemode/SafeModeToggle";
import { StandardManagementRoomDetail } from "../managementroom/ManagementRoomDetail";
import { DraupnirBotModeToggle } from "../DraupnirBotMode";
const log = new Logger("DraupnirFactory");
@@ -141,7 +142,11 @@ export class DraupnirFactory {
roomMembershipManager,
config,
configLogTracker,
toggle
toggle,
// synapseHTTPAntispam is only available in bot mode.
toggle instanceof DraupnirBotModeToggle
? toggle.synapseHTTPAntispam
: undefined
);
}
@@ -0,0 +1,86 @@
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
import { Type } from "@sinclair/typebox";
import {
EDStatic,
isError,
Logger,
RoomEvent,
Task,
Value,
} from "matrix-protection-suite";
import { SpamCheckEndpointPluginManager } from "./SpamCheckEndpointPluginManager";
import { Request, Response } from "express";
const log = new Logger("CheckEventForSpamEndpoint");
export type CheckEventForSpamListenerArguments = Parameters<
(details: CheckEventForSpamRequestBody) => void
>;
type CheckEventForSpamRequestBody = EDStatic<
typeof CheckEventForSpamRequestBody
>;
const CheckEventForSpamRequestBody = Type.Object({
event: RoomEvent(Type.Unknown()),
});
export class CheckEventForSpamEndpoint {
public constructor(
private readonly pluginManager: SpamCheckEndpointPluginManager<CheckEventForSpamListenerArguments>
) {
// nothing to do.
}
private async handleCheckEventForSpamAsync(
request: Request,
response: Response,
isResponded: boolean
): Promise<void> {
const decodedBody = Value.Decode(
CheckEventForSpamRequestBody,
request.body
);
if (isError(decodedBody)) {
log.error("Error decoding request body:", decodedBody.error);
if (!isResponded && this.pluginManager.isBlocking()) {
response
.status(400)
.send({ errcode: "M_INVALID_PARAM", error: "Error handling event" });
}
return;
}
if (!isResponded && this.pluginManager.isBlocking()) {
const blockingResult = await this.pluginManager.callBlockingHandles(
decodedBody.ok
);
if (blockingResult === "NOT_SPAM") {
response.status(200);
response.send({});
} else {
response.status(400);
response.send(blockingResult);
}
} else if (!isResponded) {
response.status(200);
response.send({});
}
this.pluginManager.callNonBlockingHandlesInTask(decodedBody.ok);
}
public handleCheckEventForSpam(request: Request, response: Response): void {
if (!this.pluginManager.isBlocking()) {
response.status(200);
response.send({});
}
void Task(
this.handleCheckEventForSpamAsync(
request,
response,
!this.pluginManager.isBlocking()
)
);
}
}
@@ -0,0 +1,93 @@
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
import { Logger, Task } from "matrix-protection-suite";
type BlockingResponse =
| "NOT_SPAM"
| {
errcode: string;
error: string;
};
const log = new Logger("SpamCheckEndpointPluginManager");
export type BlockingCallback<CBArguments extends unknown[]> = (
...args: CBArguments
) => Promise<BlockingResponse>;
export type NonBlockingCallback<CBArguments extends unknown[]> = (
...args: CBArguments
) => void;
export class SpamCheckEndpointPluginManager<CBArguments extends unknown[]> {
private readonly blockingHandles = new Set<BlockingCallback<CBArguments>>();
private readonly nonBlockingHandles = new Set<
NonBlockingCallback<CBArguments>
>();
public registerBlockingHandle(handle: BlockingCallback<CBArguments>): void {
this.blockingHandles.add(handle);
}
public registerNonBlockingHandle(
handle: NonBlockingCallback<CBArguments>
): void {
this.nonBlockingHandles.add(handle);
}
public unregisterHandle(
handle: BlockingCallback<CBArguments> | NonBlockingCallback<CBArguments>
): void {
this.blockingHandles.delete(handle as BlockingCallback<CBArguments>);
this.nonBlockingHandles.delete(handle as NonBlockingCallback<CBArguments>);
}
public unregisterListeners(): void {
this.blockingHandles.clear();
this.nonBlockingHandles.clear();
}
public isBlocking(): boolean {
return this.blockingHandles.size > 0;
}
public async callBlockingHandles(
...args: CBArguments
): ReturnType<BlockingCallback<CBArguments>> {
const results = await Promise.allSettled(
[...this.blockingHandles.values()].map((handle) => handle(...args))
);
for (const result of results) {
if (result.status === "rejected") {
log.error(
"Error processing a blocking spam check callback:",
result.reason
);
} else {
if (result.value !== "NOT_SPAM") {
return result.value;
}
}
}
return "NOT_SPAM";
}
public callNonBlockingHandles(...args: CBArguments): void {
for (const handle of this.nonBlockingHandles) {
try {
handle(...args);
} catch (e) {
log.error("Error processing a non blocking spam check callback:", e);
}
}
}
public callNonBlockingHandlesInTask(...args: CBArguments): void {
void Task(
(async () => {
this.callNonBlockingHandles(...args);
})()
);
}
}
@@ -0,0 +1,88 @@
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
import { Express, Request, Response } from "express";
import { SpamCheckEndpointPluginManager } from "./SpamCheckEndpointPluginManager";
import {
UserMayInviteEndpoint,
UserMayInviteListenerArguments,
} from "./UserMayInviteEndpoint";
import {
UserMayJoinRoomEndpoint,
UserMayJoinRoomListenerArguments,
} from "./UserMayJoinRoomEndpoint";
import {
CheckEventForSpamEndpoint,
CheckEventForSpamListenerArguments,
} from "./CheckEventForSpamEndpoint";
const SPAM_CHECK_PREFIX = "/api/1/spam_check";
const AUTHORIZATION = new RegExp("Bearer (.*)");
function makeAuthenticatedEndpointHandler(
secret: string,
cb: (request: Request, response: Response) => void
): (request: Request, response: Response) => void {
return function (request, response) {
const authorization = request.get("Authorization");
if (!authorization) {
response.status(401).send("Missing access token");
return;
}
const [, accessToken] = AUTHORIZATION.exec(authorization) ?? [];
if (accessToken !== secret) {
response.status(401).send("Missing access token");
return;
}
cb(request, response);
};
}
export class SynapseHttpAntispam {
public readonly userMayInviteHandles =
new SpamCheckEndpointPluginManager<UserMayInviteListenerArguments>();
private readonly userMayInviteEndpoint = new UserMayInviteEndpoint(
this.userMayInviteHandles
);
public readonly userMayJoinRoomhandles =
new SpamCheckEndpointPluginManager<UserMayJoinRoomListenerArguments>();
private readonly userMayJoinRoomEndpoint = new UserMayJoinRoomEndpoint(
this.userMayJoinRoomhandles
);
public readonly checkEventForSpamHandles =
new SpamCheckEndpointPluginManager<CheckEventForSpamListenerArguments>();
private readonly checkEventForSpamEndpoint = new CheckEventForSpamEndpoint(
this.checkEventForSpamHandles
);
public constructor(
private readonly webController: Express,
private readonly secret: string
) {
// nothing to do
}
public register(): void {
this.webController.post(
`${SPAM_CHECK_PREFIX}/user_may_invite`,
makeAuthenticatedEndpointHandler(this.secret, (request, response) => {
this.userMayInviteEndpoint.handleUserMayInvite(request, response);
})
);
this.webController.post(
`${SPAM_CHECK_PREFIX}/user_may_join_room`,
makeAuthenticatedEndpointHandler(this.secret, (request, response) => {
this.userMayJoinRoomEndpoint.handleUserMayJoinRoom(request, response);
})
);
this.webController.post(
`${SPAM_CHECK_PREFIX}/check_event_for_spam`,
makeAuthenticatedEndpointHandler(this.secret, (request, response) => {
this.checkEventForSpamEndpoint.handleCheckEventForSpam(
request,
response
);
})
);
}
}
@@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
import { SpamCheckEndpointPluginManager } from "./SpamCheckEndpointPluginManager";
import { Request, Response } from "express";
import {
EDStatic,
isError,
Logger,
StringRoomIDSchema,
StringUserIDSchema,
Task,
Value,
} from "matrix-protection-suite";
import { Type } from "@sinclair/typebox";
// for check_event_for_spam we will leave the event as unparsed
const log = new Logger("UserMayInviteEndpoint");
export type UserMayInviteListenerArguments = Parameters<
(details: UserMayInviteRequestBody) => void
>;
type UserMayInviteRequestBody = EDStatic<typeof UserMayInviteRequestBody>;
const UserMayInviteRequestBody = Type.Object({
inviter: StringUserIDSchema,
invitee: StringUserIDSchema,
room_id: StringRoomIDSchema,
});
export type UserMayInvitePluginManager =
SpamCheckEndpointPluginManager<UserMayInviteListenerArguments>;
export class UserMayInviteEndpoint {
public constructor(
private readonly pluginManager: SpamCheckEndpointPluginManager<UserMayInviteListenerArguments>
) {
// nothing to do.
}
private async handleUserMayInviteAsync(
request: Request,
response: Response,
isResponded: boolean
): Promise<void> {
const decodedBody = Value.Decode(UserMayInviteRequestBody, request.body);
if (isError(decodedBody)) {
log.error("Error decoding request body:", decodedBody.error);
if (!isResponded && this.pluginManager.isBlocking()) {
response.status(400).send({
errcode: "M_INVALID_PARAM",
error: "Error handling inviter, invitee, and room_id",
});
}
return;
}
if (!isResponded && this.pluginManager.isBlocking()) {
const blockingResult = await this.pluginManager.callBlockingHandles(
decodedBody.ok
);
if (blockingResult === "NOT_SPAM") {
response.status(200);
response.send({});
} else {
response.status(400);
response.send(blockingResult);
}
} else if (!isResponded) {
response.status(200);
response.send({});
}
this.pluginManager.callNonBlockingHandlesInTask(decodedBody.ok);
}
public handleUserMayInvite(request: Request, response: Response): void {
if (!this.pluginManager.isBlocking()) {
response.status(200);
response.send({});
}
void Task(
this.handleUserMayInviteAsync(
request,
response,
!this.pluginManager.isBlocking()
)
);
}
}
@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
import { Type } from "@sinclair/typebox";
import {
EDStatic,
isError,
Logger,
StringRoomIDSchema,
StringUserIDSchema,
Task,
Value,
} from "matrix-protection-suite";
import { SpamCheckEndpointPluginManager } from "./SpamCheckEndpointPluginManager";
import { Request, Response } from "express";
const log = new Logger("UserMayJoinRoomEndpoint");
export type UserMayJoinRoomListenerArguments = Parameters<
(details: UserMayJoinRoomRequestBody) => void
>;
type UserMayJoinRoomRequestBody = EDStatic<typeof UserMayJoinRoomRequestBody>;
const UserMayJoinRoomRequestBody = Type.Object({
user: StringUserIDSchema,
room: StringRoomIDSchema,
is_invited: Type.Boolean(),
});
export class UserMayJoinRoomEndpoint {
public constructor(
private readonly pluginManager: SpamCheckEndpointPluginManager<UserMayJoinRoomListenerArguments>
) {
// nothing to do.
}
private async handleUserMayJoinRoomAsync(
request: Request,
response: Response,
isResponded: boolean
): Promise<void> {
const decodedBody = Value.Decode(UserMayJoinRoomRequestBody, request.body);
if (isError(decodedBody)) {
log.error("Error decoding request body:", decodedBody.error);
if (!isResponded && this.pluginManager.isBlocking()) {
response.status(400).send({
errcode: "M_INVALID_PARAM",
error: "Error handling user, room, and is_invited",
});
}
return;
}
if (!isResponded && this.pluginManager.isBlocking()) {
const blockingResult = await this.pluginManager.callBlockingHandles(
decodedBody.ok
);
if (blockingResult === "NOT_SPAM") {
response.status(200);
response.send({});
} else {
response.status(400);
response.send(blockingResult);
}
} else if (!isResponded) {
response.status(200);
response.send({});
}
this.pluginManager.callNonBlockingHandlesInTask(decodedBody.ok);
}
public handleUserMayJoinRoom(request: Request, response: Response): void {
if (!this.pluginManager.isBlocking()) {
response.status(200);
response.send({});
}
void Task(
this.handleUserMayJoinRoomAsync(
request,
response,
!this.pluginManager.isBlocking()
)
);
}
}
+9
View File
@@ -20,6 +20,7 @@ import {
isStringEventID,
} from "@the-draupnir-project/matrix-basic-types";
import { Logger, Task } from "matrix-protection-suite";
import { SynapseHttpAntispam } from "./SynapseHTTPAntispam/SynapseHttpAntispam";
const log = new Logger("WebAPIs");
@@ -33,6 +34,10 @@ const AUTHORIZATION = new RegExp("Bearer (.*)");
export class WebAPIs {
private webController: express.Express = express();
private httpServer?: Server | undefined;
public readonly synapseHTTPAntispam = new SynapseHttpAntispam(
this.webController,
this.config.web.synapseHTTPAntispam.authorization
);
constructor(
private reportManager: StandardReportManager,
@@ -58,6 +63,10 @@ export class WebAPIs {
}
);
});
// enable synapse http antispam
if (this.config.web.synapseHTTPAntispam.enabled) {
this.synapseHTTPAntispam.register();
}
// configure /report API.
if (this.config.web.abuseReporting.enabled) {
log.info(`configuring ${API_PREFIX}/report/:room_id/:event_id...`);
+75
View File
@@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: 2025 Gnuxie <Gnuxie@protonmail.com>
//
// SPDX-License-Identifier: AFL-3.0
import expect from "expect";
import { DraupnirTestContext } from "./mjolnirSetupUtils";
import {
ActionException,
isOk,
MatrixException,
} from "matrix-protection-suite";
import { MatrixError } from "matrix-bot-sdk";
describe("Test for http antispam callbacks", function () {
it("We can process check_event_for_spam", async function (
this: DraupnirTestContext
) {
const draupnir = this.draupnir;
if (draupnir === undefined) {
throw new TypeError(`setup code is wrong`);
}
const synapseHTTPAntispam = this.toggle?.synapseHTTPAntispam;
if (synapseHTTPAntispam === undefined) {
throw new TypeError("Setup code is wrong");
}
const promise = new Promise((resolve) => {
synapseHTTPAntispam.checkEventForSpamHandles.registerNonBlockingHandle(
(details) => {
if (details.event.sender === draupnir.clientUserID) {
resolve(undefined);
}
}
);
});
(
await draupnir.clientPlatform
.toRoomMessageSender()
.sendMessage(draupnir.managementRoomID, {
body: "hello",
msgtype: "m.text",
})
).expect("should be able to send the message just fine");
await promise;
// now try blocking
synapseHTTPAntispam.checkEventForSpamHandles.registerBlockingHandle(() => {
return Promise.resolve({ errcode: "M_FORBIDDEN", error: "no." });
});
const sendResult = await draupnir.clientPlatform
.toRoomMessageSender()
.sendMessage(draupnir.managementRoomID, {
body: "hello",
msgtype: "m.text",
});
if (isOk(sendResult)) {
throw new TypeError("We expect the result to be blocked");
}
if (!(sendResult.error instanceof ActionException)) {
throw new TypeError(
"We're trying to destructure this to get the MatrixError"
);
}
// I'm pretty sure there are different versions of this being used in the code base
// so instanceof fails :/ sucks balls mare
// https://github.com/the-draupnir-project/Draupnir/issues/760
// https://github.com/the-draupnir-project/Draupnir/issues/759
if (sendResult.error instanceof MatrixException) {
expect(sendResult.error.matrixErrorMessage).toBe("no.");
expect(sendResult.error.matrixErrorCode).toBe("M_FORBIDDEN");
} else {
const matrixError = sendResult.error.exception as MatrixError;
expect(matrixError.error).toBe("no.");
expect(matrixError.errcode).toBe("M_FORBIDDEN");
}
} as unknown as Mocha.AsyncFunc);
});