Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 249 additions & 0 deletions src/components/ban-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@


import { Intent, MatrixUser, WeakStateEvent, Logger, MatrixHostResolver } from "..";
import { MatrixGlob } from "matrix-bot-sdk";
import axios from "axios";

const log = new Logger("MatrixBanSync");

const CACHE_HOMESERVER_PROPERTIES_FOR_MS = 1000 * 60 * 30; // 30 minutes

export interface MatrixBanSyncConfig {
rooms?: string[];
blockOpenRegistration?: {
allowUnknown?: boolean;
};
}

enum BanEntityType {
Server = "m.policy.rule.server",
User = "m.policy.rule.user"
}

interface BanEntity {
matcher: MatrixGlob;
entityType: BanEntityType;
reason: string;
}

interface MPolicyContent {
entity: string;
reason: string;
recommendation: "m.ban";
}

function eventTypeToBanEntityType(eventType: string): BanEntityType|null {
switch (eventType) {
case "m.policy.rule.user":
case "org.matrix.mjolnir.rule.user":
return BanEntityType.User;
case "m.policy.rule.server":
case "org.matrix.mjolnir.rule.server":
return BanEntityType.Server
default:
return null;
}
}

const supportedRecommendations = [
"org.matrix.mjolnir.ban", // Used historically.
"m.ban"
];

interface MatrixRegistrationResponse {
flows: {
stages: string[],
}[],
}

enum RegistrationStatus {
Unknown = 0,
Open = 1,
ProtectedEmail = 2,
ProtectedCaptcha = 3,
Closed = 4,
}

const AuthTypeRecaptcha = 'm.login.recaptcha';
const AuthTypeEmail = 'm.login.email.identity';

/**
* Synchronises Matrix `m.policy.rule` events with the bridge to filter specific
* users from using the service.
*/
export class MatrixBanSync {
private readonly homeserverPropertiesCache = new Map<string, {openRegistration: RegistrationStatus; ts: number;}>();
private readonly bannedEntites = new Map<string, BanEntity>();
private readonly subscribedRooms = new Set<string>();
private readonly hostResolver = new MatrixHostResolver();
constructor(private config: MatrixBanSyncConfig) {

}

public async getHomeserverProperties(serverName: string) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could use jsdoc and return type.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added.

const hsData = this.homeserverPropertiesCache.get(serverName);
// Slightly fuzz the ttl.
const ttl = CACHE_HOMESERVER_PROPERTIES_FOR_MS + (Math.random()*60000);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is missing a Date.now()?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

if (hsData && hsData.ts < ttl) {
return hsData;
}

const { url } = await this.hostResolver.resolveMatrixServer(serverName);
const registrationResponse = await axios.post(new URL('/_matrix/client/v3/register', url).toString(), { }, { });

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about older homeservers? If old homeservers only expose /_matrix/client/r0/register, will the bridge allow them to stay despite having open reg?

Copy link
Contributor Author

@Half-Shot Half-Shot Apr 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point, we'll include those.


let openReg = RegistrationStatus.Unknown;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would help readability to put this all this logic in a separate method which only handles returning a RegistrationStatus. Also that way it could return instead of mutating openReg.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.


if (registrationResponse.status === 403 && registrationResponse.data.errcode === 'M_FORBIDDEN') {
// Explicitly forbidden private server -> great!
openReg = RegistrationStatus.Closed;
}

if (registrationResponse.status === 404) {
// Endpoint is not connected, probably also great!
openReg = RegistrationStatus.Closed;
}

if (registrationResponse.status === 401) {
// Look at the flows.
const { flows } = registrationResponse.data as MatrixRegistrationResponse;
if (!flows) {
// Invalid response
openReg = RegistrationStatus.Unknown;
}
else if (flows.length === 0) {
// No available flows, so closed.
openReg = RegistrationStatus.Closed;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the case of GNOME, I tried

>>> r = requests.post("https://gnome.modular.im/_matrix/client/v3/register", json={})
>>> r.json()
{'session': 'lpztxwMUAyTzgzdLHWhvwGtm', 'flows': [{'stages': ['m.login.recaptcha', 'm.login.terms', 'm.login.email.identity']}], 'params': {'m.login.recaptcha': {'public_key': '6LcgI54UAAAAABGdGmruw6DdOocFpYVdjYBRe4zb'}, 'm.login.terms': {'policies': {'privacy_policy': {'version': '1.0', 'en': {'name': 'Privacy Policy', 'url': 'https://gnome.modular.im/_matrix/consent?v=1.0'}}}}}}
>>> r.json()["flows"]
[{'stages': ['m.login.recaptcha', 'm.login.terms', 'm.login.email.identity']}]

It looks like the HS has open registrations, but in practice it's restricted to [email protected] email addresses. Is there a way to detect that remotely?

else {
// Check the flows
for (const flow of flows) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding this loop a bit hard to follow, I think it could be more straightforward with a .map of flow stages to RegistrationStatus and then taking the minimum?

Just a suggestion feel free to ignore :)

// A flow with recaptcha
if (openReg > RegistrationStatus.ProtectedCaptcha && flow.stages.includes(AuthTypeRecaptcha)) {
openReg = RegistrationStatus.ProtectedCaptcha;
}
// A flow without any recaptcha stages
if (openReg > RegistrationStatus.ProtectedEmail &&
flow.stages.includes(AuthTypeEmail) && !flow.stages.includes(AuthTypeRecaptcha)) {
openReg = RegistrationStatus.ProtectedEmail;
}
// A flow without any email or recaptcha stages
if (openReg > RegistrationStatus.Open &&
!flow.stages.includes(AuthTypeEmail) && !flow.stages.includes(AuthTypeRecaptcha)) {
openReg = RegistrationStatus.Open;
// Already as bad as it gets
break;
}
}
}
}

