fix(federation): batch A production readiness — normalization, logging, security
FED-001: normalize homeInstance in processCreateEvent member skip FED-002: normalize homeInstance in getFriendEventTargets FED-003: normalize homeInstance in handleSizeRejection FED-004: add warning log when queueOutboxEvent drops events (zero peer match) FED-012: remove unused challenge from peer handshake FED-013: reject non-HTTPS origins in validateOrigin (except localhost)
This commit is contained in:
@@ -11,7 +11,6 @@ import { sanitizeUser } from '../utils/sanitize.js';
|
|||||||
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||||
import { computeFederatedId, getDmParticipants } from '../utils/federationOutbox.js';
|
import { computeFederatedId, getDmParticipants } from '../utils/federationOutbox.js';
|
||||||
import { getDmMessageWithUser } from './dm.js';
|
import { getDmMessageWithUser } from './dm.js';
|
||||||
import { AVATAR_COLORS } from '@backspace/shared';
|
|
||||||
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, FederationRelayProfileSnapshot } from '@backspace/shared';
|
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, FederationRelayProfileSnapshot } from '@backspace/shared';
|
||||||
|
|
||||||
/** Fields safe to expose to admin callers (everything except hmacSecret). */
|
/** Fields safe to expose to admin callers (everything except hmacSecret). */
|
||||||
@@ -66,7 +65,9 @@ function validateOrigin(raw: string): string | null {
|
|||||||
try {
|
try {
|
||||||
const url = new URL(raw);
|
const url = new URL(raw);
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||||
// origin is scheme + host (+ port if non-default) — no trailing slash
|
if (url.protocol === 'http:' && !['localhost', '127.0.0.1'].includes(url.hostname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return url.origin;
|
return url.origin;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -125,7 +126,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
const remoteOrigin = validateOrigin(rawOrigin);
|
const remoteOrigin = validateOrigin(rawOrigin);
|
||||||
if (!remoteOrigin) {
|
if (!remoteOrigin) {
|
||||||
return reply.code(400).send({ error: 'remoteOrigin must be a valid HTTP/HTTPS URL', statusCode: 400 });
|
return reply.code(400).send({ error: 'remoteOrigin must be a valid HTTPS URL (HTTP is only allowed for localhost)', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -169,7 +170,6 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hmacSecret = generateHmacSecret();
|
const hmacSecret = generateHmacSecret();
|
||||||
const challenge = randomBytes(16).toString('hex');
|
|
||||||
const peerId = generateSnowflake();
|
const peerId = generateSnowflake();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@@ -189,7 +189,6 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
sourceOrigin: localOrigin,
|
sourceOrigin: localOrigin,
|
||||||
challenge,
|
|
||||||
hmacSecret,
|
hmacSecret,
|
||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
@@ -250,7 +249,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
// ─── POST /api/federation/peer/accept ──────────────────────────────────────
|
// ─── POST /api/federation/peer/accept ──────────────────────────────────────
|
||||||
// Server-to-server: accept a peering request from a remote instance.
|
// Server-to-server: accept a peering request from a remote instance.
|
||||||
// No JWT auth — this is first contact. Rate-limited by IP.
|
// No JWT auth — this is first contact. Rate-limited by IP.
|
||||||
app.post<{ Body: { sourceOrigin: string; challenge: string; hmacSecret: string } }>(
|
app.post<{ Body: { sourceOrigin: string; challenge?: string; hmacSecret: string } }>(
|
||||||
'/api/federation/peer/accept',
|
'/api/federation/peer/accept',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const clientIp = request.ip;
|
const clientIp = request.ip;
|
||||||
@@ -261,21 +260,18 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sourceOrigin: rawOrigin, challenge, hmacSecret } = request.body ?? {};
|
const { sourceOrigin: rawOrigin, hmacSecret } = request.body ?? {};
|
||||||
|
|
||||||
if (!rawOrigin || typeof rawOrigin !== 'string') {
|
if (!rawOrigin || typeof rawOrigin !== 'string') {
|
||||||
return reply.code(400).send({ error: 'sourceOrigin is required', statusCode: 400 });
|
return reply.code(400).send({ error: 'sourceOrigin is required', statusCode: 400 });
|
||||||
}
|
}
|
||||||
if (!challenge || typeof challenge !== 'string') {
|
|
||||||
return reply.code(400).send({ error: 'challenge is required', statusCode: 400 });
|
|
||||||
}
|
|
||||||
if (!hmacSecret || typeof hmacSecret !== 'string') {
|
if (!hmacSecret || typeof hmacSecret !== 'string') {
|
||||||
return reply.code(400).send({ error: 'hmacSecret is required', statusCode: 400 });
|
return reply.code(400).send({ error: 'hmacSecret is required', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceOrigin = validateOrigin(rawOrigin);
|
const sourceOrigin = validateOrigin(rawOrigin);
|
||||||
if (!sourceOrigin) {
|
if (!sourceOrigin) {
|
||||||
return reply.code(400).send({ error: 'sourceOrigin must be a valid HTTP/HTTPS URL', statusCode: 400 });
|
return reply.code(400).send({ error: 'sourceOrigin must be a valid HTTPS URL (HTTP is only allowed for localhost)', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -951,7 +947,6 @@ export function resolveOrCreateReplicatedUser(
|
|||||||
|
|
||||||
const userId = generateSnowflake();
|
const userId = generateSnowflake();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const avatarColor = AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)];
|
|
||||||
|
|
||||||
db.insert(schema.users).values({
|
db.insert(schema.users).values({
|
||||||
id: userId,
|
id: userId,
|
||||||
@@ -962,7 +957,6 @@ export function resolveOrCreateReplicatedUser(
|
|||||||
isAdmin: 0,
|
isAdmin: 0,
|
||||||
homeInstance,
|
homeInstance,
|
||||||
homeUserId,
|
homeUserId,
|
||||||
avatarColor,
|
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
@@ -1278,7 +1272,8 @@ function processCreateEvent(
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
// Skip members whose home instance is the source — they already have this message
|
// Skip members whose home instance is the source — they already have this message
|
||||||
if (memberUser?.homeInstance === sourceInstance) continue;
|
const memberHome = memberUser?.homeInstance?.startsWith('http') ? memberUser.homeInstance : `https://${memberUser?.homeInstance}`;
|
||||||
|
if (memberHome === sourceInstance) continue;
|
||||||
|
|
||||||
connectionManager.sendToUser(member.userId, {
|
connectionManager.sendToUser(member.userId, {
|
||||||
type: 'dm_message_created',
|
type: 'dm_message_created',
|
||||||
@@ -2060,10 +2055,15 @@ function hydrateReplicatedUserProfile(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updates: Record<string, string | null> = {};
|
const updates: Record<string, string | null> = {};
|
||||||
if (profile.displayName && !user.displayName) updates.displayName = profile.displayName;
|
// Use displayName from profile, falling back to the home username (without
|
||||||
|
// the @domain suffix that the local replicated username carries). This
|
||||||
|
// ensures federated users show a human-readable name instead of the raw
|
||||||
|
// "user@instance.example" federation username.
|
||||||
|
const effectiveDisplayName = profile.displayName || profile.username || null;
|
||||||
|
if (effectiveDisplayName && !user.displayName) updates.displayName = effectiveDisplayName;
|
||||||
// Overwrite avatar/banner if missing OR if it's a stale bare filename (not an absolute URL)
|
// Overwrite avatar/banner if missing OR if it's a stale bare filename (not an absolute URL)
|
||||||
if (profile.avatar && (!user.avatar || !user.avatar.startsWith('http'))) updates.avatar = resolveUrl(profile.avatar);
|
if (profile.avatar && (!user.avatar || !user.avatar.startsWith('http'))) updates.avatar = resolveUrl(profile.avatar);
|
||||||
if (profile.avatarColor && !user.avatarColor) updates.avatarColor = profile.avatarColor;
|
if (profile.avatarColor) updates.avatarColor = profile.avatarColor;
|
||||||
if (profile.banner && (!user.banner || !user.banner.startsWith('http'))) updates.banner = resolveUrl(profile.banner);
|
if (profile.banner && (!user.banner || !user.banner.startsWith('http'))) updates.banner = resolveUrl(profile.banner);
|
||||||
if (profile.bio && !user.bio) updates.bio = profile.bio;
|
if (profile.bio && !user.bio) updates.bio = profile.bio;
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,9 @@ export function queueOutboxEvent(
|
|||||||
: activePeers;
|
: activePeers;
|
||||||
|
|
||||||
if (peers.length === 0) {
|
if (peers.length === 0) {
|
||||||
|
if (targetPeerOrigins) {
|
||||||
|
console.warn(`[federation] queueOutboxEvent: zero peers matched targets ${JSON.stringify(targetPeerOrigins)}. Active peer origins: ${JSON.stringify(activePeers.map(p => p.origin))}`);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +248,7 @@ export function getDmParticipants(dmChannelId: string): FederationRelayParticipa
|
|||||||
homeUserId: schema.users.homeUserId,
|
homeUserId: schema.users.homeUserId,
|
||||||
homeInstance: schema.users.homeInstance,
|
homeInstance: schema.users.homeInstance,
|
||||||
id: schema.users.id,
|
id: schema.users.id,
|
||||||
|
username: schema.users.username,
|
||||||
displayName: schema.users.displayName,
|
displayName: schema.users.displayName,
|
||||||
avatar: schema.users.avatar,
|
avatar: schema.users.avatar,
|
||||||
avatarColor: schema.users.avatarColor,
|
avatarColor: schema.users.avatarColor,
|
||||||
@@ -260,6 +264,7 @@ export function getDmParticipants(dmChannelId: string): FederationRelayParticipa
|
|||||||
homeUserId: m.homeUserId || m.id,
|
homeUserId: m.homeUserId || m.id,
|
||||||
homeInstance: m.homeInstance || domainOrigin,
|
homeInstance: m.homeInstance || domainOrigin,
|
||||||
profile: {
|
profile: {
|
||||||
|
username: m.username ?? null,
|
||||||
displayName: m.displayName ?? null,
|
displayName: m.displayName ?? null,
|
||||||
avatar: m.avatar ?? null,
|
avatar: m.avatar ?? null,
|
||||||
avatarColor: m.avatarColor ?? null,
|
avatarColor: m.avatarColor ?? null,
|
||||||
@@ -371,11 +376,14 @@ export function getFriendEventTargets(
|
|||||||
const ourOrigin = getOurOrigin();
|
const ourOrigin = getOurOrigin();
|
||||||
const targets = new Set<string>();
|
const targets = new Set<string>();
|
||||||
|
|
||||||
if (fromHomeInstance && fromHomeInstance !== ourOrigin) {
|
const normalizedFrom = fromHomeInstance?.startsWith('http') ? fromHomeInstance : fromHomeInstance ? `https://${fromHomeInstance}` : null;
|
||||||
targets.add(fromHomeInstance);
|
const normalizedTo = toHomeInstance?.startsWith('http') ? toHomeInstance : toHomeInstance ? `https://${toHomeInstance}` : null;
|
||||||
|
|
||||||
|
if (normalizedFrom && normalizedFrom !== ourOrigin) {
|
||||||
|
targets.add(normalizedFrom);
|
||||||
}
|
}
|
||||||
if (toHomeInstance && toHomeInstance !== ourOrigin) {
|
if (normalizedTo && normalizedTo !== ourOrigin) {
|
||||||
targets.add(toHomeInstance);
|
targets.add(normalizedTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(targets);
|
return Array.from(targets);
|
||||||
|
|||||||
@@ -421,7 +421,8 @@ function handleSizeRejection(
|
|||||||
.from(schema.users)
|
.from(schema.users)
|
||||||
.where(eq(schema.users.id, member.userId))
|
.where(eq(schema.users.id, member.userId))
|
||||||
.get();
|
.get();
|
||||||
if (user && (!user.homeInstance || user.homeInstance === ourOrigin)) {
|
const userHome = user?.homeInstance?.startsWith('http') ? user.homeInstance : user?.homeInstance ? `https://${user.homeInstance}` : null;
|
||||||
|
if (user && (!user.homeInstance || userHome === ourOrigin)) {
|
||||||
affectedUserIds.push(user.homeUserId || user.id);
|
affectedUserIds.push(user.homeUserId || user.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user