338119ab77
* feat: implement private IP range request host validator for server-side HTTP requests * feat: add link-local address support * fix: use correct config keys * fix: if config missing use default loopback addresses * fix: re-use resolved address for connection * test: add unit coverage for private request filter and proxy interaction Agent-Logs-Url: https://github.com/SillyTavern/SillyTavern/sessions/1813593e-2263-45e2-aa53-74d39515f1df Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * test: remove request-proxy.test.js * perf: cache resolved matches * fix: remove unused import * fix: use proper ipv4 loopback cidr * fix: correct raiseError comment * test: uses tls.connect for secure endpoints * Implement private IP range request host validator Agent-Logs-Url: https://github.com/SillyTavern/SillyTavern/sessions/e76ba122-136e-43ad-b4bc-ea48a01fcdda Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * Revert "Implement private IP range request host validator" This reverts commit 14e271470227b485b7d23caac31a237abf9f7835. * fix: close request without sending status in CORS forwarding when headers were sent * fix: not enabled -> disabled * feat: add enableKeepAlive option to PrivateRequestAgent Co-authored-by: Copilot <copilot@github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com> Co-authored-by: Copilot <copilot@github.com>
131 lines
4.9 KiB
JavaScript
131 lines
4.9 KiB
JavaScript
import { describe, test, expect, jest, beforeAll, beforeEach, afterAll } from '@jest/globals';
|
|
|
|
const mockNetConnect = jest.fn(() => ({ type: 'net-socket' }));
|
|
const mockTlsConnect = jest.fn(() => ({ type: 'tls-socket' }));
|
|
const mockLookup = jest.fn();
|
|
|
|
jest.unstable_mockModule('node:net', () => ({
|
|
default: { connect: mockNetConnect },
|
|
}));
|
|
|
|
jest.unstable_mockModule('node:tls', () => ({
|
|
default: { connect: mockTlsConnect },
|
|
}));
|
|
|
|
jest.unstable_mockModule('node:dns', () => ({
|
|
default: { promises: { lookup: mockLookup } },
|
|
}));
|
|
|
|
jest.unstable_mockModule('../src/util.js', () => ({
|
|
color: {
|
|
red: text => text,
|
|
green: text => text,
|
|
blue: text => text,
|
|
yellow: text => text,
|
|
},
|
|
}));
|
|
|
|
jest.unstable_mockModule('../src/express-common.js', () => ({
|
|
filterValidIpPatterns: patterns => patterns,
|
|
}));
|
|
|
|
/** @type {import('../src/private-request-filter.js').default} */
|
|
let initPrivateRequestFilter;
|
|
/** @type {import('node:http').default} */
|
|
let http;
|
|
/** @type {import('node:https').default} */
|
|
let https;
|
|
let originalHttpGlobalAgent;
|
|
let originalHttpsGlobalAgent;
|
|
|
|
beforeAll(async () => {
|
|
({ default: initPrivateRequestFilter } = await import('../src/private-request-filter.js'));
|
|
({ default: http } = await import('node:http'));
|
|
({ default: https } = await import('node:https'));
|
|
originalHttpGlobalAgent = http.globalAgent;
|
|
originalHttpsGlobalAgent = https.globalAgent;
|
|
});
|
|
|
|
beforeEach(() => {
|
|
mockNetConnect.mockClear();
|
|
mockTlsConnect.mockClear();
|
|
mockLookup.mockReset();
|
|
http.globalAgent = originalHttpGlobalAgent;
|
|
https.globalAgent = originalHttpsGlobalAgent;
|
|
});
|
|
|
|
afterAll(() => {
|
|
http.globalAgent = originalHttpGlobalAgent;
|
|
https.globalAgent = originalHttpsGlobalAgent;
|
|
});
|
|
|
|
function initAgent({ privateAddressWhitelist = [], allowUnresolvedHosts = false } = {}) {
|
|
initPrivateRequestFilter({
|
|
listen: false,
|
|
enabled: true,
|
|
privateAddressWhitelist,
|
|
logBlocked: false,
|
|
logAllowed: false,
|
|
allowUnresolvedHosts,
|
|
});
|
|
|
|
return http.globalAgent;
|
|
}
|
|
|
|
describe('private request filter', () => {
|
|
test('allows direct private IP requests only when whitelisted', async () => {
|
|
const agent = initAgent({ privateAddressWhitelist: ['127.0.0.0/8'] });
|
|
await agent.connect({}, { host: '127.0.0.1', secureEndpoint: false });
|
|
|
|
expect(mockNetConnect).toHaveBeenCalledWith(expect.objectContaining({ host: '127.0.0.1' }));
|
|
|
|
const blockedAgent = initAgent({ privateAddressWhitelist: [] });
|
|
await expect(blockedAgent.connect({}, { host: '127.0.0.1', secureEndpoint: false }))
|
|
.rejects
|
|
.toThrow('Blocked request to private IP address: 127.0.0.1');
|
|
});
|
|
|
|
test('resolves hostnames and blocks when DNS returns private IP', async () => {
|
|
mockLookup.mockResolvedValue({ address: '192.168.1.8' });
|
|
const agent = initAgent();
|
|
|
|
await expect(agent.connect({}, { host: 'example.com', secureEndpoint: false }))
|
|
.rejects
|
|
.toThrow('Blocked request to private IP address: 192.168.1.8');
|
|
expect(mockNetConnect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('connects to resolved public IP to avoid hostname re-resolution', async () => {
|
|
mockLookup.mockResolvedValue({ address: '93.184.216.34' });
|
|
const agent = initAgent();
|
|
|
|
await agent.connect({}, { host: 'example.com', secureEndpoint: false });
|
|
|
|
expect(mockLookup).toHaveBeenCalledWith('example.com');
|
|
expect(mockNetConnect).toHaveBeenCalledWith(expect.objectContaining({ host: '93.184.216.34' }));
|
|
});
|
|
|
|
test('handles unresolved hosts according to allowUnresolvedHosts setting', async () => {
|
|
mockLookup.mockRejectedValue(new Error('lookup failed'));
|
|
const blockedAgent = initAgent({ allowUnresolvedHosts: false });
|
|
|
|
await expect(blockedAgent.connect({}, { host: 'missing-host.local', secureEndpoint: false }))
|
|
.rejects
|
|
.toThrow('Unable to resolve host: missing-host.local. Set privateAddressWhitelist.allowUnresolvedHosts to true to bypass this check.');
|
|
expect(mockNetConnect).not.toHaveBeenCalled();
|
|
|
|
const allowedAgent = initAgent({ allowUnresolvedHosts: true });
|
|
await allowedAgent.connect({}, { host: 'missing-host.local', secureEndpoint: false });
|
|
expect(mockNetConnect).toHaveBeenCalledWith(expect.objectContaining({ host: 'missing-host.local' }));
|
|
});
|
|
|
|
test('uses tls.connect for secure endpoints', async () => {
|
|
mockLookup.mockResolvedValue({ address: '93.184.216.34' });
|
|
const agent = initAgent();
|
|
await agent.connect({}, { host: 'example.com', secureEndpoint: true });
|
|
expect(mockLookup).toHaveBeenCalledWith('example.com');
|
|
expect(mockTlsConnect).toHaveBeenCalledWith(expect.objectContaining({ host: '93.184.216.34' }));
|
|
expect(mockNetConnect).not.toHaveBeenCalled();
|
|
});
|
|
});
|