Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6380a46
feat: adds canAccessMedia middleware and upload repo
ricardogarim Sep 12, 2025
6d83f68
refactor: adds bridged room repo and separate domains
ricardogarim Sep 12, 2025
3f154b5
chore: simplifies binary data requests
ricardogarim Sep 13, 2025
ef3a08b
chore: remove unused imports
ricardogarim Sep 13, 2025
972c9fb
test: adds new test cases for binary data
ricardogarim Sep 13, 2025
758dd9e
refactor: makes signature check to use existing procedures
ricardogarim Sep 13, 2025
91b8eea
adds TODOs
ricardogarim Sep 14, 2025
727d611
chore: adds default 20s timeout into core fetch
ricardogarim Sep 14, 2025
3029b75
chore: adds await into the requestBinaryData to prevent locking and n…
ricardogarim Sep 14, 2025
c2a08f1
chore: adds abort signal handlers as per AI review
ricardogarim Sep 14, 2025
204ef34
chore: adds fetch size limit check
ricardogarim Sep 14, 2025
2c779b7
chore: changes OutgoingHttpHeaders to IncomingHttpHeaders on fetch as…
ricardogarim Sep 14, 2025
cd215b0
chore: makes regex insensitive as per AI review
ricardogarim Sep 14, 2025
695476e
chore: removes unneeded promise response
ricardogarim Sep 15, 2025
e9c776e
Make fetch request suspended until body sub function call
rodrigok Sep 16, 2025
294fe6e
Update packages/core/src/utils/fetch.ts
rodrigok Sep 16, 2025
981a1c7
biome style fix
ricardogarim Sep 16, 2025
ec3c9df
Small improvements on fetch logic and typing
rodrigok Sep 16, 2025
2f05530
Fix lint
rodrigok Sep 16, 2025
6ed5e75
fixes federation-request tests
ricardogarim Sep 16, 2025
5e17654
renames mediaPlugin path
ricardogarim Sep 16, 2025
7640ae5
avoids duplicated calls to state building from canAccessMedia
ricardogarim Sep 16, 2025
87f4973
add TODOs
sampaiodiego Sep 17, 2025
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
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,6 @@ export * from './url';

export { makeUnsignedRequest } from './utils/makeRequest';

export type { FetchResponse } from './utils/fetch';

export { fetch } from './utils/fetch';
22 changes: 14 additions & 8 deletions packages/core/src/utils/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,18 +85,24 @@ export const validateAuthorizationHeader = async <T extends object>(
destination,
...(content && { content }),
});

const signature = Uint8Array.from(atob(hash as string), (c) =>
c.charCodeAt(0),
);
const signingKeyBytes = Uint8Array.from(atob(signingKey as string), (c) =>
c.charCodeAt(0),
);
const messageBytes = new TextEncoder().encode(canonicalJson);
const isValid = nacl.sign.detached.verify(
messageBytes,
signature,
signingKeyBytes,
);

