254 lines
8.9 KiB
TypeScript
254 lines
8.9 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const mocks = vi.hoisted(() => {
|
|
const agentInstances: Array<{
|
|
options: Record<string, unknown>;
|
|
close: ReturnType<typeof vi.fn>;
|
|
}> = [];
|
|
|
|
return {
|
|
lookup: vi.fn(),
|
|
fetch: vi.fn(),
|
|
agentInstances,
|
|
};
|
|
});
|
|
|
|
vi.mock('node:dns', () => ({
|
|
promises: { lookup: mocks.lookup },
|
|
}));
|
|
|
|
vi.mock('undici', () => ({
|
|
Agent: class MockAgent {
|
|
readonly close = vi.fn().mockResolvedValue(undefined);
|
|
|
|
constructor(readonly options: Record<string, unknown>) {
|
|
mocks.agentInstances.push(this);
|
|
}
|
|
},
|
|
fetch: mocks.fetch,
|
|
}));
|
|
|
|
const originalAllowLocalNetworks = process.env.ALLOW_LOCAL_NETWORKS;
|
|
|
|
describe('public URL pinned fetch', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
mocks.lookup.mockReset();
|
|
mocks.fetch.mockReset();
|
|
mocks.agentInstances.length = 0;
|
|
delete process.env.ALLOW_LOCAL_NETWORKS;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalAllowLocalNetworks === undefined) {
|
|
delete process.env.ALLOW_LOCAL_NETWORKS;
|
|
} else {
|
|
process.env.ALLOW_LOCAL_NETWORKS = originalAllowLocalNetworks;
|
|
}
|
|
});
|
|
|
|
it('accepts only ordinary global-unicast IP literals', async () => {
|
|
const { resolvePublicTarget } = await import('@/lib/server/public-url-fetch');
|
|
|
|
await expect(resolvePublicTarget('https://8.8.8.8/media.png')).resolves.toMatchObject({
|
|
hostname: '8.8.8.8',
|
|
addresses: [{ address: '8.8.8.8', family: 4 }],
|
|
});
|
|
await expect(
|
|
resolvePublicTarget('https://[2606:4700:4700::1111]/media.png'),
|
|
).resolves.toMatchObject({
|
|
hostname: '2606:4700:4700::1111',
|
|
addresses: [{ address: '2606:4700:4700::1111', family: 6 }],
|
|
});
|
|
|
|
for (const target of [
|
|
'http://127.0.0.1/',
|
|
'http://100.100.100.200/latest/meta-data/',
|
|
'http://198.18.0.1/',
|
|
'http://203.0.113.10/',
|
|
'http://224.0.0.1/',
|
|
'http://[::ffff:127.0.0.1]/',
|
|
'http://[2001:db8::1]/',
|
|
'http://[2002:0808:0808::]/',
|
|
'http://[2001:4860:0:1::5efe:10.0.0.1]/',
|
|
]) {
|
|
await expect(resolvePublicTarget(target)).rejects.toMatchObject({
|
|
code: 'BLOCKED_TARGET',
|
|
});
|
|
}
|
|
expect(mocks.lookup).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects a hostname if any DNS answer is not global-unicast', async () => {
|
|
mocks.lookup.mockResolvedValue([
|
|
{ address: '93.184.216.34', family: 4 },
|
|
{ address: '100.100.100.200', family: 4 },
|
|
]);
|
|
const { resolvePublicTarget } = await import('@/lib/server/public-url-fetch');
|
|
|
|
await expect(resolvePublicTarget('https://mixed.example/media')).rejects.toMatchObject({
|
|
code: 'BLOCKED_TARGET',
|
|
});
|
|
});
|
|
|
|
it('never honors ALLOW_LOCAL_NETWORKS for the public media fetch policy', async () => {
|
|
process.env.ALLOW_LOCAL_NETWORKS = 'true';
|
|
mocks.lookup.mockResolvedValue([{ address: '192.168.1.20', family: 4 }]);
|
|
const { resolvePublicTarget } = await import('@/lib/server/public-url-fetch');
|
|
|
|
await expect(resolvePublicTarget('http://10.0.0.1/file')).rejects.toMatchObject({
|
|
code: 'BLOCKED_TARGET',
|
|
});
|
|
await expect(resolvePublicTarget('https://internal.example/file')).rejects.toMatchObject({
|
|
code: 'BLOCKED_TARGET',
|
|
});
|
|
});
|
|
|
|
it('pins the connector to the first verified DNS result set without a second lookup', async () => {
|
|
mocks.lookup
|
|
.mockResolvedValueOnce([
|
|
{ address: '93.184.216.34', family: 4 },
|
|
{ address: '2606:2800:220:1:248:1893:25c8:1946', family: 6 },
|
|
])
|
|
// A validate-then-fetch implementation would consume this rebinding answer.
|
|
.mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }]);
|
|
mocks.fetch.mockResolvedValue(new Response('media', { status: 200 }));
|
|
const { fetchPinnedPublicUrl } = await import('@/lib/server/public-url-fetch');
|
|
|
|
const result = await fetchPinnedPublicUrl('https://rebind.example/media.png');
|
|
|
|
expect(mocks.lookup).toHaveBeenCalledTimes(1);
|
|
expect(mocks.fetch).toHaveBeenCalledOnce();
|
|
expect(String(mocks.fetch.mock.calls[0][0])).toBe('https://rebind.example/media.png');
|
|
|
|
const agentOptions = mocks.agentInstances[0].options as {
|
|
connect: {
|
|
lookup: (
|
|
hostname: string,
|
|
options: { all: boolean },
|
|
callback: (error: Error | null, addresses: unknown) => void,
|
|
) => void;
|
|
};
|
|
};
|
|
const pinnedAddresses = await new Promise<unknown>((resolve, reject) => {
|
|
agentOptions.connect.lookup('rebind.example', { all: true }, (error, addresses) => {
|
|
if (error) reject(error);
|
|
else resolve(addresses);
|
|
});
|
|
});
|
|
expect(pinnedAddresses).toEqual([
|
|
{ address: '93.184.216.34', family: 4 },
|
|
{ address: '2606:2800:220:1:248:1893:25c8:1946', family: 6 },
|
|
]);
|
|
expect(mocks.lookup).toHaveBeenCalledTimes(1);
|
|
|
|
await result.dispose();
|
|
expect(mocks.agentInstances[0].close).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('fails a pinned lookup if a connector asks for a different hostname', async () => {
|
|
mocks.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
|
const { createPinnedLookup, resolvePublicTarget } =
|
|
await import('@/lib/server/public-url-fetch');
|
|
const target = await resolvePublicTarget('https://safe.example/media');
|
|
const pinnedLookup = createPinnedLookup(target);
|
|
|
|
const error = await new Promise<Error | null>((resolve) => {
|
|
pinnedLookup('changed.example', { all: true }, (lookupError) => resolve(lookupError));
|
|
});
|
|
expect(error).toMatchObject({ code: 'ENOTFOUND' });
|
|
});
|
|
|
|
it('closes the dispatcher and returns a generic typed error when transport fails', async () => {
|
|
mocks.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
|
mocks.fetch.mockRejectedValue(new Error('connect ECONNREFUSED 10.0.0.8:443'));
|
|
const { fetchPinnedPublicUrl } = await import('@/lib/server/public-url-fetch');
|
|
|
|
await expect(fetchPinnedPublicUrl('https://safe.example/media')).rejects.toMatchObject({
|
|
code: 'UPSTREAM_FAILURE',
|
|
message: 'Unable to fetch upstream media',
|
|
});
|
|
expect(mocks.agentInstances[0].close).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('passes trusted authentication headers without allowing Host to be overridden', async () => {
|
|
mocks.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
|
mocks.fetch.mockResolvedValue(new Response('ok'));
|
|
const { fetchPinnedPublicUrl } = await import('@/lib/server/public-url-fetch');
|
|
|
|
const result = await fetchPinnedPublicUrl('https://safe.example/models', {
|
|
headers: { Authorization: 'Bearer secret' },
|
|
});
|
|
expect(mocks.fetch).toHaveBeenCalledWith(
|
|
new URL('https://safe.example/models'),
|
|
expect.objectContaining({
|
|
headers: { Authorization: 'Bearer secret' },
|
|
redirect: 'manual',
|
|
}),
|
|
);
|
|
await result.dispose();
|
|
|
|
await expect(
|
|
fetchPinnedPublicUrl('https://safe.example/models', {
|
|
headers: { Host: 'internal.example' },
|
|
}),
|
|
).rejects.toMatchObject({ code: 'INVALID_URL' });
|
|
await expect(
|
|
fetchPinnedPublicUrl('https://safe.example/models', {
|
|
headers: { 'Content-Length': '1' },
|
|
}),
|
|
).rejects.toMatchObject({ code: 'INVALID_URL' });
|
|
expect(mocks.fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('supports a bounded PUT through the same pinned dispatcher', async () => {
|
|
mocks.lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
|
mocks.fetch.mockResolvedValue(new Response('', { status: 200 }));
|
|
const { fetchPinnedPublicUrl } = await import('@/lib/server/public-url-fetch');
|
|
const body = Buffer.from('document bytes');
|
|
|
|
const result = await fetchPinnedPublicUrl('https://upload.example/presigned?signature=secret', {
|
|
method: 'PUT',
|
|
body,
|
|
maxRequestBytes: body.byteLength,
|
|
maxResponseBytes: 1024,
|
|
headersTimeoutMs: 180_000,
|
|
bodyTimeoutMs: 60_000,
|
|
});
|
|
|
|
expect(mocks.lookup).toHaveBeenCalledTimes(1);
|
|
expect(mocks.fetch).toHaveBeenCalledWith(
|
|
new URL('https://upload.example/presigned?signature=secret'),
|
|
expect.objectContaining({
|
|
method: 'PUT',
|
|
body,
|
|
redirect: 'manual',
|
|
}),
|
|
);
|
|
expect(mocks.agentInstances[0].options).toMatchObject({
|
|
headersTimeout: 180_000,
|
|
bodyTimeout: 60_000,
|
|
maxResponseSize: 1024,
|
|
});
|
|
|
|
await result.dispose();
|
|
expect(mocks.agentInstances[0].close).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('rejects an oversized PUT before DNS or transport work', async () => {
|
|
const { fetchPinnedPublicUrl } = await import('@/lib/server/public-url-fetch');
|
|
|
|
await expect(
|
|
fetchPinnedPublicUrl('https://upload.example/presigned', {
|
|
method: 'PUT',
|
|
body: Buffer.from('too large'),
|
|
maxRequestBytes: 1,
|
|
}),
|
|
).rejects.toMatchObject({ code: 'REQUEST_TOO_LARGE' });
|
|
|
|
expect(mocks.lookup).not.toHaveBeenCalled();
|
|
expect(mocks.fetch).not.toHaveBeenCalled();
|
|
expect(mocks.agentInstances).toHaveLength(0);
|
|
});
|
|
});
|