fix(federation): handshake sourceOrigin honors PUBLIC_ORIGIN (align with S2S auth origin)
This commit is contained in:
@@ -41,11 +41,16 @@ vi.mock('../utils/auth.js', () => ({
|
|||||||
requireAdmin: async () => {},
|
requireAdmin: async () => {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// getOurOrigin returns an origin DISTINCT from `https://${config.domain}`
|
||||||
|
// (config.domain === 'local.example'). This simulates PUBLIC_ORIGIN being set
|
||||||
|
// to something other than https://DOMAIN — the exact configuration that exposed
|
||||||
|
// the handshake/S2S-auth origin desync (BUG: resolveLocalOrigin used DOMAIN).
|
||||||
|
const PUBLIC_ORIGIN = 'https://public.example';
|
||||||
vi.mock('../utils/federationAuth.js', async () => {
|
vi.mock('../utils/federationAuth.js', async () => {
|
||||||
const actual = await vi.importActual<typeof import('../utils/federationAuth.js')>('../utils/federationAuth.js');
|
const actual = await vi.importActual<typeof import('../utils/federationAuth.js')>('../utils/federationAuth.js');
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
getOurOrigin: () => 'https://local.example',
|
getOurOrigin: () => 'https://public.example',
|
||||||
generateHmacSecret: () => 'mock-generated-secret',
|
generateHmacSecret: () => 'mock-generated-secret',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -134,6 +139,39 @@ describe('POST /api/federation/peer/initiate — 202 token capture & 200 clear',
|
|||||||
expect(peer?.approvalToken).toBe(remoteToken);
|
expect(peer?.approvalToken).toBe(remoteToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handshake sourceOrigin equals getOurOrigin() (honors PUBLIC_ORIGIN), not https://DOMAIN', async () => {
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ accepted: true, instanceName: 'Remote' }),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/federation/peer/initiate',
|
||||||
|
payload: { remoteOrigin: 'https://remote.example' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// Locate the outbound handshake call to the remote /peer/accept endpoint.
|
||||||
|
const acceptCall = fetchSpy.mock.calls.find(([url]) =>
|
||||||
|
String(url) === 'https://remote.example/api/federation/peer/accept',
|
||||||
|
);
|
||||||
|
expect(acceptCall).toBeDefined();
|
||||||
|
|
||||||
|
const init = acceptCall![1] as RequestInit;
|
||||||
|
const body = JSON.parse(String(init.body)) as { sourceOrigin: string };
|
||||||
|
|
||||||
|
// The handshake sourceOrigin MUST match the origin used for S2S auth
|
||||||
|
// (getOurOrigin / PUBLIC_ORIGIN), so the responder keys the peer row by the
|
||||||
|
// same value it will later see in X-Federation-Origin.
|
||||||
|
expect(body.sourceOrigin).toBe(PUBLIC_ORIGIN);
|
||||||
|
// And it must NOT fall back to https://DOMAIN when PUBLIC_ORIGIN differs.
|
||||||
|
expect(body.sourceOrigin).not.toBe('https://local.example');
|
||||||
|
});
|
||||||
|
|
||||||
it('on 200 from remote, activates and clears approvalToken', async () => {
|
it('on 200 from remote, activates and clears approvalToken', async () => {
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
new Response(
|
new Response(
|
||||||
|
|||||||
@@ -62,20 +62,17 @@ function sanitizePeer(row: typeof schema.federationPeers.$inferSelect): Sanitize
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine this instance's public origin.
|
* Determine this instance's public origin for the peering handshake.
|
||||||
* Prefer the explicit DOMAIN env var; fall back to the request Host header.
|
*
|
||||||
|
* Delegates to `getOurOrigin()` so the handshake `sourceOrigin` is IDENTICAL to
|
||||||
|
* the `X-Federation-Origin` value used for authenticated S2S requests. This
|
||||||
|
* honors `PUBLIC_ORIGIN` (getOurOrigin's precedence: PUBLIC_ORIGIN →
|
||||||
|
* `https://${DOMAIN}` → `http://localhost:${PORT}`). Using DOMAIN directly here
|
||||||
|
* previously desynced the responder's peer-row key from the auth origin,
|
||||||
|
* causing permanent `403 Not peered` whenever PUBLIC_ORIGIN != https://DOMAIN.
|
||||||
*/
|
*/
|
||||||
function resolveLocalOrigin(request: { headers: Record<string, string | string[] | undefined> }): string {
|
function resolveLocalOrigin(): string {
|
||||||
if (config.domain) {
|
return getOurOrigin();
|
||||||
return `https://${config.domain}`;
|
|
||||||
}
|
|
||||||
const hostHeader = request.headers.host;
|
|
||||||
const host = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader;
|
|
||||||
if (!host) {
|
|
||||||
throw new Error('Cannot determine local origin: no DOMAIN configured and no Host header');
|
|
||||||
}
|
|
||||||
const protocol = (request as Record<string, unknown>).protocol === 'https' ? 'https' : 'http';
|
|
||||||
return `${protocol}://${host}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -861,7 +858,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
let localOrigin: string;
|
let localOrigin: string;
|
||||||
try {
|
try {
|
||||||
localOrigin = resolveLocalOrigin(request);
|
localOrigin = resolveLocalOrigin();
|
||||||
} catch {
|
} catch {
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
error: 'Cannot determine local instance origin. Set the DOMAIN environment variable.',
|
error: 'Cannot determine local instance origin. Set the DOMAIN environment variable.',
|
||||||
@@ -1883,7 +1880,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
let localOrigin: string;
|
let localOrigin: string;
|
||||||
try {
|
try {
|
||||||
localOrigin = resolveLocalOrigin(request);
|
localOrigin = resolveLocalOrigin();
|
||||||
} catch {
|
} catch {
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
error: 'Cannot determine local instance origin. Set the DOMAIN environment variable.',
|
error: 'Cannot determine local instance origin. Set the DOMAIN environment variable.',
|
||||||
@@ -2138,7 +2135,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const newSecret = generateHmacSecret();
|
const newSecret = generateHmacSecret();
|
||||||
let localOrigin: string;
|
let localOrigin: string;
|
||||||
try {
|
try {
|
||||||
localOrigin = resolveLocalOrigin(request);
|
localOrigin = resolveLocalOrigin();
|
||||||
} catch {
|
} catch {
|
||||||
return reply.code(500).send({
|
return reply.code(500).send({
|
||||||
error: 'Cannot determine local instance origin. Set the DOMAIN environment variable.',
|
error: 'Cannot determine local instance origin. Set the DOMAIN environment variable.',
|
||||||
@@ -3073,7 +3070,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
let localOrigin: string;
|
let localOrigin: string;
|
||||||
try {
|
try {
|
||||||
localOrigin = resolveLocalOrigin(request);
|
localOrigin = resolveLocalOrigin();
|
||||||
} catch {
|
} catch {
|
||||||
localOrigin = config.domain ? `https://${config.domain}` : '';
|
localOrigin = config.domain ? `https://${config.domain}` : '';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user