if (
!nacl.sign.detached.verify(
new TextEncoder().encode(canonicalJson),
signature,
Uint8Array.from(atob(signingKey as string), (c) => c.charCodeAt(0)),
)
) {
throw new Error(`Invalid signature for ${destination}`);
if (!isValid) {
throw new Error(
`Invalid signature from ${origin} for request to ${destination}`,
);
}

return true;
Expand Down
265 changes: 237 additions & 28 deletions packages/core/src/utils/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,62 +1,271 @@
import { type OutgoingHttpHeaders } from 'node:http';
import { type IncomingHttpHeaders } from 'node:http';
import https from 'node:https';

type RequestOptions = Parameters<typeof https.request>[1];

export type MultipartResult = {
content: Buffer;
headers?: Record<string, string>;
redirect?: string;
};

/**
* parses Matrix federation multipart/mixed media responses according to spec.
* https://spec.matrix.org/v1.15/server-server-api/#get_matrixfederationv1mediadownloadmediaid
*/
function parseMultipart(buffer: Buffer, boundary: string): MultipartResult {
const bufferStr = buffer.toString();

// check if the second part contains a Location header (CDN redirect)
// pattern: after first boundary and JSON part, look for Location header
const parts = bufferStr.split(`--${boundary}`);
if (parts.length >= 3) {
const secondPart = parts[2];
const locationMatch = secondPart.match(/\r?\nLocation:\s*(.+)\r?\n/i);

if (locationMatch) {
return {
content: Buffer.from(''),
redirect: locationMatch[1].trim(),
};
}
}

// find where the last part's content starts (after the last \r\n\r\n)
const lastHeaderEnd = buffer.lastIndexOf('\r\n\r\n');
if (lastHeaderEnd === -1) return { content: buffer };

const binaryStart = lastHeaderEnd + 4;
const closingBoundary = buffer.lastIndexOf(`\r\n--${boundary}`);

const content =
closingBoundary > binaryStart
? buffer.subarray(binaryStart, closingBoundary)
: buffer.subarray(binaryStart);

return { content };
}

async function handleJson<T>(
contentType: string,
body: () => Promise<Buffer>,
): Promise<T> {
if (!contentType.includes('application/json')) {
throw new Error('Content-Type is not application/json');
}

try {
return JSON.parse((await body()).toString());
} catch {
throw new Error('Failed to parse JSON response');
}
}

async function handleText(
contentType: string,
body: () => Promise<Buffer>,
): Promise<string> {
if (!contentType.includes('text/')) {
return '';
}

return (await body()).toString();
}

// the redirect URL should be fetched without Matrix auth
// and will only occur for media downloads as per Matrix spec
async function handleMultipartRedirect<T>(
redirect: string,
): Promise<FetchResponse<T>> {
const redirectResponse = await fetch<T>(new URL(redirect), {
method: 'GET',
headers: {},
});

if (!redirectResponse.ok) {
throw new Error(`Failed to fetch media from redirect: ${redirect}`);
Comment on lines +80 to +86
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

Harden redirect fetch: enforce https and set Host for consistent SNI.

Prevents accidental plain HTTP and ensures correct SNI without relying on caller headers.

-	const redirectResponse = await fetch<T>(new URL(redirect), {
-		method: 'GET',
-		headers: {},
-	});
+	const u = new URL(redirect);
+	if (u.protocol !== 'https:') {
+		throw new Error(`Rejected non-HTTPS redirect: ${redirect}`);
+	}
+	const redirectResponse = await fetch<T>(u, {
+		method: 'GET',
+		headers: { Host: u.host },
+	});
📝 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
const redirectResponse = await fetch<T>(new URL(redirect), {
method: 'GET',
headers: {},
});
if (!redirectResponse.ok) {
throw new Error(`Failed to fetch media from redirect: ${redirect}`);
const u = new URL(redirect);
if (u.protocol !== 'https:') {
throw new Error(`Rejected non-HTTPS redirect: ${redirect}`);
}
const redirectResponse = await fetch<T>(u, {
method: 'GET',
headers: { Host: u.host },
});
if (!redirectResponse.ok) {
throw new Error(`Failed to fetch media from redirect: ${redirect}`);
🤖 Prompt for AI Agents
In packages/core/src/utils/fetch.ts around lines 75 to 81, the redirect fetch
currently uses the raw redirect URL and empty headers; validate that the
redirect URL uses the https: scheme and throw if not (reject plain http), and
set the request headers to include a Host header derived from the redirect URL's
host (e.g., redirectUrl.host) so SNI is consistent; preserve or merge any
existing headers if needed, then perform the GET with those headers.

}

return redirectResponse;
}

async function handleMultipart<T>(
contentType: string,
body: () => Promise<Buffer>,
depth = 0,
): Promise<MultipartResult> {
if (!/\bmultipart\b/i.test(contentType)) {
throw new Error('Content-Type is not multipart');
}

// extract boundary from content-type header
const boundaryMatch = contentType.match(/boundary=([^;,\s]+)/i);
if (!boundaryMatch) {
throw new Error('Boundary not found in Content-Type header');
}

// remove quotes if present
const boundary = boundaryMatch[1].replace(/^["']|["']$/g, '');
const multipart = parseMultipart(await body(), boundary);

if (multipart.redirect) {
if (depth >= 5) {
throw new Error('Too many redirects in multipart response');
}

const redirectResponse = await handleMultipartRedirect<T>(
multipart.redirect,
);
return handleMultipart(
redirectResponse.headers['content-type'] || '',
redirectResponse.body,
depth + 1,
);
}

return multipart;
}

export type FetchResponse<T> = {
ok: boolean;
status: number | undefined;
headers: IncomingHttpHeaders;
buffer: () => Promise<Buffer>;
json: () => Promise<T>;
text: () => Promise<string>;
multipart: () => Promise<MultipartResult>;
body: () => Promise<Buffer>;
};

// this fetch is used when connecting to a multihome server, same server hosting multiple homeservers, and we need to verify the cert with the right SNI (hostname), or else, cert check will fail due to connecting through ip and not hostname (due to matrix spec).
export async function fetch(url: URL, options: RequestInit) {
export async function fetch<T>(
url: URL,
options: RequestInit,
): Promise<FetchResponse<T>> {
const serverName = new URL(
`http://${(options.headers as OutgoingHttpHeaders).Host}` as string,
`http://${(options.headers as IncomingHttpHeaders).Host}` as string,
).hostname;

const requestParams: RequestOptions = {
host: url.hostname, // IP
port: url.port,
method: options.method,
path: url.pathname + url.search,
headers: {
...(options.headers as OutgoingHttpHeaders),
'content-type': 'application/json',
},
headers: options.headers as IncomingHttpHeaders,
servername: serverName,
};
Comment on lines 145 to 156
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

SNI: fall back to URL hostname when Host header is absent.

Current code computes servername from a possibly missing Host header and can become "undefined", breaking TLS SNI. Fallback to url.hostname.

Apply:

-const serverName = new URL(
-  `http://${(options.headers as IncomingHttpHeaders).Host}` as string,
-).hostname;
+const hostHeader = (options.headers as IncomingHttpHeaders)?.Host;
+const serverName = hostHeader
+  ? new URL(`http://${hostHeader}`).hostname
+  : url.hostname;
📝 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
const serverName = new URL(
`http://${(options.headers as OutgoingHttpHeaders).Host}` as string,
`http://${(options.headers as IncomingHttpHeaders).Host}` as string,
).hostname;
const requestParams: RequestOptions = {
host: url.hostname, // IP
port: url.port,
method: options.method,
path: url.pathname + url.search,
headers: {
...(options.headers as OutgoingHttpHeaders),
'content-type': 'application/json',
},
headers: options.headers as IncomingHttpHeaders,
servername: serverName,
};
const hostHeader = (options.headers as IncomingHttpHeaders)?.Host;
const serverName = hostHeader
? new URL(`http://${hostHeader}`).hostname
: url.hostname;
const requestParams: RequestOptions = {
host: url.hostname, // IP
port: url.port,
method: options.method,
path: url.pathname + url.search,
headers: options.headers as IncomingHttpHeaders,
servername: serverName,
};
🤖 Prompt for AI Agents
In packages/core/src/utils/fetch.ts around lines 90 to 101, the servername for
TLS SNI is being derived directly from the Host header which may be undefined;
change the logic to use the Host header only when present and non-empty,
otherwise fall back to url.hostname (ensuring any port is stripped), then assign
that resulting hostname to requestParams.servername so SNI always receives a
valid hostname.


try {
const response: { statusCode: number | undefined; body: string } =
await new Promise((resolve, reject) => {
const request = https.request(requestParams, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
resolve({
statusCode: res.statusCode,
body: data,
});
});
});
request.on('error', (err) => {
reject(err);
const response: {
statusCode: number | undefined;
body: () => Promise<Buffer>;
headers: IncomingHttpHeaders;
} = await new Promise((resolve, reject) => {
const request = https.request(requestParams, (res) => {
const chunks: Buffer[] = [];

res.once('error', reject);

res.pause();

let body: Promise<Buffer>;

resolve({
statusCode: res.statusCode,
headers: res.headers,
body() {
if (!body) {
body = new Promise<Buffer>((resBody, rejBody) => {
// TODO: Make @hs/core fetch size limit configurable
let total = 0;
const MAX_RESPONSE_BYTES = 50 * 1024 * 1024; // 50 MB

const onData = (chunk: Buffer) => {
total += chunk.length;
if (total > MAX_RESPONSE_BYTES) {
const err = new Error('Response exceeds size limit');
res.destroy(err);
cleanup();
rejBody(err);
return;
}
chunks.push(chunk);
};
const onEnd = () => {
cleanup();
resBody(Buffer.concat(chunks));
};
const onErr = (err: Error) => {
cleanup();
rejBody(err);
};
const onAborted = () => onErr(new Error('Response aborted'));
const cleanup = () => {
res.off('data', onData);
res.off('end', onEnd);
res.off('error', onErr);
res.off('aborted', onAborted);
};
res.on('data', onData);
res.once('end', onEnd);
res.once('error', onErr);
res.once('aborted', onAborted);
res.resume();
});
}

return body;
},
});
});

request.end(options.body);
const signal = options.signal;
if (signal) {
const onAbort = () => request.destroy(new Error('Aborted'));
signal.addEventListener('abort', onAbort, { once: true });
request.once('close', () =>
signal.removeEventListener('abort', onAbort),
);
}

request.on('error', (err) => {
reject(err);
});

// TODO: Make @hs/core fetch timeout configurable
request.setTimeout(20_000, () => {
request.destroy(new Error('Request timed out after 20s'));
});

request.end(options.body);
});

const contentType = response.headers['content-type'] || '';

return {
ok: response.statusCode
? response.statusCode >= 200 && response.statusCode < 300
: false,
json: () => JSON.parse(response.body),
text: () => response.body,
buffer: () => response.body(),
json: () => handleJson<T>(contentType, response.body),
text: () => handleText(contentType, response.body),
multipart: () => handleMultipart(contentType, response.body),
body: response.body,
status: response.statusCode,
headers: response.headers,
};
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);

return {
ok: false,
json: () => undefined,
text: () => (err instanceof Error ? err.message : String(err)),
status: undefined,
headers: {},
buffer: () => Promise.reject(reason),
json: () => Promise.reject(reason),
text: () => Promise.reject(reason),
multipart: () => Promise.reject(reason),
body: () => Promise.reject(reason),
};
}
}
24 changes: 22 additions & 2 deletions packages/federation-sdk/src/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,14 @@ import { EventStagingRepository } from './repositories/event-staging.repository'
import { EventRepository } from './repositories/event.repository';
import { Key, KeyRepository } from './repositories/key.repository';
import { Lock, LockRepository } from './repositories/lock.repository';
import {
MatrixBridgedRoom,
MatrixBridgedRoomRepository,
} from './repositories/matrix-bridged-room.repository';
import { Room, RoomRepository } from './repositories/room.repository';
import { Server, ServerRepository } from './repositories/server.repository';
import { StateRepository, StateStore } from './repositories/state.repository';
import { Upload, UploadRepository } from './repositories/upload.repository';
import { ConfigService } from './services/config.service';
import { DatabaseConnectionService } from './services/database-connection.service';
import { EduService } from './services/edu.service';
Expand Down Expand Up @@ -86,20 +91,35 @@ export async function createFederationContainer(
useValue: db.collection<Server>('rocketchat_federation_servers'),
});

container.register<Collection<Upload>>('UploadCollection', {
useValue: db.collection<Upload>('rocketchat_uploads'),
});

container.register<Collection<MatrixBridgedRoom>>(
'MatrixBridgedRoomCollection',
{
useValue: db.collection<MatrixBridgedRoom>(
'rocketchat_matrix_bridged_rooms',
),
},
);

container.registerSingleton(EventRepository);
container.registerSingleton(EventStagingRepository);
container.registerSingleton(KeyRepository);
container.registerSingleton(LockRepository);
container.registerSingleton(RoomRepository);
container.registerSingleton(StateRepository);
container.registerSingleton(ServerRepository);
container.registerSingleton(MatrixBridgedRoomRepository);
container.registerSingleton(UploadRepository);

container.registerSingleton(FederationRequestService);
container.registerSingleton(FederationService);
container.registerSingleton(StateService);
container.registerSingleton(EventAuthorizationService);
container.registerSingleton('EventFetcherService', EventFetcherService);
container.registerSingleton(EventService);
container.registerSingleton(EventFetcherService);
container.registerSingleton(EventAuthorizationService);
container.registerSingleton(EventEmitterService);
container.registerSingleton(InviteService);
container.registerSingleton(MediaService);
Expand Down
Loading