Files
proffesor-for-testing__agen…/tests/hooks/security/ssrf-guard.test.ts
Dragan Spiridonov 65457ccc84 fix: add kill switches, missing tests, and log redirect for CC-internals (IMP-00–10)
Address verification findings from user-perspective review of IMP-00 through IMP-10:

- Implement 8 missing env var kill switches (AQE_MICROCOMPACT, AQE_RETRY_DISABLED,
  AQE_SESSION_DURABILITY, AQE_PROMPT_CACHE_LATCH, AQE_FAST_PATHS,
  AQE_HOOKS_SSRF_DISABLED, AQE_COMPACTION_DISABLED, AQE_PLUGINS_DISABLED)
- Add 5 missing test files: parallel-prefetch, session-durability-middleware,
  and plugin sources (local, github, npm) — 40 new tests
- Add kill switch test coverage for all 8 env vars — 10 new tests
- Fix stdout log leak: redirect [ParserRegistry], [ContinueGateIntegration],
  timestamped logs, and other internal prefixes to stderr
- Update plan doc verification matrix with correct test file names (37 files)
- Add user-perspective verification report

466 tests pass across 37 IMP test files. Build clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 06:46:57 +00:00

167 lines
5.6 KiB
TypeScript

/**
* Tests for ssrf-guard: isPrivateIp and validateHookUrl
* @see IMP-07 Hook Security Hardening
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isPrivateIp, validateHookUrl } from '../../../src/hooks/security/ssrf-guard.js';
// =============================================================================
// Mock dns/promises so we can control DNS resolution in tests
// =============================================================================
vi.mock('dns/promises', () => ({
lookup: vi.fn(),
}));
// Import the mock after vi.mock so we can control return values per test
import { lookup as mockLookup } from 'dns/promises';
// =============================================================================
// isPrivateIp
// =============================================================================
describe('isPrivateIp', () => {
it.each([
['10.0.0.1', true],
['10.255.255.255', true],
['172.16.0.1', true],
['172.31.255.255', true],
['192.168.1.1', true],
['192.168.0.0', true],
['127.0.0.1', true],
['127.255.255.255', true],
['0.0.0.0', true],
['169.254.1.1', true],
['169.254.169.254', true],
['::1', true],
['fc00::', true],
['fe80::1', true],
['fd12:3456:789a::1', true],
])('identifies %s as private (%s)', (ip, expected) => {
expect(isPrivateIp(ip)).toBe(expected);
});
it.each([
['8.8.8.8', false],
['1.1.1.1', false],
['203.0.113.50', false],
['172.32.0.1', false], // just outside 172.16-31 range
['11.0.0.1', false],
['2001:db8::1', false],
])('identifies %s as public (%s)', (ip, expected) => {
expect(isPrivateIp(ip)).toBe(expected);
});
});
// =============================================================================
// validateHookUrl
// =============================================================================
describe('validateHookUrl', () => {
beforeEach(() => {
vi.mocked(mockLookup).mockReset();
});
// ---- Invalid URLs ----
it('returns error for an invalid URL', async () => {
const result = await validateHookUrl('not-a-url');
expect(result.safe).toBe(false);
expect(result.reason).toContain('Invalid URL');
});
it('returns error for an empty string', async () => {
const result = await validateHookUrl('');
expect(result.safe).toBe(false);
expect(result.reason).toContain('Invalid URL');
});
// ---- Direct IP addresses ----
it.each([
'http://10.0.0.1/hook',
'http://172.16.0.1:8080/callback',
'http://192.168.1.1/api',
'http://127.0.0.1:3000/webhook',
'http://169.254.169.254/latest/meta-data/',
])('blocks direct private IP: %s', async (url) => {
const result = await validateHookUrl(url);
expect(result.safe).toBe(false);
expect(result.reason).toContain('Private IP blocked');
});
it('blocks IPv6 loopback', async () => {
const result = await validateHookUrl('http://[::1]:8080/hook');
expect(result.safe).toBe(false);
expect(result.reason).toContain('Private IP blocked');
});
it.each([
'http://8.8.8.8/hook',
'https://1.1.1.1/callback',
])('allows direct public IP: %s', async (url) => {
const result = await validateHookUrl(url);
expect(result.safe).toBe(true);
expect(result.reason).toBeUndefined();
});
// ---- DNS resolution ----
it('allows a hostname that resolves to a public IP', async () => {
vi.mocked(mockLookup).mockResolvedValue({ address: '93.184.216.34', family: 4 });
const result = await validateHookUrl('https://example.com/webhook');
expect(result.safe).toBe(true);
});
it('blocks a hostname that resolves to a private IP (DNS rebinding)', async () => {
vi.mocked(mockLookup).mockResolvedValue({ address: '192.168.1.100', family: 4 });
const result = await validateHookUrl('https://evil.example.com/steal-data');
expect(result.safe).toBe(false);
expect(result.reason).toContain('DNS resolves to private IP');
expect(result.reason).toContain('192.168.1.100');
});
it('blocks a hostname that resolves to loopback', async () => {
vi.mocked(mockLookup).mockResolvedValue({ address: '127.0.0.1', family: 4 });
const result = await validateHookUrl('https://sneaky.example.com/hook');
expect(result.safe).toBe(false);
expect(result.reason).toContain('DNS resolves to private IP');
});
it('returns error when DNS lookup fails', async () => {
vi.mocked(mockLookup).mockRejectedValue(new Error('ENOTFOUND'));
const result = await validateHookUrl('https://nonexistent.invalid/hook');
expect(result.safe).toBe(false);
expect(result.reason).toContain('DNS lookup failed');
expect(result.reason).toContain('ENOTFOUND');
});
it('blocks link-local metadata endpoint via DNS', async () => {
vi.mocked(mockLookup).mockResolvedValue({ address: '169.254.169.254', family: 4 });
const result = await validateHookUrl('https://metadata.google.internal/computeMetadata/v1/');
expect(result.safe).toBe(false);
expect(result.reason).toContain('DNS resolves to private IP');
});
describe('kill switch: AQE_HOOKS_SSRF_DISABLED=true', () => {
beforeEach(() => { process.env.AQE_HOOKS_SSRF_DISABLED = 'true'; });
afterEach(() => { delete process.env.AQE_HOOKS_SSRF_DISABLED; });
it('should allow private IPs when SSRF guard is disabled', async () => {
const result = await validateHookUrl('http://127.0.0.1:3000/hook');
expect(result.safe).toBe(true);
});
it('should allow any URL when SSRF guard is disabled', async () => {
const result = await validateHookUrl('http://10.0.0.1/internal');
expect(result.safe).toBe(true);
});
});
});