feat(core): implement Stage 2 security and consistency improvements for web_fetch (#22217)

This commit is contained in:
Aishanee Shah
2026-03-16 17:38:53 -04:00
committed by GitHub
parent b6c6da3618
commit 990d010ecf
4 changed files with 250 additions and 86 deletions
+65 -3
View File
@@ -5,7 +5,15 @@
*/
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import { isPrivateIp, isAddressPrivate, fetchWithTimeout } from './fetch.js';
import {
isPrivateIp,
isPrivateIpAsync,
isAddressPrivate,
fetchWithTimeout,
} from './fetch.js';
import * as dnsPromises from 'node:dns/promises';
import type { LookupAddress, LookupAllOptions } from 'node:dns';
import ipaddr from 'ipaddr.js';
vi.mock('node:dns/promises', () => ({
lookup: vi.fn(),
@@ -15,9 +23,25 @@ vi.mock('node:dns/promises', () => ({
const originalFetch = global.fetch;
global.fetch = vi.fn();
interface ErrorWithCode extends Error {
code?: string;
}
describe('fetch utils', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default DNS lookup to return a public IP, or the IP itself if valid
vi.mocked(
dnsPromises.lookup as (
hostname: string,
options: LookupAllOptions,
) => Promise<LookupAddress[]>,
).mockImplementation(async (hostname: string) => {
if (ipaddr.isValid(hostname)) {
return [{ address: hostname, family: hostname.includes(':') ? 6 : 4 }];
}
return [{ address: '93.184.216.34', family: 4 }];
});
});
afterAll(() => {
@@ -99,6 +123,43 @@ describe('fetch utils', () => {
});
});
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<LookupAddress[]>,
).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<LookupAddress[]>,
).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 handle timeouts', async () => {
vi.mocked(global.fetch).mockImplementation(
@@ -106,9 +167,10 @@ describe('fetch utils', () => {
new Promise((_resolve, reject) => {
if (init?.signal) {
init.signal.addEventListener('abort', () => {
const error = new Error('The operation was aborted');
const error = new Error(
'The operation was aborted',
) as ErrorWithCode;
error.name = 'AbortError';
// @ts-expect-error - for mocking purposes
error.code = 'ABORT_ERR';
reject(error);
});