mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
180 lines
7.6 KiB
TypeScript
180 lines
7.6 KiB
TypeScript
// @vitest-environment node
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { createServer, type Server } from 'node:http';
|
|
import { connect as netConnect, type AddressInfo } from 'node:net';
|
|
|
|
/**
|
|
* #1367: with NODE_USE_ENV_PROXY=1 and HTTP(S)_PROXY set, Node's own fetch
|
|
* goes through the proxy, but fetchPublicUrl (iCal subscriptions and other
|
|
* caller-supplied URLs) connected directly. It now resolves and checks the
|
|
* address itself, then asks the proxy for a tunnel to exactly that address,
|
|
* so the private-address guard still holds.
|
|
*
|
|
* The fake proxy below records every CONNECT and tunnels it to a local origin
|
|
* server, whatever address was asked for.
|
|
*/
|
|
|
|
const lookup = vi.fn();
|
|
|
|
vi.mock('node:dns/promises', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('node:dns/promises')>();
|
|
return {
|
|
...actual,
|
|
default: { ...actual, lookup: (...args: unknown[]) => lookup(...args) },
|
|
lookup: (...args: unknown[]) => lookup(...args),
|
|
};
|
|
});
|
|
|
|
// TEST-NET-1: passes the guard, but nothing answers there, so only the proxy
|
|
// can make a request to it succeed.
|
|
const PUBLIC_IP = '192.0.2.10';
|
|
|
|
describe('fetchPublicUrl behind an outbound proxy', () => {
|
|
let origin: Server;
|
|
let originPort: number;
|
|
let originHosts: string[] = [];
|
|
let proxy: Server;
|
|
let proxyPort: number;
|
|
let tunnels: Array<{ target: string; auth: string | undefined }> = [];
|
|
|
|
beforeAll(async () => {
|
|
origin = createServer((req, res) => {
|
|
originHosts.push(req.headers.host ?? '');
|
|
res.writeHead(200, { 'content-type': 'text/calendar' });
|
|
res.end('BEGIN:VCALENDAR\nEND:VCALENDAR\n');
|
|
});
|
|
await new Promise<void>((resolve) => origin.listen(0, '127.0.0.1', resolve));
|
|
originPort = (origin.address() as AddressInfo).port;
|
|
|
|
proxy = createServer((_req, res) => { res.writeHead(405); res.end(); });
|
|
proxy.on('connect', (req, client, head) => {
|
|
tunnels.push({ target: req.url ?? '', auth: req.headers['proxy-authorization'] });
|
|
const upstream = netConnect(originPort, '127.0.0.1', () => {
|
|
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
upstream.write(head);
|
|
upstream.pipe(client);
|
|
client.pipe(upstream);
|
|
});
|
|
upstream.on('error', () => client.destroy());
|
|
client.on('error', () => upstream.destroy());
|
|
});
|
|
await new Promise<void>((resolve) => proxy.listen(0, '127.0.0.1', resolve));
|
|
proxyPort = (proxy.address() as AddressInfo).port;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
proxy.closeAllConnections();
|
|
origin.closeAllConnections();
|
|
await new Promise<void>((resolve) => proxy.close(() => resolve()));
|
|
await new Promise<void>((resolve) => origin.close(() => resolve()));
|
|
});
|
|
|
|
beforeEach(() => {
|
|
lookup.mockReset();
|
|
originHosts = [];
|
|
tunnels = [];
|
|
for (const name of ['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY', 'no_proxy', 'NO_PROXY', 'NODE_USE_ENV_PROXY']) {
|
|
vi.stubEnv(name, undefined);
|
|
}
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
const useProxy = (auth = '') => {
|
|
vi.stubEnv('NODE_USE_ENV_PROXY', '1');
|
|
vi.stubEnv('HTTP_PROXY', `http://${auth}127.0.0.1:${proxyPort}`);
|
|
};
|
|
|
|
it('tunnels through the proxy to the address it checked, keeping the Host header', async () => {
|
|
useProxy();
|
|
lookup.mockResolvedValue([{ address: PUBLIC_IP, family: 4 }]);
|
|
const { fetchPublicUrl } = await import('@/lib/security/url-guard');
|
|
|
|
const res = await fetchPublicUrl(`http://cal.example:${originPort}/a.ics`, { signal: AbortSignal.timeout(3000) });
|
|
expect(res.status).toBe(200);
|
|
expect(await res.text()).toContain('BEGIN:VCALENDAR');
|
|
expect(tunnels.map((t) => t.target)).toEqual([`${PUBLIC_IP}:${originPort}`]);
|
|
expect(originHosts).toEqual([`cal.example:${originPort}`]);
|
|
});
|
|
|
|
it('sends the proxy credentials from the proxy URL', async () => {
|
|
useProxy('us%40er:p%3Ass@');
|
|
lookup.mockResolvedValue([{ address: PUBLIC_IP, family: 4 }]);
|
|
const { fetchPublicUrl } = await import('@/lib/security/url-guard');
|
|
|
|
const res = await fetchPublicUrl(`http://cal.example:${originPort}/a.ics`, { signal: AbortSignal.timeout(3000) });
|
|
await res.text();
|
|
expect(tunnels[0]?.auth).toBe(`Basic ${Buffer.from('us@er:p:ss').toString('base64')}`);
|
|
});
|
|
|
|
it('refuses a name that resolves to a private address, with a proxy', async () => {
|
|
useProxy();
|
|
lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
|
|
const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard');
|
|
|
|
await expect(fetchPublicUrl(`http://rebind.example:${originPort}/a.ics`)).rejects.toBeInstanceOf(DisallowedUrlError);
|
|
expect(tunnels).toEqual([]);
|
|
expect(originHosts).toEqual([]);
|
|
});
|
|
|
|
it('refuses a private literal address, with a proxy', async () => {
|
|
useProxy();
|
|
const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard');
|
|
|
|
await expect(fetchPublicUrl(`http://10.1.2.3:${originPort}/a.ics`)).rejects.toBeInstanceOf(DisallowedUrlError);
|
|
expect(tunnels).toEqual([]);
|
|
});
|
|
|
|
it('refuses a name that resolves to a private address, without a proxy', async () => {
|
|
lookup.mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
|
|
const { fetchPublicUrl, DisallowedUrlError } = await import('@/lib/security/url-guard');
|
|
|
|
await expect(fetchPublicUrl(`http://rebind.example:${originPort}/a.ics`)).rejects.toBeInstanceOf(DisallowedUrlError);
|
|
expect(tunnels).toEqual([]);
|
|
expect(originHosts).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('which proxy a URL uses', () => {
|
|
beforeEach(() => {
|
|
for (const name of ['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY', 'no_proxy', 'NO_PROXY', 'NODE_USE_ENV_PROXY']) {
|
|
vi.stubEnv(name, undefined);
|
|
}
|
|
});
|
|
afterEach(() => vi.unstubAllEnvs());
|
|
|
|
it('uses none unless Node is told to use the environment proxy, as Node fetch does', async () => {
|
|
vi.stubEnv('HTTPS_PROXY', 'http://proxy.corp:3128');
|
|
const { envProxyFor } = await import('@/lib/security/url-guard');
|
|
expect(envProxyFor(new URL('https://cal.example/a.ics'))).toBeNull();
|
|
vi.stubEnv('NODE_USE_ENV_PROXY', '1');
|
|
expect(envProxyFor(new URL('https://cal.example/a.ics'))?.href).toBe('http://proxy.corp:3128/');
|
|
});
|
|
|
|
it('picks the proxy by scheme, https falling back to the http proxy', async () => {
|
|
vi.stubEnv('NODE_USE_ENV_PROXY', '1');
|
|
vi.stubEnv('HTTP_PROXY', 'http://plain.corp:3128');
|
|
const { envProxyFor } = await import('@/lib/security/url-guard');
|
|
expect(envProxyFor(new URL('https://cal.example/a.ics'))?.host).toBe('plain.corp:3128');
|
|
vi.stubEnv('https_proxy', 'http://tls.corp:3128');
|
|
expect(envProxyFor(new URL('https://cal.example/a.ics'))?.host).toBe('tls.corp:3128');
|
|
expect(envProxyFor(new URL('http://cal.example/a.ics'))?.host).toBe('plain.corp:3128');
|
|
});
|
|
|
|
it('skips hosts listed in NO_PROXY', async () => {
|
|
vi.stubEnv('NODE_USE_ENV_PROXY', '1');
|
|
vi.stubEnv('HTTP_PROXY', 'http://plain.corp:3128');
|
|
vi.stubEnv('NO_PROXY', 'intra.example, .corp.example,cal.example:8443');
|
|
const { envProxyFor } = await import('@/lib/security/url-guard');
|
|
expect(envProxyFor(new URL('http://intra.example/a.ics'))).toBeNull();
|
|
expect(envProxyFor(new URL('http://a.intra.example/a.ics'))).toBeNull();
|
|
expect(envProxyFor(new URL('http://x.corp.example/a.ics'))).toBeNull();
|
|
expect(envProxyFor(new URL('https://cal.example:8443/a.ics'))).toBeNull();
|
|
expect(envProxyFor(new URL('https://cal.example/a.ics'))).not.toBeNull();
|
|
expect(envProxyFor(new URL('http://other.example/a.ics'))).not.toBeNull();
|
|
vi.stubEnv('NO_PROXY', '*');
|
|
expect(envProxyFor(new URL('http://other.example/a.ics'))).toBeNull();
|
|
});
|
|
});
|