diff --git a/README.md b/README.md index b12b06c..d382cb0 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ 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. +2. Setup a reverse proxy that will redirect requests from `^/_matrix/client/(r0|v3)/rooms/([^/]*)/report/(.*)$` to `http://host:port/api/1/report/$2/$3`, 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 diff --git a/test/integration/abuseReportTest.ts b/test/integration/abuseReportTest.ts index 2bbb9bb..9484a83 100644 --- a/test/integration/abuseReportTest.ts +++ b/test/integration/abuseReportTest.ts @@ -1,6 +1,5 @@ import { strict as assert } from "assert"; -import config from "../../src/config"; import { matrixClient } from "./mjolnirSetupUtils"; import { newTestUser } from "./clientHelper"; import { ReportManager, ABUSE_ACTION_CONFIRMATION_KEY, ABUSE_REPORT_KEY } from "../../src/report/ReportManager"; @@ -20,207 +19,214 @@ const REPORT_NOTICE_REGEXPS = { describe("Test: Reporting abuse", async () => { - it('Mjölnir intercepts abuse reports', async function() { - this.timeout(60000); + // Testing with successive versions of the API. + // + // As of this writing, v3 is the standard, while r0 is deprecated. However, + // both versions are still in use in the wild. + // Note that this version change only affects the actual URL at which reports + // are sent. + for (let endpoint of ['v3', 'r0']) { + it(`Mjölnir intercepts abuse reports with endpoint ${endpoint}`, async function() { + this.timeout(90000); - // Listen for any notices that show up. - let notices: any[] = []; - matrixClient()!.on("room.event", (roomId, event) => { - if (roomId = this.mjolnir.managementRoomId) { - notices.push(event); + // Listen for any notices that show up. + let notices: any[] = []; + this.mjolnir.client.on("room.event", (roomId, event) => { + if (roomId = this.mjolnir.managementRoomId) { + notices.push(event); + } + }); + + // Create a few users and a room. + let goodUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-good-user" }}); + let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "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 badText3 = `BAD: ${Math.random()}`; // Will be reported as abuse. + let badText4 = [...Array(1024)].map(_ => `${Math.random()}`).join(""); // Text is too long. + let badText5 = [...Array(1024)].map(_ => "ABC").join("\n"); // Text has too many lines. + let badEventId = await badUser.sendText(roomId, badText); + let badEventId2 = await badUser.sendText(roomId, badText2); + let badEventId3 = await badUser.sendText(roomId, badText3); + let badEventId4 = await badUser.sendText(roomId, badText4); + let badEventId5 = await badUser.sendText(roomId, badText5); + let badEvent2Comment = `COMMENT: ${Math.random()}`; + + console.log("Test: Reporting abuse - send reports"); + let reportsToFind: any[] = [] + + // Time to report, first without a comment, then with one. + try { + await goodUser.doRequest("POST", `/_matrix/client/${endpoint}/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/${endpoint}/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; + } + + try { + await goodUser.doRequest("POST", `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId3)}`, ""); + reportsToFind.push({ + reporterId: goodUserId, + accusedId: badUserId, + eventId: badEventId3, + text: badText3, + comment: null, + }); + } catch (e) { + console.error("Could not send third report", e.body || e); + throw e; + } + + try { + await goodUser.doRequest("POST", `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId4)}`, ""); + reportsToFind.push({ + reporterId: goodUserId, + accusedId: badUserId, + eventId: badEventId4, + text: null, + textPrefix: badText4.substring(0, 256), + comment: null, + }); + } catch (e) { + console.error("Could not send fourth report", e.body || e); + throw e; + } + + try { + await goodUser.doRequest("POST", `/_matrix/client/${endpoint}/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId5)}`, ""); + reportsToFind.push({ + reporterId: goodUserId, + accusedId: badUserId, + eventId: badEventId5, + text: null, + textPrefix: badText5.substring(0, 256).split("\n").join(" "), + comment: null, + }); + } catch (e) { + console.error("Could not send fifth report", e.body || e); + throw e; + } + + console.log("Test: Reporting abuse - wait"); + await new Promise(resolve => setTimeout(resolve, 1000)); + let found: any[] = []; + for (let toFind of reportsToFind) { + for (let event of notices) { + if ("content" in event && "body" in event.content) { + if (!(ABUSE_REPORT_KEY in event.content) || event.content[ABUSE_REPORT_KEY].event_id != toFind.eventId) { + // Not a report or not our report. + continue; + } + let report = event.content[ABUSE_REPORT_KEY]; + let body = event.content.body as string; + let matches: Map | null = new Map(); + for (let key of Object.keys(REPORT_NOTICE_REGEXPS)) { + let match = body.match(REPORT_NOTICE_REGEXPS[key]); + if (match) { + console.debug("We have a match", key, REPORT_NOTICE_REGEXPS[key], match.groups); + } else { + console.debug("Not a match", key, REPORT_NOTICE_REGEXPS[key]); + // Not a report, skipping. + matches = null; + break; + } + matches.set(key, match); + } + if (!matches) { + // Not a report, skipping. + continue; + } + + assert(body.length < 3000, `The report shouldn't be too long ${body.length}`); + assert(body.split("\n").length < 200, "The report shouldn't have too many newlines."); + + assert.equal(matches.get("event")!.groups!.eventId, toFind.eventId, "The report should specify the correct event id");; + + assert.equal(matches.get("reporter")!.groups!.reporterId, toFind.reporterId, "The report should specify the correct reporter"); + assert.equal(report.reporter_id, toFind.reporterId, "The embedded report should specify the correct reporter"); + assert.ok(toFind.reporterId.includes(matches.get("reporter")!.groups!.reporterDisplay), "The report should display the correct reporter"); + + assert.equal(matches.get("accused")!.groups!.accusedId, toFind.accusedId, "The report should specify the correct accused"); + assert.equal(report.accused_id, toFind.accusedId, "The embedded report should specify the correct accused"); + assert.ok(toFind.accusedId.includes(matches.get("accused")!.groups!.accusedDisplay), "The report should display the correct reporter"); + + if (toFind.text) { + assert.equal(matches.get("content")!.groups!.eventContent, toFind.text, "The report should contain the text we inserted in the event"); + } + if (toFind.textPrefix) { + assert.ok(matches.get("content")!.groups!.eventContent.startsWith(toFind.textPrefix), `The report should contain a prefix of the long text we inserted in the event: ${toFind.textPrefix} in? ${matches.get("content")!.groups!.eventContent}`); + } + if (toFind.comment) { + assert.equal(matches.get("comments")!.groups!.comments, toFind.comment, "The report should contain the comment we added"); + } + assert.equal(matches.get("room")!.groups!.roomAliasOrId, roomId, "The report should specify the correct room"); + assert.equal(report.room_id, roomId, "The embedded report should specify the correct room"); + found.push(toFind); + break; + } + } + } + assert.deepEqual(found, reportsToFind); + + // Since Mjölnir is not a member of the room, the only buttons we should find + // are `help` and `ignore`. + for (let event of notices) { + if (event.content && event.content["m.relates_to"] && event.content["m.relates_to"]["key"]) { + let regexp = /\/([[^]]*)\]/; + let matches = event.content["m.relates_to"]["key"].match(regexp); + if (!matches) { + continue; + } + switch (matches[1]) { + case "bad-report": + case "help": + continue; + default: + throw new Error(`Didn't expect label ${matches[1]}`); + } + } } }); - - // Create a few users and a room. - let goodUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-good-user" }}); - let badUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "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 badText3 = `BAD: ${Math.random()}`; // Will be reported as abuse. - let badText4 = [...Array(1024)].map(_ => `${Math.random()}`).join(""); // Text is too long. - let badText5 = [...Array(1024)].map(_ => "ABC").join("\n"); // Text has too many lines. - let goodEventId = await goodUser.sendText(roomId, goodText); - let badEventId = await badUser.sendText(roomId, badText); - let badEventId2 = await badUser.sendText(roomId, badText2); - let badEventId3 = await badUser.sendText(roomId, badText3); - let badEventId4 = await badUser.sendText(roomId, badText4); - let badEventId5 = await badUser.sendText(roomId, badText5); - 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; - } - - try { - await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId3)}`, ""); - reportsToFind.push({ - reporterId: goodUserId, - accusedId: badUserId, - eventId: badEventId3, - text: badText3, - comment: null, - }); - } catch (e) { - console.error("Could not send third report", e.body || e); - throw e; - } - - try { - await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId4)}`, ""); - reportsToFind.push({ - reporterId: goodUserId, - accusedId: badUserId, - eventId: badEventId4, - text: null, - textPrefix: badText4.substring(0, 256), - comment: null, - }); - } catch (e) { - console.error("Could not send fourth report", e.body || e); - throw e; - } - - try { - await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId5)}`, ""); - reportsToFind.push({ - reporterId: goodUserId, - accusedId: badUserId, - eventId: badEventId5, - text: null, - textPrefix: badText5.substring(0, 256).split("\n").join(" "), - comment: null, - }); - } catch (e) { - console.error("Could not send fifth report", e.body || e); - throw e; - } - - console.log("Test: Reporting abuse - wait"); - await new Promise(resolve => setTimeout(resolve, 1000)); - let found = []; - for (let toFind of reportsToFind) { - for (let event of notices) { - if ("content" in event && "body" in event.content) { - if (!(ABUSE_REPORT_KEY in event.content) || event.content[ABUSE_REPORT_KEY].event_id != toFind.eventId) { - // Not a report or not our report. - continue; - } - let report = event.content[ABUSE_REPORT_KEY]; - let body = event.content.body as string; - let matches = new Map(); - for (let key of Object.keys(REPORT_NOTICE_REGEXPS)) { - let match = body.match(REPORT_NOTICE_REGEXPS[key]); - if (match) { - console.debug("We have a match", key, REPORT_NOTICE_REGEXPS[key], match.groups); - } else { - console.debug("Not a match", key, REPORT_NOTICE_REGEXPS[key]); - // Not a report, skipping. - matches = null; - break; - } - matches.set(key, match); - } - if (!matches) { - // Not a report, skipping. - continue; - } - - assert(body.length < 3000, `The report shouldn't be too long ${body.length}`); - assert(body.split("\n").length < 200, "The report shouldn't have too many newlines."); - - assert.equal(matches.get("event")!.groups.eventId, toFind.eventId, "The report should specify the correct event id");; - - assert.equal(matches.get("reporter")!.groups.reporterId, toFind.reporterId, "The report should specify the correct reporter"); - assert.equal(report.reporter_id, toFind.reporterId, "The embedded report should specify the correct reporter"); - assert.ok(toFind.reporterId.includes(matches.get("reporter")!.groups.reporterDisplay), "The report should display the correct reporter"); - - assert.equal(matches.get("accused")!.groups.accusedId, toFind.accusedId, "The report should specify the correct accused"); - assert.equal(report.accused_id, toFind.accusedId, "The embedded report should specify the correct accused"); - assert.ok(toFind.accusedId.includes(matches.get("accused")!.groups.accusedDisplay), "The report should display the correct reporter"); - - if (toFind.text) { - assert.equal(matches.get("content")!.groups.eventContent, toFind.text, "The report should contain the text we inserted in the event"); - } - if (toFind.textPrefix) { - assert.ok(matches.get("content")!.groups.eventContent.startsWith(toFind.textPrefix), `The report should contain a prefix of the long text we inserted in the event: ${toFind.textPrefix} in? ${matches.get("content")!.groups.eventContent}`); - } - if (toFind.comment) { - assert.equal(matches.get("comments")!.groups.comments, toFind.comment, "The report should contain the comment we added"); - } - assert.equal(matches.get("room")!.groups.roomAliasOrId, roomId, "The report should specify the correct room"); - assert.equal(report.room_id, roomId, "The embedded report should specify the correct room"); - found.push(toFind); - break; - } - } - } - assert.deepEqual(found, reportsToFind); - - // Since Mjölnir is not a member of the room, the only buttons we should find - // are `help` and `ignore`. - for (let event of notices) { - if (event.content && event.content["m.relates_to"] && event.content["m.relates_to"]["key"]) { - let regexp = /\/([[^]]*)\]/; - let matches = event.content["m.relates_to"]["key"].match(regexp); - if (!matches) { - continue; - } - switch (matches[1]) { - case "bad-report": - case "help": - continue; - default: - throw new Error(`Didn't expect label ${matches[1]}`); - } - } - } - }); + } it('The redact action works', async function() { this.timeout(60000); // Listen for any notices that show up. - let notices = []; - matrixClient().on("room.event", (roomId, event) => { + let notices: any[] = []; + this.mjolnir.client.on("room.event", (roomId, event) => { if (roomId = this.mjolnir.managementRoomId) { notices.push(event); } @@ -228,7 +234,7 @@ describe("Test: Reporting abuse", async () => { // Create a moderator. let moderatorUser = await newTestUser(this.config.homeserverUrl, { name: { contains: "reporting-abuse-moderator-user" }}); - matrixClient().inviteUser(await moderatorUser.getUserId(), this.mjolnir.managementRoomId); + this.mjolnir.client.inviteUser(await moderatorUser.getUserId(), this.mjolnir.managementRoomId); await moderatorUser.joinRoom(this.mjolnir.managementRoomId); // Create a few users and a room. @@ -244,8 +250,8 @@ describe("Test: Reporting abuse", async () => { await goodUser.joinRoom(roomId); // Setup Mjölnir as moderator for our room. - await moderatorUser.inviteUser(await matrixClient().getUserId(), roomId); - await moderatorUser.setUserPowerLevel(await matrixClient().getUserId(), roomId, 100); + await moderatorUser.inviteUser(await this.mjolnir.client.getUserId(), roomId); + await moderatorUser.setUserPowerLevel(await this.mjolnir.client.getUserId(), roomId, 100); console.log("Test: Reporting abuse - send messages"); // Exchange a few messages. @@ -275,7 +281,7 @@ describe("Test: Reporting abuse", async () => { console.log("Test: Reporting abuse - wait"); await new Promise(resolve => setTimeout(resolve, 1000)); - let mjolnirRooms = new Set(await matrixClient().getJoinedRooms()); + let mjolnirRooms = new Set(await this.mjolnir.client.getJoinedRooms()); assert.ok(mjolnirRooms.has(roomId), "Mjölnir should be a member of the room"); // Find the notice @@ -293,7 +299,7 @@ describe("Test: Reporting abuse", async () => { assert.ok(noticeId, "We should have found our notice"); // Find the buttons. - let buttons = []; + let buttons: any[] = []; for (let event of notices) { if (event["type"] != "m.reaction") { continue; @@ -347,7 +353,7 @@ describe("Test: Reporting abuse", async () => { await new Promise(resolve => setTimeout(resolve, 1000)); // This should have redacted the message. - let newBadEvent = await matrixClient().getEvent(roomId, badEventId); + let newBadEvent = await this.mjolnir.client.getEvent(roomId, badEventId); assert.deepEqual(Object.keys(newBadEvent.content), [], "Redaction should have removed the content of the offending event"); }); }); diff --git a/test/integration/fixtures.ts b/test/integration/fixtures.ts index d1975af..d19c0ac 100644 --- a/test/integration/fixtures.ts +++ b/test/integration/fixtures.ts @@ -12,7 +12,7 @@ export const mochaHooks = { console.error("---- entering test", JSON.stringify(this.currentTest.title)); // Makes MatrixClient error logs a bit easier to parse. console.log("mochaHooks.beforeEach"); // Sometimes it takes a little longer to register users. - this.timeout(10000); + this.timeout(30000); const config = this.config = configRead(); this.managementRoomAlias = config.managementRoom; this.mjolnir = await makeMjolnir(config); diff --git a/test/nginx.conf b/test/nginx.conf index 9b73091..c4add6a 100644 --- a/test/nginx.conf +++ b/test/nginx.conf @@ -6,8 +6,10 @@ http { server { listen 8081; - location ~ ^/_matrix/client/r0/rooms/([^/]*)/report/(.*)$ { + location ~ ^/_matrix/client/(r0|v3)/rooms/([^/]*)/report/(.*)$ { # Abuse reports should be sent to Mjölnir. + # The r0 endpoint is deprecated but still used by many clients. + # As of this writing, the v3 endpoint is the up-to-date version. # 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. @@ -18,8 +20,8 @@ http { 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; + set $room_id $2; + set $event_id $3; proxy_pass http://127.0.0.1:8082/api/1/report/$room_id/$event_id; } location / {