-
Notifications
You must be signed in to change notification settings - Fork 74
Migrate the ban-sync component from matrix-appservice-irc #459
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Half-Shot
wants to merge
6
commits into
develop
Choose a base branch
from
hs/ban-sync-component
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
73d1c10
Port the ban sync component to matrix-appservice-bridge.
Half-Shot bb24c0a
Support blocking open registration
Half-Shot 9a98f1f
Merge remote-tracking branch 'origin/develop' into hs/ban-sync-component
Half-Shot 261b226
Add support for identifying the client API
Half-Shot 6402de9
More configurable client reg blocking
Half-Shot 2ce1db4
More jsdoc
Half-Shot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,273 @@ | ||
|
|
||
|
|
||
| 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[]; | ||
| blockByRegistrationStatus: RegistrationStatus[]; | ||
| } | ||
|
|
||
| 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, { | ||
| registrationStatus: 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) { | ||
|
|
||
| } | ||
|
|
||
| /** | ||
| * Determine the state of the homeservers user registration system. | ||
| * | ||
| * @param url The base URL for the C-S API of the homeserver. | ||
| * @returns A status enum. | ||
| */ | ||
| private static async getRegistrationStatus(url: URL): Promise<RegistrationStatus> { | ||
| let registrationResponse = await axios.post(new URL('/_matrix/client/v3/register', url).toString(), { }); | ||
| if (registrationResponse.status === 404) { | ||
| // Fallback to old APIs | ||
| registrationResponse = await axios.post(new URL('/_matrix/client/r0/register', url).toString(), { }); | ||
| } | ||
|
|
||
|
|
||
| if (registrationResponse.status === 403 && registrationResponse.data.errcode === 'M_FORBIDDEN') { | ||
| // Explicitly forbidden private server -> great! | ||
| return RegistrationStatus.Closed; | ||
| } | ||
|
|
||
| if (registrationResponse.status === 404) { | ||
| // Endpoint is not connected, probably also great! | ||
| return RegistrationStatus.Closed; | ||
| } | ||
|
|
||
| if (registrationResponse.status === 401) { | ||
| // Look at the flows. | ||
| const { flows } = registrationResponse.data as MatrixRegistrationResponse; | ||
| if (!flows) { | ||
| // Invalid response | ||
| return RegistrationStatus.Unknown; | ||
| } | ||
|
|
||
| if (flows.length === 0) { | ||
| // No available flows, so closed. | ||
| return RegistrationStatus.Closed; | ||
| } | ||
|
|
||
| let openReg = RegistrationStatus.Unknown; | ||
| // Check the flows | ||
| for (const flow of flows) { | ||
| // 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; | ||
| } | ||
| } | ||
| return openReg; | ||
| } | ||
|
|
||
| return RegistrationStatus.Unknown; | ||
| } | ||
|
|
||
| /** | ||
| * Get properties about a given homeserver that may influence the rules | ||
| * applied to it. | ||
| * @param serverName The homeserver name. | ||
| * @returns A set of properties. | ||
| * @throws This will fail if the client does not provide a well-known. Callers should | ||
| * make their own assumptions about the state of the host in this case. | ||
| */ | ||
| public async getHomeserverProperties(serverName: string) { | ||
| const hsData = this.homeserverPropertiesCache.get(serverName); | ||
| // Slightly fuzz the ttl. | ||
| const ttl = Date.now() + CACHE_HOMESERVER_PROPERTIES_FOR_MS + (Math.random()*60000); | ||
| if (hsData && hsData.ts < ttl) { | ||
| return hsData; | ||
| } | ||
| const url = await this.hostResolver.resolveMatrixClient(serverName, true); | ||
|
|
||
| const hsProps = { | ||
| registrationStatus: await MatrixBanSync.getRegistrationStatus(url), | ||
| ts: Date.now(), | ||
| }; | ||
| this.homeserverPropertiesCache.set(serverName, hsProps); | ||
| return hsProps; | ||
| } | ||
|
|
||
| /** | ||
| * Perform joins against all the configured ban list rooms, and pull all ban state. | ||
| * This can be quite expensive to run if there is lots of state to pull. | ||
| * @param intent The client intent to pull the state via. | ||
| */ | ||
| public async syncRules(intent: Intent) { | ||
| 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.blockByRegistrationStatus) { | ||
| // Check the user's homeserver. | ||
| const { registrationStatus } = await this.getHomeserverProperties(matrixUser.host); | ||
| if (this.config.blockByRegistrationStatus.includes(registrationStatus)) { | ||
| const statusName = RegistrationStatus[registrationStatus]; | ||
| return `${matrixUser.host} has ${statusName} registration, which is blocked by this bridge.` | ||
| } | ||
| } | ||
| 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); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.