Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 4 additions & 1 deletion packages/federation-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { EventID } from '@hs/room';
import { container } from 'tsyringe';
import { ConfigService } from './services/config.service';
import { EduService } from './services/edu.service';
import { EventAuthorizationService } from './services/event-authorization.service';
import { EventService } from './services/event.service';
import { FederationRequestService } from './services/federation-request.service';
import { InviteService } from './services/invite.service';
Expand Down Expand Up @@ -52,7 +53,6 @@ export { StagingAreaService } from './services/staging-area.service';
export { SendJoinService } from './services/send-join.service';
export { EventEmitterService } from './services/event-emitter.service';
export { MediaService } from './services/media.service';

// Repository interfaces and implementations

// Queue implementations
Expand All @@ -67,6 +67,7 @@ export {
roomV10Schemas,
type BaseEventType,
} from './utils/event-schemas';
export { errCodes } from './utils/response-codes';

export { EventRepository } from './repositories/event.repository';
export { RoomRepository } from './repositories/room.repository';
Expand All @@ -88,6 +89,7 @@ export interface HomeserverServices {
edu: EduService;
media: MediaService;
request: FederationRequestService;
federationAuth: EventAuthorizationService;
}

export type HomeserverEventSignatures = {
Expand Down Expand Up @@ -212,6 +214,7 @@ export function getAllServices(): HomeserverServices {
edu: container.resolve(EduService),
media: container.resolve(MediaService),
request: container.resolve(FederationRequestService),
federationAuth: container.resolve(EventAuthorizationService),
};
}

Expand Down
271 changes: 268 additions & 3 deletions packages/federation-sdk/src/services/event-authorization.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
import { createLogger, generateId } from '@hs/core';
import type { EventBase } from '@hs/core';
import { Pdu } from '@hs/room';
import {
createLogger,
extractSignaturesFromHeader,
generateId,
getPublicKeyFromRemoteServer,
makeGetPublicKeyFromServerProcedure,
validateAuthorizationHeader,
} from '@hs/core';
import type { EventID, Pdu, PersistentEventBase } from '@hs/room';
import { singleton } from 'tsyringe';
import { KeyRepository } from '../repositories/key.repository';
import { ConfigService } from './config.service';
import { EventService } from './event.service';
import { StateService } from './state.service';