const hsProps = {
openRegistration: openReg,
ts: Date.now(),
};
this.homeserverPropertiesCache.set(serverName, hsProps);
return hsProps;
}

public async syncRules(intent: Intent) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could use a jsdoc and return type.

this.bannedEntites.clear();
this.subscribedRooms.clear();
for (const roomIdOrAlias of this.config.rooms || []) {
try {
const roomId = await intent.join(roomIdOrAlias);
this.subscribedRooms.add(roomId);
const roomState = await intent.roomState(roomId, false) as WeakStateEvent[];
for (const evt of roomState) {
this.handleIncomingState(evt, roomId);
}
}
catch (ex) {
log.error(`Failed to read ban list from ${roomIdOrAlias}`, ex);
}
}
}

/**
* Is the given room considered part of the bridge's ban list set.
* @param roomId A Matrix room ID.
* @returns true if state should be handled from the room, false otherwise.
*/
public isTrackingRoomState(roomId: string): boolean {
return this.subscribedRooms.has(roomId);
}

/**
* Checks to see if the incoming state is a recommendation entry.
* @param evt A Matrix state event. Unknown state events will be filtered out.
* @param roomId The Matrix roomID where the event came from.
* @returns `true` if the event was a new ban, and existing clients should be checked. `false` otherwise.
*/
public handleIncomingState(evt: WeakStateEvent, roomId: string) {
const content = evt.content as unknown as MPolicyContent;
const entityType = eventTypeToBanEntityType(evt.type);
if (!entityType) {
return false;
}
const key = `${roomId}:${evt.state_key}`;
if (evt.content.entity === undefined) {
// Empty, delete instead.
log.info(`Deleted ban rule ${evt.type}/$ matching ${key}`);
this.bannedEntites.delete(key);
return false;
}
if (!supportedRecommendations.includes(content.recommendation)) {
return false;
}
if (typeof content.entity !== "string" || content.entity === "") {
throw Error('`entity` key is not valid, must be a non-empty string');
}
this.bannedEntites.set(key, {
matcher: new MatrixGlob(content.entity),
entityType,
reason: content.reason || "No reason given",
});
log.info(`New ban rule ${evt.type} matching ${content.entity}`);
return true;
}

/**
* Check if a user is banned by via a ban list.
* @param user A userId string or a MatrixUser object.
* @returns Either a string reason for the ban, or false if the user was not banned.
*/
public async isUserBanned(user: MatrixUser|string): Promise<string|false> {
const matrixUser = typeof user === "string" ? new MatrixUser(user) : user;
for (const entry of this.bannedEntites.values()) {
if (entry.entityType === BanEntityType.Server && entry.matcher.test(matrixUser.host)) {
return entry.reason;
}
if (entry.entityType === BanEntityType.User && entry.matcher.test(matrixUser.userId)) {
return entry.reason;
}
}

if (this.config.blockOpenRegistration) {
// Check the user's homeserver.
const hsProps = await this.getHomeserverProperties(matrixUser.host);
if (hsProps.openRegistration === RegistrationStatus.Open) {
return `${matrixUser.host} has open registration, and this bridge is configured to block open hosts.`
}
if (this.config.blockOpenRegistration.allowUnknown
&& hsProps.openRegistration === RegistrationStatus.Unknown) {
return `${matrixUser.host} may have open registration, ` +
"and this bridge is configured to block unknown hosts";
}
}
return false;
}

/**
* Should be called when the bridge config has been updated.
* @param config The new config.
* @param intent The bot user intent.
*/
public async updateConfig(config: MatrixBanSyncConfig, intent: Intent) {
this.config = config;
await this.syncRules(intent);
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export * from "./components/room-upgrade-handler";
export * from "./components/app-service-bot";
export * from "./components/state-lookup";
export * from "./components/activity-tracker";
export * from "./components/ban-sync";

// Config and CLI
export * from "./components/cli";
Expand Down