/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { updateGlobalFetchTimeouts } from './fetch.js'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as dnsPromises from 'node:dns/promises'; import type { LookupAddress, LookupAllOptions } from 'node:dns'; import ipaddr from 'ipaddr.js'; const { setGlobalDispatcher, Agent, EnvHttpProxyAgent } = vi.hoisted(() => ({ setGlobalDispatcher: vi.fn(), Agent: vi.fn(), EnvHttpProxyAgent: vi.fn(), })); vi.mock('undici', () => ({ setGlobalDispatcher, Agent, EnvHttpProxyAgent, })); vi.mock('node:dns/promises', () => ({ lookup: vi.fn(), })); // Import after mocks are established const { isPrivateIp, isPrivateIpAsync, isAddressPrivate, fetchWithTimeout, setGlobalProxy, createSafeProxyAgent, } = await import('./fetch.js'); interface ErrorWithCode extends Error { code?: string; } describe('fetch utils', () => { beforeEach(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(global, 'fetch').mockImplementation(vi.fn() as any); // Default DNS lookup to return a public IP, or the IP itself if valid vi.mocked( dnsPromises.lookup as ( hostname: string, options: LookupAllOptions, ) => Promise, ).mockImplementation(async (hostname: string) => { if (ipaddr.isValid(hostname)) { return [{ address: hostname, family: hostname.includes(':') ? 6 : 4 }]; } return [{ address: '93.184.216.34', family: 4 }]; }); vi.unstubAllEnvs(); updateGlobalFetchTimeouts(60000); }); afterEach(() => { vi.restoreAllMocks(); }); describe('isAddressPrivate', () => { it('should identify private IPv4 addresses', () => { expect(isAddressPrivate('10.0.0.1')).toBe(true); expect(isAddressPrivate('127.0.0.1')).toBe(true); expect(isAddressPrivate('172.16.0.1')).toBe(true); expect(isAddressPrivate('192.168.1.1')).toBe(true); }); it('should identify non-routable and reserved IPv4 addresses (RFC 6890)', () => { expect(isAddressPrivate('0.0.0.0')).toBe(true); expect(isAddressPrivate('100.64.0.1')).toBe(true); expect(isAddressPrivate('192.0.0.1')).toBe(true); expect(isAddressPrivate('192.0.2.1')).toBe(true); expect(isAddressPrivate('192.88.99.1')).toBe(true); // Benchmark range (198.18.0.0/15) expect(isAddressPrivate('198.18.0.0')).toBe(true); expect(isAddressPrivate('198.18.0.1')).toBe(true); expect(isAddressPrivate('198.19.255.255')).toBe(true); expect(isAddressPrivate('198.51.100.1')).toBe(true); expect(isAddressPrivate('203.0.113.1')).toBe(true); expect(isAddressPrivate('224.0.0.1')).toBe(true); expect(isAddressPrivate('240.0.0.1')).toBe(true); }); it('should identify private IPv6 addresses', () => { expect(isAddressPrivate('::1')).toBe(true); expect(isAddressPrivate('fc00::')).toBe(true); expect(isAddressPrivate('fd00::')).toBe(true); expect(isAddressPrivate('fe80::')).toBe(true); expect(isAddressPrivate('febf::')).toBe(true); }); it('should identify special local addresses', () => { expect(isAddressPrivate('0.0.0.0')).toBe(true); expect(isAddressPrivate('::')).toBe(true); expect(isAddressPrivate('localhost')).toBe(true); }); it('should identify link-local addresses', () => { expect(isAddressPrivate('169.254.169.254')).toBe(true); }); it('should identify IPv4-mapped IPv6 private addresses', () => { expect(isAddressPrivate('::ffff:127.0.0.1')).toBe(true); expect(isAddressPrivate('::ffff:10.0.0.1')).toBe(true); expect(isAddressPrivate('::ffff:169.254.169.254')).toBe(true); expect(isAddressPrivate('::ffff:192.168.1.1')).toBe(true); expect(isAddressPrivate('::ffff:172.16.0.1')).toBe(true); expect(isAddressPrivate('::ffff:0.0.0.0')).toBe(true); expect(isAddressPrivate('::ffff:100.64.0.1')).toBe(true); expect(isAddressPrivate('::ffff:a9fe:101')).toBe(true); // 169.254.1.1 }); it('should identify public addresses as non-private', () => { expect(isAddressPrivate('8.8.8.8')).toBe(false); expect(isAddressPrivate('93.184.216.34')).toBe(false); expect(isAddressPrivate('2001:4860:4860::8888')).toBe(false); expect(isAddressPrivate('::ffff:8.8.8.8')).toBe(false); }); }); describe('isPrivateIp', () => { it('should identify private IPs in URLs', () => { expect(isPrivateIp('http://10.0.0.1/')).toBe(true); expect(isPrivateIp('https://127.0.0.1:8080/')).toBe(true); expect(isPrivateIp('http://localhost/')).toBe(true); expect(isPrivateIp('http://[::1]/')).toBe(true); }); it('should identify public IPs in URLs as non-private', () => { expect(isPrivateIp('http://8.8.8.8/')).toBe(false); expect(isPrivateIp('https://google.com/')).toBe(false); }); }); describe('isPrivateIpAsync', () => { it('should identify private IPs directly', async () => { expect(await isPrivateIpAsync('http://10.0.0.1/')).toBe(true); }); it('should identify domains resolving to private IPs', async () => { vi.mocked( dnsPromises.lookup as ( hostname: string, options: LookupAllOptions, ) => Promise, ).mockImplementation(async () => [{ address: '10.0.0.1', family: 4 }]); expect(await isPrivateIpAsync('http://malicious.com/')).toBe(true); }); it('should identify domains resolving to public IPs as non-private', async () => { vi.mocked( dnsPromises.lookup as ( hostname: string, options: LookupAllOptions, ) => Promise, ).mockImplementation(async () => [{ address: '8.8.8.8', family: 4 }]); expect(await isPrivateIpAsync('http://google.com/')).toBe(false); }); it('should throw error if DNS resolution fails (fail closed)', async () => { vi.mocked(dnsPromises.lookup).mockRejectedValue(new Error('DNS Error')); await expect(isPrivateIpAsync('http://unreachable.com/')).rejects.toThrow( 'Failed to verify if URL resolves to private IP', ); }); it('should return false for invalid URLs instead of throwing verification error', async () => { expect(await isPrivateIpAsync('not-a-url')).toBe(false); }); }); describe('fetchWithTimeout', () => { it('should throw FetchError with ETIMEDOUT on an internal timeout', async () => { vi.mocked(global.fetch).mockImplementation( (_input, init) => new Promise((_resolve, reject) => { if (init?.signal) { init.signal.addEventListener('abort', () => { const error = new Error( 'The operation was aborted', ) as ErrorWithCode; error.name = 'AbortError'; error.code = 'ABORT_ERR'; reject(error); }); } }), ); await expect(fetchWithTimeout('http://example.com', 50)).rejects.toThrow( 'Request timed out after 50ms', ); }); it('should throw an AbortError (not ETIMEDOUT) when the caller signal is aborted', async () => { vi.mocked(global.fetch).mockImplementation( (_input, init) => new Promise((_resolve, reject) => { const rejectWithAbortError = () => { const error = new Error('The operation was aborted'); error.name = 'AbortError'; // @ts-expect-error - for mocking purposes error.code = 'ABORT_ERR'; reject(error); }; // Handle the case where the signal is already aborted before // fetch is called (e.g. controller.abort() called synchronously). if (init?.signal?.aborted) { rejectWithAbortError(); return; } if (init?.signal) { init.signal.addEventListener('abort', rejectWithAbortError, { once: true, }); } }), ); const controller = new AbortController(); // Abort the external signal before the request even starts controller.abort(); const rejection = fetchWithTimeout('http://example.com', 10_000, { signal: controller.signal, }); await expect(rejection).rejects.toMatchObject({ name: 'AbortError' }); // Must NOT be classified as a timeout await expect(rejection).rejects.not.toThrow('timed out'); }); }); describe('setGlobalProxy', () => { it('should configure EnvHttpProxyAgent with experiment flag timeout and noProxy', () => { const proxyUrl = ' http://proxy.example.com '; const noProxyValue = ' localhost,127.0.0.1 '; vi.stubEnv('NO_PROXY', noProxyValue); updateGlobalFetchTimeouts(45773134); setGlobalProxy(proxyUrl); expect(EnvHttpProxyAgent).toHaveBeenCalledWith({ httpProxy: 'http://proxy.example.com', httpsProxy: 'http://proxy.example.com', noProxy: 'localhost,127.0.0.1', headersTimeout: 45773134, bodyTimeout: 300000, }); expect(setGlobalDispatcher).toHaveBeenCalled(); }); it('should fall back to no_proxy if NO_PROXY is not set', () => { const proxyUrl = 'http://proxy.example.com'; const noProxyValue = 'localhost,127.0.0.1'; vi.stubEnv('NO_PROXY', undefined); vi.stubEnv('no_proxy', noProxyValue); setGlobalProxy(proxyUrl); expect(EnvHttpProxyAgent).toHaveBeenCalledWith( expect.objectContaining({ noProxy: noProxyValue, }), ); }); it('should handle empty NO_PROXY', () => { const proxyUrl = 'http://proxy.example.com'; vi.stubEnv('NO_PROXY', ''); setGlobalProxy(proxyUrl); expect(EnvHttpProxyAgent).toHaveBeenCalledWith( expect.objectContaining({ noProxy: '', }), ); }); it('should handle multi-entry NO_PROXY with trimming', () => { const proxyUrl = 'http://proxy.example.com'; const noProxyValue = ' google.com, 127.0.0.1 , localhost '; vi.stubEnv('NO_PROXY', noProxyValue); setGlobalProxy(proxyUrl); expect(EnvHttpProxyAgent).toHaveBeenCalledWith( expect.objectContaining({ noProxy: 'google.com, 127.0.0.1 , localhost', }), ); }); }); describe('createSafeProxyAgent', () => { it('should create an EnvHttpProxyAgent with trimmed values and default timeouts', () => { const proxyUrl = ' http://proxy.example.com '; const noProxyValue = ' localhost,127.0.0.1 '; vi.stubEnv('NO_PROXY', noProxyValue); createSafeProxyAgent(proxyUrl); expect(EnvHttpProxyAgent).toHaveBeenCalledWith({ httpProxy: 'http://proxy.example.com', httpsProxy: 'http://proxy.example.com', noProxy: 'localhost,127.0.0.1', headersTimeout: 60000, bodyTimeout: 300000, }); }); }); });