@singleton()
export class EventAuthorizationService {
private readonly logger = createLogger('EventAuthorizationService');

constructor(
private readonly stateService: StateService,
private readonly eventService: EventService,
private readonly configService: ConfigService,
private readonly keyRepository: KeyRepository,
) {}

async authorizeEvent(event: Pdu, authEvents: Pdu[]): Promise<boolean> {
this.logger.debug(
`Authorizing event ${generateId(event)} of type ${event.type}`,
Expand Down Expand Up @@ -93,4 +110,252 @@ export class EventAuthorizationService {
// TODO: Check sender has permission to change join rules
return true;
}

private async verifyRequestSignature(
method: string,
uri: string,
authorizationHeader: string,
body?: Record<string, unknown>,
): Promise<string | undefined> {
if (!authorizationHeader?.startsWith('X-Matrix')) {
this.logger.debug('Missing or invalid X-Matrix authorization header');
return;
}

try {
const { origin, destination, key, signature } =
extractSignaturesFromHeader(authorizationHeader);

if (
!origin ||
!key ||
!signature ||
(destination && destination !== this.configService.serverName)
) {
return;
}

const [algorithm] = key.split(':');
if (algorithm !== 'ed25519') {
return;
}

// TODO: move makeGetPublicKeyFromServerProcedure procedure to a proper service
const getPublicKeyFromServer = makeGetPublicKeyFromServerProcedure(
(origin, keyId) =>
this.keyRepository.getValidPublicKeyFromLocal(origin, keyId),
(origin, key) =>
getPublicKeyFromRemoteServer(
origin,
this.configService.serverName,
key,
),
(origin, keyId, publicKey) =>
this.keyRepository.storePublicKey(origin, keyId, publicKey),
);
const publicKey = await getPublicKeyFromServer(origin, key);
if (!publicKey) {
this.logger.warn(`Could not fetch public key for ${origin}:${key}`);
return;
}

const actualDestination = destination || this.configService.serverName;
const isValid = await validateAuthorizationHeader(
origin,
publicKey,
actualDestination,
method,
uri,
signature,
body,
);
if (!isValid) {
this.logger.warn(`Invalid signature from ${origin}`);
return;
}

return origin;
} catch (error) {
this.logger.error(error, 'Error verifying request signature');
return;
}
}

private async canAccessEvent(
eventId: EventID,
serverName: string,
): Promise<boolean> {
try {
const event = await this.eventService.getEventById(eventId);
if (!event) {
this.logger.debug(`Event ${eventId} not found`);
return false;
}

const roomId = event.event.room_id;
const state = await this.stateService.getFullRoomState(roomId);

const aclEvent = state.get('m.room.server_acl:');
const isServerAllowed = await this.checkServerAcl(aclEvent, serverName);
if (!isServerAllowed) {
this.logger.warn(
`Server ${serverName} is denied by room ACL for room ${roomId}`,
);
return false;
}

const serversInRoom = await this.stateService.getServersInRoom(roomId);
if (serversInRoom.includes(serverName)) {
this.logger.debug(`Server ${serverName} is in room, allowing access`);
return true;
}

const historyVisibilityEvent = state.get('m.room.history_visibility:');
if (
historyVisibilityEvent?.isHistoryVisibilityEvent() &&
historyVisibilityEvent.getContent().history_visibility ===
'world_readable'
) {
this.logger.debug(
`Event ${eventId} is world_readable, allowing ${serverName}`,
);
return true;
}

this.logger.debug(
`Server ${serverName} not authorized: not in room and event not world_readable`,
);
return false;
} catch (error) {
this.logger.error(
{ error, eventId, serverName },
'Error checking event access',
);
return false;
}
}

async canAccessEventFromAuthorizationHeader(
eventId: EventID,
authorizationHeader: string,
method: string,
uri: string,
body?: Record<string, unknown>,
): Promise<
| { authorized: true }
| {
authorized: false;
errorCode: 'M_UNAUTHORIZED' | 'M_FORBIDDEN' | 'M_UNKNOWN';
}
> {
try {
const signatureResult = await this.verifyRequestSignature(
method,
uri,
authorizationHeader,
body, // keep body due to canonical json validation
);
if (!signatureResult) {
return {
authorized: false,
errorCode: 'M_UNAUTHORIZED',
};
}

const authorized = await this.canAccessEvent(eventId, signatureResult);
if (!authorized) {
return {
authorized: false,
errorCode: 'M_FORBIDDEN',
};
}

return {
authorized: true,
};
} catch (error) {
this.logger.error(
{ error, eventId, authorizationHeader, method, uri, body },
'Error checking event access',
);
return {
authorized: false,
errorCode: 'M_UNKNOWN',
};
}
}

// as per Matrix spec: https://spec.matrix.org/v1.15/client-server-api/#mroomserver_acl
private async checkServerAcl(
aclEvent: PersistentEventBase | undefined,
serverName: string,
): Promise<boolean> {
if (!aclEvent || !aclEvent.isServerAclEvent()) {
return true;
}

const serverAclContent = aclEvent.getContent();
const {
allow = [],
deny = [],
allow_ip_literals = true,
} = serverAclContent;

const isIpLiteral =
/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?$/.test(serverName) ||
/^\[.*\](:\d+)?$/.test(serverName); // IPv6
if (isIpLiteral && !allow_ip_literals) {
this.logger.debug(`Server ${serverName} denied: IP literals not allowed`);
return false;
}

for (const pattern of deny) {
if (this.matchesServerPattern(serverName, pattern)) {
this.logger.debug(
`Server ${serverName} matches deny pattern: ${pattern}`,
);
return false;
}
}

// if allow list is empty, deny all servers (as per Matrix spec)
// empty allow list means no servers are allowed
if (allow.length === 0) {
this.logger.debug(`Server ${serverName} denied: allow list is empty`);
return false;
}

for (const pattern of allow) {
if (this.matchesServerPattern(serverName, pattern)) {
this.logger.debug(
`Server ${serverName} matches allow pattern: ${pattern}`,
);
return true;
}
}

this.logger.debug(`Server ${serverName} not in allow list`);
return false;
}

private matchesServerPattern(serverName: string, pattern: string): boolean {
if (serverName === pattern) {
return true;
}

let regexPattern = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*')
.replace(/\?/g, '.');

regexPattern = `^${regexPattern}$`;

try {
const regex = new RegExp(regexPattern);
return regex.test(serverName);
} catch (error) {
this.logger.warn(`Invalid ACL pattern: ${pattern}`, error);
return false;
}
}
}
14 changes: 11 additions & 3 deletions packages/federation-sdk/src/services/state.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -911,8 +911,16 @@ export class StateService {
}

async getServersInRoom(roomId: string) {
return this.getMembersOfRoom(roomId).then((members) =>
members.map((member) => member.split(':').pop()!),
);
const members = await this.getMembersOfRoom(roomId);
if (!members.length) {
throw new Error(`No members found in room ${roomId}`);
}
return members.map((member) => {
const server = member.split(':').pop();
if (!server) {
throw new Error(`Invalid member format of room ${roomId}: ${member}`);
}
return server;
});
Comment on lines +918 to +924
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Bug: incorrect server parsing for ports/IPv6; dedupe servers

member.split(':').pop() returns only the last segment, breaking for servers with ports (example.org:8448) or IPv6 ([2001:db8::1]:8448). Parse everything after the first colon and return unique servers.

Apply this diff:

-  return members.map((member) => {
-    const server = member.split(':').pop();
-    if (!server) {
-      throw new Error(`Invalid member format of room ${roomId}: ${member}`);
-    }
-    return server;
-  });
+  const servers = new Set<string>();
+  for (const member of members) {
+    const idx = member.indexOf(':');
+    if (idx < 0 || idx === member.length - 1) {
+      throw new Error(`Invalid member format in room ${roomId}: ${member}`);
+    }
+    // Everything after the first ":" is the server name; it may contain ":" (port/IPv6).
+    const server = member.slice(idx + 1);
+    servers.add(server);
+  }
+  return [...servers];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return members.map((member) => {
const server = member.split(':').pop();
if (!server) {
throw new Error(`Invalid member format of room ${roomId}: ${member}`);
}
return server;
});
const servers = new Set<string>();
for (const member of members) {
const idx = member.indexOf(':');
if (idx < 0 || idx === member.length - 1) {
throw new Error(`Invalid member format in room ${roomId}: ${member}`);
}
// Everything after the first ":" is the server name; it may contain ":" (port/IPv6).
const server = member.slice(idx + 1);
servers.add(server);
}
return [...servers];

}
}
20 changes: 20 additions & 0 deletions packages/federation-sdk/src/utils/response-codes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export const errCodes: Record<
string,
{ errcode: string; error: string; status: 401 | 403 | 500 }
> = {
M_UNAUTHORIZED: {
errcode: 'M_UNAUTHORIZED',
error: 'Invalid or missing signature',
status: 401,
},
M_FORBIDDEN: {
errcode: 'M_FORBIDDEN',
error: 'Access denied',
status: 403,
},
M_UNKNOWN: {
errcode: 'M_UNKNOWN',
error: 'Internal server error while processing request',
status: 500,
},
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { ConfigService, EventService } from '@hs/federation-sdk';
import {
ConfigService,
EventAuthorizationService,
EventService,
} from '@hs/federation-sdk';
import { EventID } from '@hs/room';
import { Elysia } from 'elysia';
import { container } from 'tsyringe';
Expand All @@ -10,10 +14,12 @@ import {
SendTransactionBodyDto,
SendTransactionResponseDto,
} from '../../dtos';
import { canAccessEvent } from '../../middlewares/acl.middleware';

export const transactionsPlugin = (app: Elysia) => {
const eventService = container.resolve(EventService);
const configService = container.resolve(ConfigService);
const eventAuthService = container.resolve(EventAuthorizationService);

return app
.put(
Expand Down Expand Up @@ -62,6 +68,7 @@ export const transactionsPlugin = (app: Elysia) => {
};
},
{
use: canAccessEvent(eventAuthService),
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Verify Elysia supports per‑route use: in this project’s version.
If not supported, the middleware won’t run. Confirm the Elysia version or switch to a route‑scoped beforeHandle or a .group(..., { use: plugin }) wrapper for this GET only.


🌐 Web query:

Does Elysia support a `use` property in a single route’s options (to attach a plugin only to that route)? Cite docs and the minimum version that supports it.

💡 Result:

Short answer: No — Elysia does not provide a per-route "use" option to attach a plugin to a single route. The official route documentation describes route options for things like local schema/hooks but does not list a "use" property, and the plugin docs describe attaching plugins via app.use (instance‑level) or using guards/plugins as instances, not a per‑route options "use". [1][2]

Sources:

  • Route docs (shows route signature and local schema/hooks in route options; no "use" per-route option). [1]
  • Plugin docs (describes .use to register plugins on an instance and plugin patterns). [2]

Remove per‑route use — Elysia does not support use in route options.

Confirmed: route options expose a route-level beforeHandle for auth/validation; plugins are registered via .use on an instance and scoping is done with group/guard. (elysiajs.com)

File: packages/homeserver/src/controllers/federation/transactions.controller.ts (line ~68) — replace

  • use: canAccessEvent(eventAuthService),
    with either:
  • beforeHandle: canAccessEvent(eventAuthService) (preferred for per-route auth), or
  • wrap the route in a group/guard and call app.use(...) inside that scope to apply the plugin only to that group.
🤖 Prompt for AI Agents
In packages/homeserver/src/controllers/federation/transactions.controller.ts
around line 68, the route option incorrectly uses "use:
canAccessEvent(eventAuthService)" which Elysia does not support; replace that
option with "beforeHandle: canAccessEvent(eventAuthService)" to apply per-route
auth, or alternatively remove the per-route plugin and scope the plugin via
app.group/guard and app.use(...) to apply canAccessEvent to the route group.

params: GetEventParamsDto,
response: {
200: GetEventResponseDto,
Expand Down
Loading