mirror of
https://github.com/matrix-org/mjolnir.git
synced 2024-10-01 01:36:06 -04:00
Intercept /report and display human-readable abuse reports in the moderation room - Resolves #38 (#135)
* Intercept /report and display human-readable abuse reports in the moderation room - Resolves #38
This commit is contained in:
parent
725d400650
commit
06e5f00b2d
30
README.md
30
README.md
@ -130,6 +130,36 @@ on your homeserver join. The antispam module will not join the rooms for you.
|
||||
If you change the configuration, you will need to restart Synapse. You'll also need
|
||||
to restart Synapse to install the plugin.
|
||||
|
||||
## Enabling readable abuse reports
|
||||
|
||||
Since version 1.2, Mjölnir offers the ability to replace the Matrix endpoint used
|
||||
to report abuse and display it into a room, instead of requiring you to request
|
||||
this data from an admin API.
|
||||
|
||||
This requires two configuration steps:
|
||||
|
||||
1. In your Mjölnir configuration file, typically `/etc/mjolnir/config/production.yaml`, copy and paste the `web` section from `default.yaml`, if you don't have it yet (it appears with version 1.20) and set `enabled: true` for both `web` and
|
||||
`abuseReporting`.
|
||||
2. Setup a reverse proxy that will redirect requests from `^/_matrix/client/r0/rooms/([^/]*)/report/(.*)$` to `http://host:port/api/1/report/$1/$2`, where `host` is the host where you run Mjölnir, and `port` is the port you configured in `production.yaml`. For an example nginx configuration, see `test/nginx.conf`. It's the confirmation we use during runtime testing.
|
||||
|
||||
### Security note
|
||||
|
||||
This mechanism can extract some information from **unencrypted** rooms. We have
|
||||
taken precautions to ensure that this cannot be abused: the only case in which
|
||||
this feature will publish information from room *foo* is:
|
||||
|
||||
1. If it is used by a member of room *foo*; AND
|
||||
2. If said member did witness the event; AND
|
||||
3. If the event was unencrypted; AND
|
||||
4. If the event was not redacted/removed/...
|
||||
|
||||
Essentially, this is a more restricted variant of the Admin APIs available on
|
||||
homeservers.
|
||||
|
||||
However, if you are uncomfortable with this, please do not activate this feature.
|
||||
Also, you should probably setup your `production.yaml` to ensure that the web
|
||||
server can only receive requests from your reverse proxy (e.g. `localhost`).
|
||||
|
||||
## Development
|
||||
|
||||
TODO. It's a TypeScript project with a linter.
|
||||
|
@ -2,6 +2,11 @@
|
||||
# pantalaimon if you're using that.
|
||||
homeserverUrl: "https://matrix.org"
|
||||
|
||||
|
||||
# Where the homeserver is located (client-server URL). NOT panalaimon.
|
||||
rawHomeserverUrl: "https://matrix.org"
|
||||
|
||||
|
||||
# The access token for the bot to use. Do not populate if using Pantalaimon.
|
||||
accessToken: "YOUR_TOKEN_HERE"
|
||||
|
||||
@ -160,3 +165,30 @@ health:
|
||||
# The HTTP status code which reports that the bot is not healthy/ready.
|
||||
# Defaults to 418.
|
||||
unhealthyStatus: 418
|
||||
|
||||
# Options for exposing web APIs.
|
||||
web:
|
||||
# Whether to enable web APIs.
|
||||
enabled: false
|
||||
|
||||
# The port to expose the webserver on. Defaults to 8080.
|
||||
port: 8080
|
||||
|
||||
# The address to listen for requests on. Defaults to only the current
|
||||
# computer.
|
||||
address: localhost
|
||||
|
||||
# Alternative setting to open to the entire web. Be careful,
|
||||
# as this will increase your security perimeter:
|
||||
#
|
||||
# address: "0.0.0.0"
|
||||
|
||||
# A web API designed to intercept Matrix API
|
||||
# POST /_matrix/client/r0/rooms/{roomId}/report/{eventId}
|
||||
# and display readable abuse reports in the moderation room.
|
||||
#
|
||||
# If you wish to take advantage of this feature, you will need
|
||||
# to configure a reverse proxy, see e.g. test/nginx.conf
|
||||
abuseReporting:
|
||||
# Whether to enable this feature.
|
||||
enabled: false
|
||||
|
@ -1,9 +1,13 @@
|
||||
# This configuration file is for the integration tests run by yarn:integration.
|
||||
# Editing this will do nothing and you shouldn't use it as a template.
|
||||
# For a template use default.yaml
|
||||
# Unless you're working on the test suite, you should probably rather check
|
||||
# default.yaml!
|
||||
|
||||
# Where the homeserver is located (client-server URL). This should point at
|
||||
# pantalaimon if you're using that.
|
||||
homeserverUrl: "http://localhost:9999"
|
||||
homeserverUrl: "http://localhost:8081"
|
||||
|
||||
# Where the homeserver is located (client-server URL). NOT pantalaimon.
|
||||
rawHomeserverUrl: "http://localhost:8081"
|
||||
|
||||
# Pantalaimon options (https://github.com/matrix-org/pantalaimon)
|
||||
pantalaimon:
|
||||
@ -159,3 +163,23 @@ health:
|
||||
# The HTTP status code which reports that the bot is not healthy/ready.
|
||||
# Defaults to 418.
|
||||
unhealthyStatus: 418
|
||||
|
||||
# Options for exposing web APIs.
|
||||
web:
|
||||
# Whether to enable web APIs.
|
||||
enabled: true
|
||||
|
||||
# The port to expose the webserver on. Defaults to 8080.
|
||||
port: 8082
|
||||
|
||||
# The address to listen for requests on. Defaults to all addresses.
|
||||
# Be careful with this setting, as opening to the wide web will increase
|
||||
# your security perimeter.
|
||||
address: localhost
|
||||
|
||||
# A web API designed to intercept Matrix API
|
||||
# POST /_matrix/client/r0/rooms/{roomId}/report/{eventId}
|
||||
# and display readable abuse reports in the moderation room.
|
||||
abuseReporting:
|
||||
# Whether to enable this feature.
|
||||
enabled: true
|
||||
|
@ -3,8 +3,14 @@ modules:
|
||||
- name: synapse_antispam
|
||||
build:
|
||||
- cp -r synapse_antispam $MX_TEST_SYNAPSE_DIR
|
||||
up:
|
||||
# Launch the reverse proxy, listening for connections *only* on the local host.
|
||||
- docker run --rm --network host --name mjolnir-test-reverse-proxy -p 127.0.0.1:8081:80 -v $MX_TEST_CWD/test/nginx.conf:/etc/nginx/nginx.conf:ro -d nginx
|
||||
run:
|
||||
- yarn test-integration
|
||||
- yarn test:integration
|
||||
down:
|
||||
finally:
|
||||
- docker stop mjolnir-test-reverse-proxy
|
||||
homeserver_config:
|
||||
server_name: localhost:9999
|
||||
pid_file: /data/homeserver.pid
|
||||
|
10
package.json
10
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mjolnir",
|
||||
"version": "1.1.20",
|
||||
"version": "1.2",
|
||||
"description": "A moderation tool for Matrix",
|
||||
"main": "lib/index.js",
|
||||
"repository": "git@github.com:matrix-org/mjolnir.git",
|
||||
@ -18,21 +18,25 @@
|
||||
"devDependencies": {
|
||||
"@types/axios": "^0.14.0",
|
||||
"@types/crypto-js": "^4.0.2",
|
||||
"@types/jsdom": "^16.2.11",
|
||||
"@types/mocha": "^9.0.0",
|
||||
"@types/node": "^16.7.10",
|
||||
"axios": "^0.21.4",
|
||||
"crypto-js": "^4.1.1",
|
||||
"eslint": "^7.32",
|
||||
"expect": "^27.0.6",
|
||||
"mocha": "^9.0.1",
|
||||
"ts-mocha": "^8.0.0",
|
||||
"ts-node": "^10.2.1",
|
||||
"tslint": "^6.1.3",
|
||||
"typescript": "^4.3.5"
|
||||
"typescript": "^4.3.5",
|
||||
"typescript-formatter": "^7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"config": "^3.3.6",
|
||||
"escape-html": "^1.0.3",
|
||||
"express": "^4.17",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsdom": "^16.6.0",
|
||||
"matrix-bot-sdk": "^0.5.19"
|
||||
}
|
||||
}
|
||||
|
@ -25,6 +25,7 @@ import {
|
||||
Permalinks,
|
||||
UserID
|
||||
} from "matrix-bot-sdk";
|
||||
|
||||
import BanList, { ALL_RULE_TYPES } from "./models/BanList";
|
||||
import { applyServerAcls } from "./actions/ApplyAcl";
|
||||
import { RoomUpdateError } from "./models/RoomUpdateError";
|
||||
@ -39,6 +40,7 @@ import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"
|
||||
import { Healthz } from "./health/healthz";
|
||||
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
|
||||
import * as htmlEscape from "escape-html";
|
||||
import { WebAPIs } from "./webapis/WebAPIs";
|
||||
|
||||
export const STATE_NOT_STARTED = "not_started";
|
||||
export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
|
||||
@ -70,7 +72,7 @@ export class Mjolnir {
|
||||
private protectedJoinedRoomIds: string[] = [];
|
||||
private explicitlyProtectedRoomIds: string[] = [];
|
||||
private knownUnprotectedRooms: string[] = [];
|
||||
|
||||
private webapis: WebAPIs;
|
||||
/**
|
||||
* Adds a listener to the client that will automatically accept invitations.
|
||||
* @param {MatrixClient} client
|
||||
@ -160,6 +162,8 @@ export class Mjolnir {
|
||||
this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase()));
|
||||
}
|
||||
|
||||
// Setup bot.
|
||||
|
||||
client.on("room.event", this.handleEvent.bind(this));
|
||||
|
||||
client.on("room.message", async (roomId, event) => {
|
||||
@ -214,6 +218,10 @@ export class Mjolnir {
|
||||
this.displayName = profile['displayname'];
|
||||
}
|
||||
});
|
||||
|
||||
// Setup Web APIs
|
||||
console.log("Creating Web APIs");
|
||||
this.webapis = new WebAPIs(this.client);
|
||||
}
|
||||
|
||||
public get lists(): BanList[] {
|
||||
@ -241,14 +249,25 @@ export class Mjolnir {
|
||||
return this.automaticRedactionReasons;
|
||||
}
|
||||
|
||||
public start() {
|
||||
return this.client.start().then(async () => {
|
||||
/**
|
||||
* Start Mjölnir.
|
||||
*/
|
||||
public async start() {
|
||||
try {
|
||||
// Start the bot.
|
||||
await this.client.start();
|
||||
|
||||
// Start the web server.
|
||||
console.log("Starting web server");
|
||||
await this.webapis.start();
|
||||
|
||||
// Load the state.
|
||||
this.currentState = STATE_CHECKING_PERMISSIONS;
|
||||
|
||||
await logMessage(LogLevel.DEBUG, "Mjolnir@startup", "Loading protected rooms...");
|
||||
await this.resyncJoinedRooms(false);
|
||||
try {
|
||||
const data: Object|null = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
|
||||
const data: Object | null = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
|
||||
if (data && data['rooms']) {
|
||||
for (const roomId of data['rooms']) {
|
||||
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
|
||||
@ -265,18 +284,18 @@ export class Mjolnir {
|
||||
await logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions...");
|
||||
await this.verifyPermissions(config.verboseLogging);
|
||||
}
|
||||
}).then(async () => {
|
||||
|
||||
this.currentState = STATE_SYNCING;
|
||||
if (config.syncOnStartup) {
|
||||
await logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists...");
|
||||
await this.syncLists(config.verboseLogging);
|
||||
await this.enableProtections();
|
||||
}
|
||||
}).then(async () => {
|
||||
|
||||
this.currentState = STATE_RUNNING;
|
||||
Healthz.isHealthy = true;
|
||||
await logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms.");
|
||||
}).catch(async err => {
|
||||
} catch (err) {
|
||||
try {
|
||||
LogService.error("Mjolnir", "Error during startup:");
|
||||
LogService.error("Mjolnir", extractRequestError(err));
|
||||
@ -286,7 +305,7 @@ export class Mjolnir {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -295,6 +314,7 @@ export class Mjolnir {
|
||||
public stop() {
|
||||
LogService.info("Mjolnir", "Stopping Mjolnir...");
|
||||
this.client.stop();
|
||||
this.webapis.stop();
|
||||
}
|
||||
|
||||
public async addProtectedRoom(roomId: string) {
|
||||
@ -310,7 +330,7 @@ export class Mjolnir {
|
||||
} catch (e) {
|
||||
LogService.warn("Mjolnir", extractRequestError(e));
|
||||
}
|
||||
if (!additionalProtectedRooms || !additionalProtectedRooms['rooms']) additionalProtectedRooms = {rooms: []};
|
||||
if (!additionalProtectedRooms || !additionalProtectedRooms['rooms']) additionalProtectedRooms = { rooms: [] };
|
||||
additionalProtectedRooms.rooms.push(roomId);
|
||||
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms);
|
||||
await this.syncLists(config.verboseLogging);
|
||||
@ -328,7 +348,7 @@ export class Mjolnir {
|
||||
} catch (e) {
|
||||
LogService.warn("Mjolnir", extractRequestError(e));
|
||||
}
|
||||
if (!additionalProtectedRooms || !additionalProtectedRooms['rooms']) additionalProtectedRooms = {rooms: []};
|
||||
if (!additionalProtectedRooms || !additionalProtectedRooms['rooms']) additionalProtectedRooms = { rooms: [] };
|
||||
additionalProtectedRooms.rooms = additionalProtectedRooms.rooms.filter(r => r !== roomId);
|
||||
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms);
|
||||
}
|
||||
@ -355,7 +375,7 @@ export class Mjolnir {
|
||||
private async getEnabledProtections() {
|
||||
let enabled: string[] = [];
|
||||
try {
|
||||
const protections: Object|null = await this.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE);
|
||||
const protections: Object | null = await this.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE);
|
||||
if (protections && protections['enabled']) {
|
||||
for (const protection of protections['enabled']) {
|
||||
enabled.push(protection);
|
||||
@ -387,7 +407,7 @@ export class Mjolnir {
|
||||
|
||||
if (persist) {
|
||||
const existing = this.protections.map(p => p.name);
|
||||
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, {enabled: existing});
|
||||
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: existing });
|
||||
}
|
||||
}
|
||||
|
||||
@ -396,10 +416,10 @@ export class Mjolnir {
|
||||
if (idx >= 0) this.protections.splice(idx, 1);
|
||||
|
||||
const existing = this.protections.map(p => p.name);
|
||||
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, {enabled: existing});
|
||||
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: existing });
|
||||
}
|
||||
|
||||
public async watchList(roomRef: string): Promise<BanList|null> {
|
||||
public async watchList(roomRef: string): Promise<BanList | null> {
|
||||
const joinedRooms = await this.client.getJoinedRooms();
|
||||
const permalink = Permalinks.parseUrl(roomRef);
|
||||
if (!permalink.roomIdOrAlias) return null;
|
||||
@ -424,7 +444,7 @@ export class Mjolnir {
|
||||
return list;
|
||||
}
|
||||
|
||||
public async unwatchList(roomRef: string): Promise<BanList|null> {
|
||||
public async unwatchList(roomRef: string): Promise<BanList | null> {
|
||||
const permalink = Permalinks.parseUrl(roomRef);
|
||||
if (!permalink.roomIdOrAlias) return null;
|
||||
|
||||
@ -450,14 +470,14 @@ export class Mjolnir {
|
||||
this.applyUnprotectedRooms();
|
||||
|
||||
try {
|
||||
const accountData: Object|null = await this.client.getAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId);
|
||||
const accountData: Object | null = await this.client.getAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId);
|
||||
if (accountData && accountData['warned']) return; // already warned
|
||||
} catch (e) {
|
||||
// Ignore - probably haven't warned about it yet
|
||||
}
|
||||
|
||||
await logMessage(LogLevel.WARN, "Mjolnir", `Not protecting ${roomId} - it is a ban list that this bot did not create. Add the room as protected if it is supposed to be protected. This warning will not appear again.`, roomId);
|
||||
await this.client.setAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId, {warned: true});
|
||||
await this.client.setAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId, { warned: true });
|
||||
}
|
||||
|
||||
private applyUnprotectedRooms() {
|
||||
@ -711,7 +731,7 @@ export class Mjolnir {
|
||||
}
|
||||
}
|
||||
|
||||
private async printActionResult(errors: RoomUpdateError[], title: string|null = null, logAnyways = false) {
|
||||
private async printActionResult(errors: RoomUpdateError[], title: string | null = null, logAnyways = false) {
|
||||
if (errors.length <= 0) return false;
|
||||
|
||||
if (!logAnyways) {
|
||||
|
@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@ -17,8 +17,17 @@ limitations under the License.
|
||||
import * as config from "config";
|
||||
import { MatrixClient } from "matrix-bot-sdk";
|
||||
|
||||
/**
|
||||
* The configuration, as read from production.yaml
|
||||
*
|
||||
* See file default.yaml for the documentation on individual options.
|
||||
*/
|
||||
// The object is magically generated by external lib `config`
|
||||
// from the file specified by `NODE_ENV`, e.g. production.yaml
|
||||
// or harness.yaml.
|
||||
interface IConfig {
|
||||
homeserverUrl: string;
|
||||
rawHomeserverUrl: string;
|
||||
accessToken: string;
|
||||
pantalaimon: {
|
||||
use: boolean;
|
||||
@ -60,6 +69,14 @@ interface IConfig {
|
||||
unhealthyStatus: number;
|
||||
};
|
||||
};
|
||||
web: {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
address: string;
|
||||
abuseReporting: {
|
||||
enabled: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Config options only set at runtime. Try to avoid using the objects
|
||||
@ -72,6 +89,7 @@ interface IConfig {
|
||||
|
||||
const defaultConfig: IConfig = {
|
||||
homeserverUrl: "http://localhost:8008",
|
||||
rawHomeserverUrl: "http://localhost:8008",
|
||||
accessToken: "NONE_PROVIDED",
|
||||
pantalaimon: {
|
||||
use: false,
|
||||
@ -113,6 +131,14 @@ const defaultConfig: IConfig = {
|
||||
unhealthyStatus: 418,
|
||||
},
|
||||
},
|
||||
web: {
|
||||
enabled: false,
|
||||
port: 8080,
|
||||
address: "localhost",
|
||||
abuseReporting: {
|
||||
enabled: false,
|
||||
}
|
||||
},
|
||||
|
||||
// Needed to make the interface happy.
|
||||
RUNTIME: {
|
||||
|
243
src/webapis/WebAPIs.ts
Normal file
243
src/webapis/WebAPIs.ts
Normal file
@ -0,0 +1,243 @@
|
||||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Server } from "http";
|
||||
|
||||
import * as express from "express";
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { MatrixClient } from "matrix-bot-sdk";
|
||||
|
||||
import config from "../config";
|
||||
|
||||
|
||||
/**
|
||||
* A common prefix for all web-exposed APIs.
|
||||
*/
|
||||
const API_PREFIX = "/api/1";
|
||||
|
||||
const AUTHORIZATION: RegExp = new RegExp("Bearer (.*)");
|
||||
|
||||
export class WebAPIs {
|
||||
private webController: express.Express = express();
|
||||
private httpServer?: Server;
|
||||
|
||||
constructor(private client: MatrixClient) {
|
||||
// Setup JSON parsing.
|
||||
this.webController.use(express.json());
|
||||
}
|
||||
|
||||
/**
|
||||
* Start accepting requests to the Web API.
|
||||
*/
|
||||
public async start() {
|
||||
if (!config.web.enabled) {
|
||||
return;
|
||||
}
|
||||
this.httpServer = this.webController.listen(config.web.port, config.web.address);
|
||||
|
||||
// Configure /report API.
|
||||
if (config.web.abuseReporting.enabled) {
|
||||
console.log(`Configuring ${API_PREFIX}/report/:room_id/:event_id...`);
|
||||
this.webController.post(`${API_PREFIX}/report/:room_id/:event_id`, async (request, response) => {
|
||||
console.debug(`Received a message on ${API_PREFIX}/report/:room_id/:event_id`, request.params);
|
||||
await this.handleReport({ request, response, roomId: request.params.room_id, eventId: request.params.event_id })
|
||||
});
|
||||
console.log(`Configuring ${API_PREFIX}/report/:room_id/:event_id... DONE`);
|
||||
}
|
||||
}
|
||||
|
||||
public stop() {
|
||||
if (this.httpServer) {
|
||||
console.log("Stopping WebAPIs.");
|
||||
this.httpServer.close();
|
||||
this.httpServer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a call to the /report API.
|
||||
*
|
||||
* In case of success, respond an empty JSON body.
|
||||
*
|
||||
* @param roomId The room in which the reported event took place. Already extracted from the URL.
|
||||
* @param eventId The event. Already extracted from the URL.
|
||||
* @param request The request. Its body SHOULD hold an object `{reason?: string}`
|
||||
* @param response The response. Used to propagate HTTP success/error.
|
||||
*/
|
||||
async handleReport({ roomId, eventId, request, response }: { roomId: string, eventId: string, request: express.Request, response: express.Response }) {
|
||||
// To display any kind of useful information, we need
|
||||
//
|
||||
// 1. The reporter id;
|
||||
// 2. The accused id, to be able to warn/kick/ban them if necessary;
|
||||
// 3. The content of the event **if the room is unencrypted**.
|
||||
|
||||
try {
|
||||
let reporterId;
|
||||
let event;
|
||||
{
|
||||
// -- Create a client on behalf of the reporter.
|
||||
// We'll use it to confirm the authenticity of the report.
|
||||
let accessToken;
|
||||
|
||||
// Authentication mechanism 1: Request header.
|
||||
let authorization = request.get('Authorization');
|
||||
|
||||
if (authorization) {
|
||||
[, accessToken] = AUTHORIZATION.exec(authorization)!;
|
||||
} else {
|
||||
// Authentication mechanism 2: Access token as query parameter.
|
||||
accessToken = request.query["access_token"];
|
||||
}
|
||||
|
||||
// Create a client dedicated to this report.
|
||||
//
|
||||
// VERY IMPORTANT NOTES
|
||||
//
|
||||
// We're impersonating the user to get the context of the report.
|
||||
//
|
||||
// For privacy's sake, we MUST ensure that:
|
||||
//
|
||||
// - we DO NOT sync with this client, as this would let us
|
||||
// snoop on messages other than the context of the report;
|
||||
// - we DO NOT associate a crypto store (e.g. Pantalaimon),
|
||||
// as this would let us read encrypted messages;
|
||||
// - this client is torn down as soon as possible to avoid
|
||||
// any case in which it could somehow be abused if a
|
||||
// malicious third-party gains access to Mjölnir.
|
||||
//
|
||||
// Rationales for using this mechanism:
|
||||
//
|
||||
// 1. This /report interception feature can only be setup by someone
|
||||
// who already controls the server. In other words, if they wish
|
||||
// to snoop on unencrypted messages, they can already do it more
|
||||
// easily at the level of the proxy.
|
||||
// 2. The `reporterClient` is used only to provide
|
||||
// - identity-checking; and
|
||||
// - features that are already available in the Synapse Admin API
|
||||
// (possibly in the Admin APIs of other homeservers, I haven't checked)
|
||||
// so we are not extending the abilities of Mjölnir
|
||||
// 3. We are avoiding the use of the Synapse Admin API to ensure that
|
||||
// this feature can work with all homeservers, not just Synapse.
|
||||
let reporterClient = new MatrixClient(config.rawHomeserverUrl, accessToken);
|
||||
reporterClient.start = () => {
|
||||
throw new Error("We MUST NEVER call start on the reporter client");
|
||||
};
|
||||
|
||||
reporterId = await reporterClient.getUserId();
|
||||
|
||||
/*
|
||||
Past this point, the following invariants hold:
|
||||
|
||||
- The report was sent by a Matrix user.
|
||||
- The identity of the Matrix user who sent the report is stored in `reporterId`.
|
||||
*/
|
||||
|
||||
// Now, let's gather more info on the event.
|
||||
// IMPORTANT: The following call will return the event without decyphering it, so we're
|
||||
// not obtaining anything that we couldn't also obtain through a homeserver's Admin API.
|
||||
//
|
||||
// By doing this with the reporterClient, we ensure that this feature of Mjölnir can work
|
||||
// with all Matrix homeservers, rather than just Synapse.
|
||||
event = await reporterClient.getEvent(roomId, eventId);
|
||||
}
|
||||
let accusedId: string = event["sender"];
|
||||
|
||||
/*
|
||||
Past this point, the following invariants hold:
|
||||
|
||||
- The reporter is a member of `roomId`.
|
||||
- Event `eventId` did take place in room `roomId`.
|
||||
- The reporter could witness event `eventId` in room `roomId`.
|
||||
- Event `eventId` was reported by user `accusedId`.
|
||||
*/
|
||||
|
||||
let { displayname: reporterDisplayName }: { displayname: string } = await this.client.getUserProfile(reporterId);
|
||||
let { displayname: accusedDisplayName }: { displayname: string } = await this.client.getUserProfile(accusedId);
|
||||
let roomAliasOrID = roomId;
|
||||
try {
|
||||
roomAliasOrID = await this.client.getPublishedAlias(roomId);
|
||||
} catch (ex) {
|
||||
// Ignore.
|
||||
}
|
||||
let eventShortcut = `https://matrix.to/#/${encodeURIComponent(roomId)}/${encodeURIComponent(eventId)}`;
|
||||
let roomShortcut = `https://matrix.to/#/${encodeURIComponent(roomAliasOrID)}`;
|
||||
let eventContent;
|
||||
if (event["type"] === "m.room.encrypted") {
|
||||
eventContent = "<encrypted content>";
|
||||
} else {
|
||||
eventContent = JSON.stringify(event["content"], null, 2);
|
||||
}
|
||||
|
||||
let reason = request.body["reason"];
|
||||
|
||||
// We now have all the information we need to produce an abuse report.
|
||||
|
||||
// We need to send the report as html to be able to use spoiler markings.
|
||||
// We build this as dom to be absolutely certain that we're not introducing
|
||||
// any kind of injection within the report.
|
||||
const document = new JSDOM(
|
||||
"<body>" +
|
||||
"User <code id='reporter-display-name'></code> (<code id='reporter-id'></code>) " +
|
||||
"reported <a id='event-shortcut'>event <span id='event-id'></span></a> " +
|
||||
"sent by user <b><span id='accused-display-name'></span> (<span id='accused-id'></span>)</b> " +
|
||||
"in <a id='room-shortcut'>room <span id='room-alias-or-id'></span></a>." +
|
||||
"<div>Event content <span id='event-container'><code id='event-content'></code><span></div>" +
|
||||
"<div>Reporter commented: <code id='reason-content'></code></div>" +
|
||||
"</body>")
|
||||
.window
|
||||
.document;
|
||||
// ...insert text content
|
||||
for (let [key, value] of [
|
||||
['reporter-display-name', reporterDisplayName],
|
||||
['reporter-id', reporterId],
|
||||
['accused-display-name', accusedDisplayName],
|
||||
['accused-id', accusedId],
|
||||
['event-id', eventId],
|
||||
['room-alias-or-id', roomAliasOrID],
|
||||
['event-content', eventContent],
|
||||
['reason-content', reason || "<no reason given>"]
|
||||
]) {
|
||||
document.getElementById(key)!.textContent = value;
|
||||
}
|
||||
// ...insert attributes
|
||||
for (let [key, value] of [
|
||||
['event-shortcut', eventShortcut],
|
||||
['room-shortcut', roomShortcut],
|
||||
]) {
|
||||
(document.getElementById(key)! as HTMLAnchorElement).href = value;
|
||||
}
|
||||
// ...set presentation
|
||||
if (event["type"] !== "m.room.encrypted") {
|
||||
// If there's some event content, mark it as a spoiler.
|
||||
document.getElementById('event-container')!.
|
||||
setAttribute("data-mx-spoiler", "");
|
||||
}
|
||||
|
||||
// Possible evolutions: in future versions, we could add the ability to one-click discard, kick, ban.
|
||||
|
||||
// Send the report and we're done!
|
||||
// We MUST send this report with the regular Mjölnir client.
|
||||
await this.client.sendHtmlNotice(config.managementRoom, document.body.outerHTML);
|
||||
|
||||
console.debug("Formatted abuse report sent");
|
||||
|
||||
// Match the spec behavior of `/report`: return 200 and an empty JSON.
|
||||
response.status(200).json({});
|
||||
} catch (ex) {
|
||||
console.warn("Error responding to an abuse report", roomId, eventId, ex);
|
||||
}
|
||||
}
|
||||
}
|
111
test/integration/abuseReportTest.ts
Normal file
111
test/integration/abuseReportTest.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { strict as assert } from "assert";
|
||||
|
||||
import config from "../../src/config";
|
||||
import { matrixClient, mjolnir } from "./mjolnirSetupUtils";
|
||||
import { newTestUser } from "./clientHelper";
|
||||
|
||||
/**
|
||||
* Test the ability to turn abuse reports into room messages.
|
||||
*/
|
||||
|
||||
describe("Test: Reporting abuse", async () => {
|
||||
it('Mjölnir intercepts abuse reports', async function() {
|
||||
this.timeout(10000);
|
||||
|
||||
// Listen for any notices that show up.
|
||||
let notices = [];
|
||||
matrixClient().on("room.event", (roomId, event) => {
|
||||
if (roomId = config.managementRoom) {
|
||||
notices.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
// Create a few users and a room.
|
||||
let goodUser = await newTestUser(false, "reporting-abuse-good-user");
|
||||
let badUser = await newTestUser(false, "reporting-abuse-bad-user");
|
||||
let goodUserId = await goodUser.getUserId();
|
||||
let badUserId = await badUser.getUserId();
|
||||
|
||||
let roomId = await goodUser.createRoom({ invite: [await badUser.getUserId()] });
|
||||
await goodUser.inviteUser(await badUser.getUserId(), roomId);
|
||||
await badUser.joinRoom(roomId);
|
||||
|
||||
console.log("Test: Reporting abuse - send messages");
|
||||
// Exchange a few messages.
|
||||
let goodText = `GOOD: ${Math.random()}`; // Will NOT be reported.
|
||||
let badText = `BAD: ${Math.random()}`; // Will be reported as abuse.
|
||||
let badText2 = `BAD: ${Math.random()}`; // Will be reported as abuse.
|
||||
let goodEventId = await goodUser.sendText(roomId, goodText);
|
||||
let badEventId = await badUser.sendText(roomId, badText);
|
||||
let badEventId2 = await badUser.sendText(roomId, badText2);
|
||||
let badEvent2Comment = `COMMENT: ${Math.random()}`;
|
||||
|
||||
console.log("Test: Reporting abuse - send reports");
|
||||
let reportsToFind = []
|
||||
|
||||
// Time to report, first without a comment, then with one.
|
||||
try {
|
||||
await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId)}`);
|
||||
reportsToFind.push({
|
||||
reporterId: goodUserId,
|
||||
accusedId: badUserId,
|
||||
eventId: badEventId,
|
||||
text: badText,
|
||||
comment: null,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Could not send first report", e.body || e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId2)}`, "", {
|
||||
reason: badEvent2Comment
|
||||
});
|
||||
reportsToFind.push({
|
||||
reporterId: goodUserId,
|
||||
accusedId: badUserId,
|
||||
eventId: badEventId2,
|
||||
text: badText2,
|
||||
comment: badEvent2Comment,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Could not send second report", e.body || e);
|
||||
throw e;
|
||||
}
|
||||
// FIXME: Also test with embedded HTML.
|
||||
|
||||
console.log("Test: Reporting abuse - wait");
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
let found = [];
|
||||
let regexp = /^User ([^ ]*) \(([^ ]*)\) reported event ([^ ]*).*sent by user ([^ ]*) \(([^ ]*)\).*\n.*\nReporter commented: (.*)/m;
|
||||
for (let toFind of reportsToFind) {
|
||||
for (let event of notices) {
|
||||
if ("content" in event && "body" in event.content) {
|
||||
let body = event.content.body as string;
|
||||
let match = body.match(regexp);
|
||||
if (!match) {
|
||||
// Not a report, skipping.
|
||||
continue;
|
||||
}
|
||||
let [, reporterDisplay, reporterId, eventId, accusedDisplay, accusedId, reason] = match;
|
||||
if (eventId != toFind.eventId) {
|
||||
// Different event id, skipping.
|
||||
continue;
|
||||
}
|
||||
assert.equal(reporterId, toFind.reporterId, "The report should specify the correct reporter");
|
||||
assert.ok(toFind.reporterId.includes(reporterDisplay), "The report should display the correct reporter");
|
||||
assert.equal(accusedId, toFind.accusedId, "The report should specify the correct accused");
|
||||
assert.ok(toFind.accusedId.includes(accusedDisplay), "The report should display the correct reporter");
|
||||
assert.ok(body.includes(toFind.text), "The report should contain the text we inserted in the event");
|
||||
if (toFind.comment) {
|
||||
assert.equal(reason, toFind.comment, "The report should contain the comment we added");
|
||||
}
|
||||
found.push(toFind);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(reportsToFind, found);
|
||||
})
|
||||
});
|
@ -7,6 +7,7 @@ import config from "../../src/config";
|
||||
* Register a user using the synapse admin api that requires the use of a registration secret rather than an admin user.
|
||||
* This should only be used by test code and should not be included from any file in the source directory
|
||||
* either by explicit imports or copy pasting.
|
||||
*
|
||||
* @param username The username to give the user.
|
||||
* @param displayname The displayname to give the user.
|
||||
* @param password The password to use.
|
||||
@ -30,14 +31,19 @@ export async function registerUser(username: string, displayname: string, passwo
|
||||
|
||||
/**
|
||||
* Register a new test user with a unique username.
|
||||
*
|
||||
* @param isAdmin Whether to make the new user an admin.
|
||||
* @param label If specified, a string to place somewhere within the username.
|
||||
* @returns A string that is the username and password of a new user.
|
||||
*/
|
||||
export async function registerNewTestUser(isAdmin: boolean) {
|
||||
export async function registerNewTestUser(isAdmin: boolean, label: string = "") {
|
||||
let isUserValid = false;
|
||||
let username;
|
||||
if (label != "") {
|
||||
label += "-";
|
||||
}
|
||||
do {
|
||||
username = `mjolnir-test-user-${Math.floor(Math.random() * 100000)}`
|
||||
username = `mjolnir-test-user-${label}${Math.floor(Math.random() * 100000)}`
|
||||
await registerUser(username, username, username, isAdmin).then(_ => isUserValid = true).catch(e => {
|
||||
if (e.isAxiosError && e?.response?.data?.errcode === 'M_USER_IN_USE') {
|
||||
LogService.debug("test/clientHelper", `${username} already registered, trying another`);
|
||||
@ -53,11 +59,13 @@ export async function registerNewTestUser(isAdmin: boolean) {
|
||||
|
||||
/**
|
||||
* Registers a unique test user and returns a `MatrixClient` logged in and ready to use.
|
||||
*
|
||||
* @param isAdmin Whether to make the user an admin.
|
||||
* @param label If specified, a string to place somewhere within the username.
|
||||
* @returns A new `MatrixClient` session for a unique test user.
|
||||
*/
|
||||
export async function newTestUser(isAdmin : boolean = false): Promise<MatrixClient> {
|
||||
const username = await registerNewTestUser(isAdmin);
|
||||
export async function newTestUser(isAdmin: boolean = false, label: string = ""): Promise<MatrixClient> {
|
||||
const username = await registerNewTestUser(isAdmin, label);
|
||||
const pantalaimon = new PantalaimonClient(config.homeserverUrl, new MemoryStorageProvider());
|
||||
return await pantalaimon.createClientWithCredentials(username, username);
|
||||
}
|
||||
|
@ -9,9 +9,11 @@ import { makeMjolnir, teardownManagementRoom } from "./mjolnirSetupUtils";
|
||||
export const mochaHooks = {
|
||||
beforeEach: [
|
||||
async function() {
|
||||
this.managementRoomAlias = config.managementRoom
|
||||
this.mjolnir = await makeMjolnir()
|
||||
this.mjolnir.start()
|
||||
console.log("mochaHooks.beforeEach");
|
||||
this.managementRoomAlias = config.managementRoom;
|
||||
this.mjolnir = await makeMjolnir();
|
||||
this.mjolnir.start();
|
||||
console.log("mochaHooks.beforeEach DONE");
|
||||
}
|
||||
],
|
||||
afterEach: [
|
||||
|
@ -1,7 +1,7 @@
|
||||
import config from "../../src/config";
|
||||
import { newTestUser, noticeListener } from "./clientHelper"
|
||||
|
||||
describe("help command", () => {
|
||||
describe("Test: !help command", () => {
|
||||
let client;
|
||||
before(async function () {
|
||||
client = await newTestUser(true);
|
||||
|
@ -45,17 +45,33 @@ export async function ensureAliasedRoomExists(client: MatrixClient, alias: strin
|
||||
}
|
||||
|
||||
async function configureMjolnir() {
|
||||
await registerUser('mjolnir', 'mjolnir', 'mjolnir', true).catch(e => {
|
||||
if (e.isAxiosError && e.response.data.errcode === 'M_USER_IN_USE') {
|
||||
console.log('mjolnir already registered, skipping');
|
||||
} else {
|
||||
throw e;
|
||||
try {
|
||||
await registerUser('mjolnir', 'mjolnir', 'mjolnir', true)
|
||||
} catch (e) {
|
||||
if (e.isAxiosError) {
|
||||
console.log('Received error while registering', e);
|
||||
if (e.response.data && e.response.data.errcode === 'M_USER_IN_USE') {
|
||||
console.log('mjolnir already registered, skipping');
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
throw e;
|
||||
};
|
||||
}
|
||||
|
||||
export function mjolnir(): Mjolnir | null {
|
||||
return globalMjolnir;
|
||||
}
|
||||
export function matrixClient(): MatrixClient | null {
|
||||
return globalClient;
|
||||
}
|
||||
let globalClient: MatrixClient | null
|
||||
let globalMjolnir: Mjolnir | null;
|
||||
|
||||
export async function makeMjolnir() {
|
||||
/**
|
||||
* Return a test instance of Mjolnir.
|
||||
*/
|
||||
export async function makeMjolnir(): Promise<Mjolnir> {
|
||||
await configureMjolnir();
|
||||
LogService.setLogger(new RichConsoleLogger());
|
||||
LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG));
|
||||
@ -63,7 +79,10 @@ export async function makeMjolnir() {
|
||||
const pantalaimon = new PantalaimonClient(config.homeserverUrl, new MemoryStorageProvider());
|
||||
const client = await pantalaimon.createClientWithCredentials(config.pantalaimon.username, config.pantalaimon.password);
|
||||
await ensureAliasedRoomExists(client, config.managementRoom);
|
||||
return await Mjolnir.setupMjolnirFromConfig(client);
|
||||
let mjolnir = await Mjolnir.setupMjolnirFromConfig(client);
|
||||
globalClient = client;
|
||||
globalMjolnir = mjolnir;
|
||||
return mjolnir;
|
||||
}
|
||||
|
||||
/**
|
||||
|
31
test/nginx.conf
Normal file
31
test/nginx.conf
Normal file
@ -0,0 +1,31 @@
|
||||
events {
|
||||
|
||||
}
|
||||
|
||||
http {
|
||||
server {
|
||||
listen 8081;
|
||||
server_name localhost;
|
||||
|
||||
location ~ ^/_matrix/client/r0/rooms/([^/]*)/report/(.*)$ {
|
||||
# Abuse reports should be sent to Mjölnir.
|
||||
|
||||
# Add CORS, otherwise a browser will refuse this request.
|
||||
add_header 'Access-Control-Allow-Origin' '*' always; # Note: '*' is for testing purposes. For your own server, you probably want to tighten this.
|
||||
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'Authorization,Content-Type,Accept,Origin,User-Agent,DNT,Cache-Control,X-Mx-ReqToken,Keep-Alive,X-Requested-With,If-Modified-Since' always;
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||
add_header 'Access-Control-Max-Age' 1728000; # cache preflight value for 20 days
|
||||
|
||||
# Alias the regexps, to ensure that they're not rewritten.
|
||||
set $room_id $1;
|
||||
set $event_id $2;
|
||||
proxy_pass http://127.0.0.1:8082/api/1/report/$room_id/$event_id;
|
||||
}
|
||||
location / {
|
||||
# Everything else should be sent to Synapse.
|
||||
proxy_pass http://localhost:9999;
|
||||
}
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@
|
||||
"emitDecoratorMetadata": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"newLine": "LF",
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"target": "es2015",
|
||||
|
Loading…
Reference in New Issue
Block a user