feat(federation): verify approval token before awaiting_approval → active
Receiver-side defense — closes the trust-bypass class the cheap fix
(4533e36) cannot cover. The /peer/accept handler's awaiting_approval
branch now requires an approvalToken matching the one stored on the
local peer row before promoting to active. Without a match:
- autoAccept=0 falls through to queueApprovalRequest (no bypass; new
approval-request queued, existing awaiting_approval row untouched).
- autoAccept=1 falls back to permissive promotion (no regression vs
prior behavior, since autoAccept=1 would accept any inbound regardless).
Successful match also deletes any stale approval-request row for the
origin to prevent debris accumulation from prior bypass attempts.
Refactor: queueing path extracted to a top-level queueApprovalRequest()
helper so both the no-existing-peer case and the awaiting_approval
mismatch fallback share one implementation. Helper generates a fresh
single-use token on every call.
Adds 'accept_awaiting_approval_fallback' variant to PeerActivationReason
to distinguish the autoAccept=1 fallback path from the verified path
in onPeerActivated audit logs.
Spec §3.5, §3.6.
Test counts: 289 → 301 (+12).
This commit is contained in:
@@ -0,0 +1,345 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import * as schema from '../db/schema.js';
|
||||||
|
import { setWorkerId } from '../utils/snowflake.js';
|
||||||
|
|
||||||
|
setWorkerId(1);
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
|
||||||
|
let sqlite: Database.Database;
|
||||||
|
let testDb: TestDb;
|
||||||
|
|
||||||
|
vi.mock('../db/index.js', () => ({
|
||||||
|
getDb: () => testDb,
|
||||||
|
getRawDb: () => sqlite,
|
||||||
|
schema,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/auth.js', () => ({
|
||||||
|
authenticate: async (req: { userId?: string }) => {
|
||||||
|
req.userId = 'admin-user';
|
||||||
|
},
|
||||||
|
requireAdmin: async () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../ws/handler.js', () => ({
|
||||||
|
connectionManager: {
|
||||||
|
sendToAdmins: vi.fn(),
|
||||||
|
getAllOnlineUserIds: () => [],
|
||||||
|
sendToUser: vi.fn(),
|
||||||
|
sendToDmMembers: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/federationPeerActivation.js', () => ({
|
||||||
|
onPeerActivated: vi.fn(async () => undefined),
|
||||||
|
onPeerDeactivated: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function applyMigrations(db: Database.Database): void {
|
||||||
|
const migrationsDir = path.resolve(__dirname, '../../drizzle');
|
||||||
|
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
|
||||||
|
for (const f of files) {
|
||||||
|
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
|
||||||
|
const statements = sqlText.split(/-->\s*statement-breakpoint/);
|
||||||
|
for (const stmt of statements) {
|
||||||
|
const clean = stmt.trim();
|
||||||
|
if (clean) db.exec(clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedInstanceSettings(autoAccept: 0 | 1): void {
|
||||||
|
testDb.insert(schema.instanceSettings).values({
|
||||||
|
id: 1,
|
||||||
|
instanceName: 'Local Backspace',
|
||||||
|
autoAcceptPeering: autoAccept,
|
||||||
|
registrationOpen: 1,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildApp(): Promise<FastifyInstance> {
|
||||||
|
const app = Fastify({ logger: false });
|
||||||
|
const { federationRoutes } = await import('./federation.js');
|
||||||
|
await app.register(federationRoutes);
|
||||||
|
await app.ready();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('POST /api/federation/peer/accept — approval token verification', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
sqlite = new Database(':memory:');
|
||||||
|
testDb = drizzle(sqlite, { schema });
|
||||||
|
applyMigrations(sqlite);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setupAutoAccept(value: 0 | 1): Promise<void> {
|
||||||
|
seedInstanceSettings(value);
|
||||||
|
app = await buildApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('queueing path stores token on peer_approval_requests and returns it in the 202 body (autoAccept=0, no existing peer)', async () => {
|
||||||
|
await setupAutoAccept(0);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/federation/peer/accept',
|
||||||
|
payload: {
|
||||||
|
sourceOrigin: 'https://remote.example',
|
||||||
|
hmacSecret: 'remote-secret',
|
||||||
|
instanceName: 'Remote',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(202);
|
||||||
|
const body = response.json() as { queued: boolean; approvalToken?: string };
|
||||||
|
expect(body.queued).toBe(true);
|
||||||
|
expect(typeof body.approvalToken).toBe('string');
|
||||||
|
expect(body.approvalToken).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
|
||||||
|
const row = testDb.select().from(schema.peerApprovalRequests)
|
||||||
|
.where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get();
|
||||||
|
expect(row?.approvalToken).toBe(body.approvalToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regenerates token on re-handshake (existing approval-request row gets new token)', async () => {
|
||||||
|
await setupAutoAccept(0);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const oldToken = 'old-token-' + 'a'.repeat(53);
|
||||||
|
testDb.insert(schema.peerApprovalRequests).values({
|
||||||
|
id: 'approval-old',
|
||||||
|
origin: 'https://remote.example',
|
||||||
|
instanceName: 'Remote',
|
||||||
|
hmacSecret: 'old-secret',
|
||||||
|
requestedAt: now - 1000,
|
||||||
|
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
|
||||||
|
approvalToken: oldToken,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/federation/peer/accept',
|
||||||
|
payload: {
|
||||||
|
sourceOrigin: 'https://remote.example',
|
||||||
|
hmacSecret: 'fresh-secret',
|
||||||
|
instanceName: 'Remote',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(202);
|
||||||
|
const body = response.json() as { queued: boolean; approvalToken?: string };
|
||||||
|
expect(body.approvalToken).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
expect(body.approvalToken).not.toBe(oldToken);
|
||||||
|
|
||||||
|
const row = testDb.select().from(schema.peerApprovalRequests)
|
||||||
|
.where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get();
|
||||||
|
expect(row?.approvalToken).toBe(body.approvalToken);
|
||||||
|
expect(row?.hmacSecret).toBe('fresh-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('promotes awaiting_approval → active when token matches (autoAccept=0)', async () => {
|
||||||
|
await setupAutoAccept(0);
|
||||||
|
|
||||||
|
const token = 'a'.repeat(64);
|
||||||
|
testDb.insert(schema.federationPeers).values({
|
||||||
|
id: 'peer-pending',
|
||||||
|
origin: 'https://remote.example',
|
||||||
|
hmacSecret: 'old-secret',
|
||||||
|
status: 'awaiting_approval',
|
||||||
|
approvalToken: token,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/federation/peer/accept',
|
||||||
|
payload: {
|
||||||
|
sourceOrigin: 'https://remote.example',
|
||||||
|
hmacSecret: 'fresh-secret',
|
||||||
|
instanceName: 'Remote',
|
||||||
|
approvalToken: token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const peer = testDb.select().from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
|
||||||
|
expect(peer?.status).toBe('active');
|
||||||
|
expect(peer?.hmacSecret).toBe('fresh-secret');
|
||||||
|
expect(peer?.approvalToken).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on successful match, deletes any stale approval-request row for the same origin', async () => {
|
||||||
|
await setupAutoAccept(0);
|
||||||
|
const token = 'b'.repeat(64);
|
||||||
|
const now = Date.now();
|
||||||
|
testDb.insert(schema.federationPeers).values({
|
||||||
|
id: 'peer-pending',
|
||||||
|
origin: 'https://remote.example',
|
||||||
|
hmacSecret: 'old-secret',
|
||||||
|
status: 'awaiting_approval',
|
||||||
|
approvalToken: token,
|
||||||
|
createdAt: now,
|
||||||
|
}).run();
|
||||||
|
// Stale approval-request from a prior bypass attempt (e.g., bug-prone code path).
|
||||||
|
testDb.insert(schema.peerApprovalRequests).values({
|
||||||
|
id: 'stale-request',
|
||||||
|
origin: 'https://remote.example',
|
||||||
|
instanceName: 'Remote',
|
||||||
|
hmacSecret: 'bypass-secret',
|
||||||
|
requestedAt: now,
|
||||||
|
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
|
||||||
|
approvalToken: 'stale-token',
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/federation/peer/accept',
|
||||||
|
payload: {
|
||||||
|
sourceOrigin: 'https://remote.example',
|
||||||
|
hmacSecret: 'fresh-secret',
|
||||||
|
approvalToken: token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const stale = testDb.select().from(schema.peerApprovalRequests)
|
||||||
|
.where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get();
|
||||||
|
expect(stale).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('autoAccept=0 + missing token → does NOT promote, queues new approval-request', async () => {
|
||||||
|
await setupAutoAccept(0);
|
||||||
|
const token = 'c'.repeat(64);
|
||||||
|
testDb.insert(schema.federationPeers).values({
|
||||||
|
id: 'peer-pending',
|
||||||
|
origin: 'https://remote.example',
|
||||||
|
hmacSecret: 'old-secret',
|
||||||
|
status: 'awaiting_approval',
|
||||||
|
approvalToken: token,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/federation/peer/accept',
|
||||||
|
payload: {
|
||||||
|
sourceOrigin: 'https://remote.example',
|
||||||
|
hmacSecret: 'bypass-secret',
|
||||||
|
// no approvalToken
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(202);
|
||||||
|
const body = response.json() as { queued: boolean; approvalToken?: string };
|
||||||
|
expect(body.queued).toBe(true);
|
||||||
|
expect(typeof body.approvalToken).toBe('string');
|
||||||
|
|
||||||
|
// Existing awaiting_approval peer row UNCHANGED.
|
||||||
|
const peer = testDb.select().from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
|
||||||
|
expect(peer?.status).toBe('awaiting_approval');
|
||||||
|
expect(peer?.hmacSecret).toBe('old-secret');
|
||||||
|
expect(peer?.approvalToken).toBe(token);
|
||||||
|
|
||||||
|
// New approval-request row exists with a fresh token (different from the existing peer's).
|
||||||
|
const req = testDb.select().from(schema.peerApprovalRequests)
|
||||||
|
.where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get();
|
||||||
|
expect(req?.approvalToken).toBe(body.approvalToken);
|
||||||
|
expect(req?.approvalToken).not.toBe(token);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('autoAccept=0 + mismatched token → behaves the same as missing token (queues)', async () => {
|
||||||
|
await setupAutoAccept(0);
|
||||||
|
testDb.insert(schema.federationPeers).values({
|
||||||
|
id: 'peer-pending',
|
||||||
|
origin: 'https://remote.example',
|
||||||
|
hmacSecret: 'old-secret',
|
||||||
|
status: 'awaiting_approval',
|
||||||
|
approvalToken: 'd'.repeat(64),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/federation/peer/accept',
|
||||||
|
payload: {
|
||||||
|
sourceOrigin: 'https://remote.example',
|
||||||
|
hmacSecret: 'bypass-secret',
|
||||||
|
approvalToken: 'e'.repeat(64),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(202);
|
||||||
|
const peer = testDb.select().from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
|
||||||
|
expect(peer?.status).toBe('awaiting_approval');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('autoAccept=0 + null stored token (legacy) + no inbound token → queues (does not promote)', async () => {
|
||||||
|
await setupAutoAccept(0);
|
||||||
|
testDb.insert(schema.federationPeers).values({
|
||||||
|
id: 'peer-legacy',
|
||||||
|
origin: 'https://remote.example',
|
||||||
|
hmacSecret: 'old-secret',
|
||||||
|
status: 'awaiting_approval',
|
||||||
|
approvalToken: null,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/federation/peer/accept',
|
||||||
|
payload: {
|
||||||
|
sourceOrigin: 'https://remote.example',
|
||||||
|
hmacSecret: 'fresh-secret',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(202);
|
||||||
|
const peer = testDb.select().from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
|
||||||
|
expect(peer?.status).toBe('awaiting_approval');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('autoAccept=1 + missing/mismatched token → fallback promotes (no security regression)', async () => {
|
||||||
|
await setupAutoAccept(1);
|
||||||
|
testDb.insert(schema.federationPeers).values({
|
||||||
|
id: 'peer-pending',
|
||||||
|
origin: 'https://remote.example',
|
||||||
|
hmacSecret: 'old-secret',
|
||||||
|
status: 'awaiting_approval',
|
||||||
|
approvalToken: 'f'.repeat(64),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/federation/peer/accept',
|
||||||
|
payload: {
|
||||||
|
sourceOrigin: 'https://remote.example',
|
||||||
|
hmacSecret: 'fresh-secret',
|
||||||
|
// no approvalToken
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const peer = testDb.select().from(schema.federationPeers)
|
||||||
|
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
|
||||||
|
expect(peer?.status).toBe('active');
|
||||||
|
expect(peer?.hmacSecret).toBe('fresh-secret');
|
||||||
|
expect(peer?.approvalToken).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -246,6 +246,77 @@ setInterval(() => {
|
|||||||
}
|
}
|
||||||
}, ACCEPT_RATE_WINDOW_MS).unref();
|
}, ACCEPT_RATE_WINDOW_MS).unref();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue an inbound peer/accept request for local-admin approval.
|
||||||
|
*
|
||||||
|
* Called from `/peer/accept` when:
|
||||||
|
* (a) `autoAcceptPeering=0` and no `pending`/`awaiting_approval` peer row
|
||||||
|
* exists for the source origin (first-contact request from remote), OR
|
||||||
|
* (b) the receiver is in `awaiting_approval` for this origin but the
|
||||||
|
* inbound `/peer/accept` cannot be cryptographically verified
|
||||||
|
* (token absent or mismatched) — see spec §3.5.
|
||||||
|
*
|
||||||
|
* Generates a fresh single-use approval token, upserts the
|
||||||
|
* `peer_approval_requests` row, notifies admins, and returns 202 with the
|
||||||
|
* token in the body. The initiator stores the token alongside its
|
||||||
|
* `awaiting_approval` row so a future `/peer/accept` from this side's
|
||||||
|
* `/approve` endpoint can verify mutual admin approval.
|
||||||
|
*/
|
||||||
|
function queueApprovalRequest(
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
sourceOrigin: string,
|
||||||
|
hmacSecret: string,
|
||||||
|
reqInstanceName: string | null,
|
||||||
|
): FastifyReply {
|
||||||
|
const now = Date.now();
|
||||||
|
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
const approvalToken = randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
const existingRequest = db
|
||||||
|
.select({ id: schema.peerApprovalRequests.id })
|
||||||
|
.from(schema.peerApprovalRequests)
|
||||||
|
.where(eq(schema.peerApprovalRequests.origin, sourceOrigin))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existingRequest) {
|
||||||
|
db.update(schema.peerApprovalRequests)
|
||||||
|
.set({
|
||||||
|
instanceName: reqInstanceName,
|
||||||
|
hmacSecret,
|
||||||
|
requestedAt: now,
|
||||||
|
expiresAt: now + THIRTY_DAYS_MS,
|
||||||
|
approvalToken,
|
||||||
|
})
|
||||||
|
.where(eq(schema.peerApprovalRequests.id, existingRequest.id))
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
db.insert(schema.peerApprovalRequests)
|
||||||
|
.values({
|
||||||
|
id: generateSnowflake(),
|
||||||
|
origin: sourceOrigin,
|
||||||
|
instanceName: reqInstanceName,
|
||||||
|
hmacSecret,
|
||||||
|
requestedAt: now,
|
||||||
|
expiresAt: now + THIRTY_DAYS_MS,
|
||||||
|
approvalToken,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionManager.sendToAdmins({
|
||||||
|
type: 'federation_approval_request_received' as const,
|
||||||
|
origin: sourceOrigin,
|
||||||
|
instanceName: reqInstanceName ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(202).send({
|
||||||
|
queued: true,
|
||||||
|
message: 'Request queued for admin approval',
|
||||||
|
approvalToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
||||||
// ─── POST /api/federation/peer/initiate ────────────────────────────────────
|
// ─── POST /api/federation/peer/initiate ────────────────────────────────────
|
||||||
// Admin-only: start a peering handshake with a remote instance.
|
// Admin-only: start a peering handshake with a remote instance.
|
||||||
@@ -432,7 +503,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; instanceName?: string } }>(
|
app.post<{ Body: { sourceOrigin: string; challenge?: string; hmacSecret: string; instanceName?: string; approvalToken?: string } }>(
|
||||||
'/api/federation/peer/accept',
|
'/api/federation/peer/accept',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const clientIp = request.ip;
|
const clientIp = request.ip;
|
||||||
@@ -443,7 +514,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sourceOrigin: rawOrigin, hmacSecret, instanceName: reqInstanceName } = request.body ?? {};
|
const { sourceOrigin: rawOrigin, hmacSecret, instanceName: reqInstanceName, approvalToken: inboundToken } = 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 });
|
||||||
@@ -514,50 +585,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue for admin approval — upsert into peer_approval_requests
|
return queueApprovalRequest(db, reply, sourceOrigin, hmacSecret, reqInstanceName ?? null);
|
||||||
const now = Date.now();
|
|
||||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const existingRequest = db
|
|
||||||
.select({ id: schema.peerApprovalRequests.id })
|
|
||||||
.from(schema.peerApprovalRequests)
|
|
||||||
.where(eq(schema.peerApprovalRequests.origin, sourceOrigin))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (existingRequest) {
|
|
||||||
db.update(schema.peerApprovalRequests)
|
|
||||||
.set({
|
|
||||||
instanceName: reqInstanceName ?? null,
|
|
||||||
hmacSecret,
|
|
||||||
requestedAt: now,
|
|
||||||
expiresAt: now + THIRTY_DAYS_MS,
|
|
||||||
})
|
|
||||||
.where(eq(schema.peerApprovalRequests.id, existingRequest.id))
|
|
||||||
.run();
|
|
||||||
} else {
|
|
||||||
db.insert(schema.peerApprovalRequests)
|
|
||||||
.values({
|
|
||||||
id: generateSnowflake(),
|
|
||||||
origin: sourceOrigin,
|
|
||||||
instanceName: reqInstanceName ?? null,
|
|
||||||
hmacSecret,
|
|
||||||
requestedAt: now,
|
|
||||||
expiresAt: now + THIRTY_DAYS_MS,
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify admin users that a new approval request arrived
|
|
||||||
connectionManager.sendToAdmins({
|
|
||||||
type: 'federation_approval_request_received' as const,
|
|
||||||
origin: sourceOrigin,
|
|
||||||
instanceName: reqInstanceName ?? undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.code(202).send({
|
|
||||||
queued: true,
|
|
||||||
message: 'Request queued for admin approval',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,32 +645,83 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName });
|
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName });
|
||||||
}
|
}
|
||||||
if (existing.status === 'awaiting_approval') {
|
if (existing.status === 'awaiting_approval') {
|
||||||
// Remote admin approved — this is a fresh handshake from them.
|
// Spec §3.5: token verification gates the awaiting_approval → active
|
||||||
|
// promotion. Without proof the inbound came from the remote's
|
||||||
|
// /approve endpoint, an adversarial timing-knowledge attack or a
|
||||||
|
// bug-prone background code path could falsely flip this row to
|
||||||
|
// active. The token is single-use entropy issued in the 202 we
|
||||||
|
// returned when the remote's outbound /peer/accept first hit our
|
||||||
|
// queue — only their /approve endpoint forwards it.
|
||||||
|
const tokenValid =
|
||||||
|
typeof existing.approvalToken === 'string' &&
|
||||||
|
existing.approvalToken.length > 0 &&
|
||||||
|
existing.approvalToken === inboundToken;
|
||||||
|
|
||||||
|
if (tokenValid) {
|
||||||
db.update(schema.federationPeers)
|
db.update(schema.federationPeers)
|
||||||
.set({
|
.set({
|
||||||
hmacSecret,
|
hmacSecret,
|
||||||
instanceName: reqInstanceName ?? null,
|
instanceName: reqInstanceName ?? null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
lastSeenAt: Date.now(),
|
lastSeenAt: Date.now(),
|
||||||
|
approvalToken: null,
|
||||||
})
|
})
|
||||||
.where(eq(schema.federationPeers.id, existing.id))
|
.where(eq(schema.federationPeers.id, existing.id))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
// Broadcast activation
|
// Clean up any stale approval-request row for this origin (e.g.,
|
||||||
|
// queued debris from a prior bypass attempt that did not promote).
|
||||||
|
db.delete(schema.peerApprovalRequests)
|
||||||
|
.where(eq(schema.peerApprovalRequests.origin, sourceOrigin))
|
||||||
|
.run();
|
||||||
|
|
||||||
for (const uid of connectionManager.getAllOnlineUserIds()) {
|
for (const uid of connectionManager.getAllOnlineUserIds()) {
|
||||||
connectionManager.sendToUser(uid, {
|
connectionManager.sendToUser(uid, {
|
||||||
type: 'federation_peer_active' as const,
|
type: 'federation_peer_active' as const,
|
||||||
peerOrigin: sourceOrigin,
|
peerOrigin: sourceOrigin,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
onPeerActivated(existing.id, 'accept_awaiting_approval').catch(err =>
|
onPeerActivated(existing.id, 'accept_awaiting_approval').catch(err =>
|
||||||
console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval) failed:', err)
|
console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval) failed:', err)
|
||||||
);
|
);
|
||||||
|
|
||||||
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName });
|
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Token absent or mismatched. Cannot prove mutual approval.
|
||||||
|
if (autoAccept === 1) {
|
||||||
|
// We accept any inbound anyway — promoting here is no weaker than
|
||||||
|
// accepting a fresh handshake from a new peer. Clear the stored
|
||||||
|
// token (moot now) and proceed.
|
||||||
|
db.update(schema.federationPeers)
|
||||||
|
.set({
|
||||||
|
hmacSecret,
|
||||||
|
instanceName: reqInstanceName ?? null,
|
||||||
|
status: 'active',
|
||||||
|
lastSeenAt: Date.now(),
|
||||||
|
approvalToken: null,
|
||||||
|
})
|
||||||
|
.where(eq(schema.federationPeers.id, existing.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
for (const uid of connectionManager.getAllOnlineUserIds()) {
|
||||||
|
connectionManager.sendToUser(uid, {
|
||||||
|
type: 'federation_peer_active' as const,
|
||||||
|
peerOrigin: sourceOrigin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
|
||||||
|
onPeerActivated(existing.id, 'accept_awaiting_approval_fallback').catch(err =>
|
||||||
|
console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval fallback) failed:', err)
|
||||||
|
);
|
||||||
|
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName });
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoAccept=0 + unverifiable inbound → queue as new approval-request.
|
||||||
|
// Existing awaiting_approval row stays untouched; the new approval-
|
||||||
|
// request lets the local admin decide whether to honor this inbound.
|
||||||
|
return queueApprovalRequest(db, reply, sourceOrigin, hmacSecret, reqInstanceName ?? null);
|
||||||
|
}
|
||||||
// Pending — update with new secret and activate
|
// Pending — update with new secret and activate
|
||||||
db.update(schema.federationPeers)
|
db.update(schema.federationPeers)
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type PeerActivationReason =
|
|||||||
| 'initiate_accepted'
|
| 'initiate_accepted'
|
||||||
| 'accept_rejected_override'
|
| 'accept_rejected_override'
|
||||||
| 'accept_awaiting_approval'
|
| 'accept_awaiting_approval'
|
||||||
|
| 'accept_awaiting_approval_fallback'
|
||||||
| 'accept_pending'
|
| 'accept_pending'
|
||||||
| 'accept_new'
|
| 'accept_new'
|
||||||
| 'approval_handshake'
|
| 'approval_handshake'
|
||||||
|
|||||||
Reference in New Issue
Block a user