276 lines
8.9 KiB
TypeScript
276 lines
8.9 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import {
|
|
normalizeDomain,
|
|
isIPAddress,
|
|
isLoopback,
|
|
matchesBlocked,
|
|
checkBlocked,
|
|
loadBlocked,
|
|
parseConnectTarget,
|
|
type ProxyOptions,
|
|
} from './proxy';
|
|
|
|
describe('parseConnectTarget', () => {
|
|
it('parses host:port', () => {
|
|
expect(parseConnectTarget('example.com:443')).toEqual({ host: 'example.com', port: 443 });
|
|
});
|
|
|
|
it('parses host:port with non-standard port', () => {
|
|
expect(parseConnectTarget('api.internal:8443')).toEqual({ host: 'api.internal', port: 8443 });
|
|
});
|
|
|
|
it('parses bracketed IPv6 with port', () => {
|
|
expect(parseConnectTarget('[2001:db8::1]:443')).toEqual({ host: '2001:db8::1', port: 443 });
|
|
expect(parseConnectTarget('[::1]:8080')).toEqual({ host: '::1', port: 8080 });
|
|
});
|
|
|
|
it('rejects invalid targets', () => {
|
|
expect(parseConnectTarget(':443')).toEqual({ host: '', port: 0 });
|
|
expect(parseConnectTarget('')).toEqual({ host: '', port: 0 });
|
|
expect(parseConnectTarget('example.com')).toEqual({ host: '', port: 0 });
|
|
expect(parseConnectTarget('example.com:0')).toEqual({ host: '', port: 0 });
|
|
expect(parseConnectTarget('example.com:99999')).toEqual({ host: '', port: 0 });
|
|
expect(parseConnectTarget('example.com:abc')).toEqual({ host: '', port: 0 });
|
|
});
|
|
});
|
|
|
|
describe('normalizeDomain', () => {
|
|
it('lowercases and trims', () => {
|
|
expect(normalizeDomain('GitHub.COM')).toBe('github.com');
|
|
expect(normalizeDomain(' example.com ')).toBe('example.com');
|
|
});
|
|
|
|
it('strips trailing dot', () => {
|
|
expect(normalizeDomain('example.com.')).toBe('example.com');
|
|
});
|
|
|
|
it('strips IPv6 brackets', () => {
|
|
expect(normalizeDomain('[::1]')).toBe('::1');
|
|
});
|
|
|
|
it('handles punycode via URL constructor', () => {
|
|
expect(normalizeDomain('MÜNCHEN.de')).toBe(new URL('http://münchen.de').hostname);
|
|
});
|
|
|
|
it('handles plain domain', () => {
|
|
expect(normalizeDomain('api.example.com')).toBe('api.example.com');
|
|
});
|
|
});
|
|
|
|
describe('isIPAddress', () => {
|
|
it('detects IPv4', () => {
|
|
expect(isIPAddress('192.168.1.1')).toBe(true);
|
|
expect(isIPAddress('10.0.0.1')).toBe(true);
|
|
expect(isIPAddress('127.0.0.1')).toBe(true);
|
|
});
|
|
|
|
it('detects IPv6', () => {
|
|
expect(isIPAddress('::1')).toBe(true);
|
|
expect(isIPAddress('2001:db8::1')).toBe(true);
|
|
});
|
|
|
|
it('rejects domains', () => {
|
|
expect(isIPAddress('example.com')).toBe(false);
|
|
expect(isIPAddress('localhost')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isLoopback', () => {
|
|
it('matches loopback addresses', () => {
|
|
expect(isLoopback('localhost')).toBe(true);
|
|
expect(isLoopback('LOCALHOST')).toBe(true);
|
|
expect(isLoopback('127.0.0.1')).toBe(true);
|
|
expect(isLoopback('127.255.255.255')).toBe(true);
|
|
expect(isLoopback('::1')).toBe(true);
|
|
expect(isLoopback('[::1]')).toBe(true);
|
|
});
|
|
|
|
it('rejects non-loopback', () => {
|
|
expect(isLoopback('192.168.1.1')).toBe(false);
|
|
expect(isLoopback('example.com')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('matchesBlocked', () => {
|
|
const blocked = new Set(['evil.com', 'pastebin.com', 'bad.example.org']);
|
|
|
|
it('blocks exact domain and subdomains', () => {
|
|
expect(matchesBlocked('evil.com', blocked)).toBe(true);
|
|
expect(matchesBlocked('www.evil.com', blocked)).toBe(true);
|
|
expect(matchesBlocked('deep.sub.evil.com', blocked)).toBe(true);
|
|
});
|
|
|
|
it('allows non-blocked domains', () => {
|
|
expect(matchesBlocked('github.com', blocked)).toBe(false);
|
|
expect(matchesBlocked('example.com', blocked)).toBe(false);
|
|
});
|
|
|
|
it('does not block parent of blocked domain', () => {
|
|
expect(matchesBlocked('example.org', blocked)).toBe(false);
|
|
expect(matchesBlocked('com', blocked)).toBe(false);
|
|
});
|
|
|
|
it('is case-insensitive and handles trailing dot', () => {
|
|
expect(matchesBlocked('EVIL.COM', blocked)).toBe(true);
|
|
expect(matchesBlocked('evil.com.', blocked)).toBe(true);
|
|
});
|
|
|
|
it('handles empty blocklist', () => {
|
|
expect(matchesBlocked('anything.com', new Set())).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('loadBlocked', () => {
|
|
it('returns empty set for undefined or missing path', () => {
|
|
expect(loadBlocked(undefined).size).toBe(0);
|
|
expect(loadBlocked('/nonexistent/path/blocked.txt').size).toBe(0);
|
|
});
|
|
|
|
it('loads domains from file, skipping comments and blanks', () => {
|
|
const tmpFile = path.join(os.tmpdir(), `blocked-test-${Date.now()}.txt`);
|
|
fs.writeFileSync(
|
|
tmpFile,
|
|
`# comment
|
|
evil.com
|
|
pastebin.com
|
|
|
|
# another comment
|
|
transfer.sh
|
|
`
|
|
);
|
|
try {
|
|
const result = loadBlocked(tmpFile);
|
|
expect(result.size).toBe(3);
|
|
expect(result.has('evil.com')).toBe(true);
|
|
expect(result.has('pastebin.com')).toBe(true);
|
|
expect(result.has('transfer.sh')).toBe(true);
|
|
} finally {
|
|
fs.unlinkSync(tmpFile);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('checkBlocked', () => {
|
|
const blocked = new Set(['evil.com', 'pastebin.com']);
|
|
const noLD = undefined;
|
|
const noLDCache = undefined;
|
|
const defaultOptions: ProxyOptions = {};
|
|
|
|
it('allows normal HTTPS traffic', async () => {
|
|
const result = await checkBlocked('github.com', 443, blocked, noLD, noLDCache, defaultOptions);
|
|
expect(result.blocked).toBe(false);
|
|
});
|
|
|
|
it('blocks domains and subdomains on the blocklist', async () => {
|
|
const exact = await checkBlocked('evil.com', 443, blocked, noLD, noLDCache, defaultOptions);
|
|
expect(exact).toEqual({ blocked: true, reason: 'blocklist' });
|
|
|
|
const sub = await checkBlocked('api.evil.com', 443, blocked, noLD, noLDCache, defaultOptions);
|
|
expect(sub).toEqual({ blocked: true, reason: 'blocklist' });
|
|
});
|
|
|
|
it('blocks raw IP addresses by default, allows when opted in', async () => {
|
|
const blocked_ = await checkBlocked(
|
|
'93.184.216.34',
|
|
443,
|
|
blocked,
|
|
noLD,
|
|
noLDCache,
|
|
defaultOptions
|
|
);
|
|
expect(blocked_).toEqual({ blocked: true, reason: 'ip-address' });
|
|
|
|
const allowed = await checkBlocked('93.184.216.34', 443, blocked, noLD, noLDCache, {
|
|
allowIPAddresses: true,
|
|
});
|
|
expect(allowed.blocked).toBe(false);
|
|
});
|
|
|
|
it('does not block loopback by default, blocks when opted in', async () => {
|
|
const allowed = await checkBlocked('localhost', 8080, blocked, noLD, noLDCache, defaultOptions);
|
|
expect(allowed.blocked).toBe(false);
|
|
|
|
const blocked_ = await checkBlocked('localhost', 8080, blocked, noLD, noLDCache, {
|
|
blockLoopback: true,
|
|
});
|
|
expect(blocked_).toEqual({ blocked: true, reason: 'loopback' });
|
|
|
|
const blocked127 = await checkBlocked('127.0.0.1', 8080, blocked, noLD, noLDCache, {
|
|
blockLoopback: true,
|
|
});
|
|
expect(blocked127).toEqual({ blocked: true, reason: 'loopback' });
|
|
});
|
|
|
|
it('allows SSH to default git hosts', async () => {
|
|
for (const host of ['github.com', 'gitlab.com', 'bitbucket.org', 'ssh.dev.azure.com']) {
|
|
const result = await checkBlocked(host, 22, blocked, noLD, noLDCache, defaultOptions);
|
|
expect(result.blocked).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('blocks SSH to non-git hosts on all SSH ports', async () => {
|
|
for (const port of [22, 2222, 7999]) {
|
|
const result = await checkBlocked(
|
|
'random-server.com',
|
|
port,
|
|
blocked,
|
|
noLD,
|
|
noLDCache,
|
|
defaultOptions
|
|
);
|
|
expect(result).toEqual({ blocked: true, reason: 'ssh-non-git-host' });
|
|
}
|
|
});
|
|
|
|
it('blocks all SSH when allowSSH is false', async () => {
|
|
const result = await checkBlocked('github.com', 22, blocked, noLD, noLDCache, {
|
|
allowSSH: false,
|
|
});
|
|
expect(result).toEqual({ blocked: true, reason: 'ssh-disabled' });
|
|
});
|
|
|
|
it('allows SSH to any host when allowSSHToAllHosts is true', async () => {
|
|
const result = await checkBlocked('random-server.com', 22, blocked, noLD, noLDCache, {
|
|
allowSSHToAllHosts: true,
|
|
});
|
|
expect(result.blocked).toBe(false);
|
|
});
|
|
|
|
it('respects custom git hosts list', async () => {
|
|
const opts = { gitHosts: ['gitea.internal.com'] };
|
|
const allowed = await checkBlocked('gitea.internal.com', 22, blocked, noLD, noLDCache, opts);
|
|
expect(allowed.blocked).toBe(false);
|
|
|
|
const denied = await checkBlocked('github.com', 22, blocked, noLD, noLDCache, opts);
|
|
expect(denied).toEqual({ blocked: true, reason: 'ssh-non-git-host' });
|
|
});
|
|
|
|
it('SSH rules only apply to SSH ports', async () => {
|
|
const result = await checkBlocked(
|
|
'random-server.com',
|
|
443,
|
|
blocked,
|
|
noLD,
|
|
noLDCache,
|
|
defaultOptions
|
|
);
|
|
expect(result.blocked).toBe(false);
|
|
});
|
|
|
|
it('checks blocking layers in priority order', async () => {
|
|
// loopback before IP
|
|
const loopback = await checkBlocked('127.0.0.1', 443, blocked, noLD, noLDCache, {
|
|
blockLoopback: true,
|
|
allowIPAddresses: false,
|
|
});
|
|
expect(loopback.reason).toBe('loopback');
|
|
|
|
// blocklist before SSH
|
|
const blocklist = await checkBlocked('evil.com', 22, blocked, noLD, noLDCache, defaultOptions);
|
|
expect(blocklist.reason).toBe('blocklist');
|
|
});
|
|
});
|