resolve domain names when checking for private URLs

This commit is contained in:
Hazelnoot 2025-07-25 16:28:53 -04:00
parent 63bac24ece
commit 05bc6f5d86
2 changed files with 69 additions and 29 deletions

View file

@ -27,16 +27,27 @@ export type HttpRequestSendOptions = {
validators?: ((res: Response) => void)[]; validators?: ((res: Response) => void)[];
}; };
export function isPrivateUrl(url: URL): boolean { export async function isPrivateUrl(url: URL, lookup: net.LookupFunction): Promise<boolean> {
if (!ipaddr.isValid(url.hostname)) { const ip = await resolveIp(url, lookup);
return false;
}
const ip = ipaddr.parse(url.hostname);
return ip.range() !== 'unicast'; return ip.range() !== 'unicast';
} }
export function isPrivateIp(allowedPrivateNetworks: PrivateNetwork[] | undefined, ip: string, port?: number): boolean { export async function resolveIp(url: URL, lookup: net.LookupFunction) {
if (ipaddr.isValid(url.hostname)) {
return ipaddr.parse(url.hostname);
}
const resolvedIp = await new Promise<string>((resolve, reject) => {
lookup(url.hostname, {}, (err, address) => {
if (err) reject(err);
else resolve(address as string);
});
});
return ipaddr.parse(resolvedIp);
}
export function isAllowedPrivateIp(allowedPrivateNetworks: PrivateNetwork[] | undefined, ip: string, port?: number): boolean {
const parsedIp = ipaddr.parse(ip); const parsedIp = ipaddr.parse(ip);
for (const { cidr, ports } of allowedPrivateNetworks ?? []) { for (const { cidr, ports } of allowedPrivateNetworks ?? []) {
@ -53,7 +64,7 @@ export function isPrivateIp(allowedPrivateNetworks: PrivateNetwork[] | undefined
export function validateSocketConnect(allowedPrivateNetworks: PrivateNetwork[] | undefined, socket: Socket): void { export function validateSocketConnect(allowedPrivateNetworks: PrivateNetwork[] | undefined, socket: Socket): void {
const address = socket.remoteAddress; const address = socket.remoteAddress;
if (address && ipaddr.isValid(address)) { if (address && ipaddr.isValid(address)) {
if (isPrivateIp(allowedPrivateNetworks, address, socket.remotePort)) { if (isAllowedPrivateIp(allowedPrivateNetworks, address, socket.remotePort)) {
socket.destroy(new Error(`Blocked address: ${address}`)); socket.destroy(new Error(`Blocked address: ${address}`));
} }
} }
@ -142,6 +153,7 @@ export class HttpRequestService {
private config: Config, private config: Config,
private readonly apUtilityService: ApUtilityService, private readonly apUtilityService: ApUtilityService,
private readonly utilityService: UtilityService, private readonly utilityService: UtilityService,
private readonly lookup: net.LookupFunction,
) { ) {
const cache = new CacheableLookup({ const cache = new CacheableLookup({
maxTtl: 3600, // 1hours maxTtl: 3600, // 1hours
@ -149,6 +161,8 @@ export class HttpRequestService {
lookup: false, // nativeのdns.lookupにfallbackしない lookup: false, // nativeのdns.lookupにfallbackしない
}); });
this.lookup = cache.lookup as unknown as net.LookupFunction;
const agentOption = { const agentOption = {
keepAlive: true, keepAlive: true,
keepAliveMsecs: 30 * 1000, keepAliveMsecs: 30 * 1000,
@ -321,7 +335,7 @@ export class HttpRequestService {
const timeout = args.timeout ?? 5000; const timeout = args.timeout ?? 5000;
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
const allowHttp = args.allowHttp || isPrivateUrl(parsedUrl); const allowHttp = args.allowHttp || await isPrivateUrl(parsedUrl, this.lookup);
this.utilityService.assertUrl(parsedUrl, allowHttp); this.utilityService.assertUrl(parsedUrl, allowHttp);
const controller = new AbortController(); const controller = new AbortController();

View file

@ -7,8 +7,9 @@ import { describe, jest } from '@jest/globals';
import type { Mock } from 'jest-mock'; import type { Mock } from 'jest-mock';
import type { PrivateNetwork } from '@/config.js'; import type { PrivateNetwork } from '@/config.js';
import type { Socket } from 'net'; import type { Socket } from 'net';
import { HttpRequestService, isPrivateIp, isPrivateUrl, validateSocketConnect } from '@/core/HttpRequestService.js'; import { HttpRequestService, isAllowedPrivateIp, isPrivateUrl, resolveIp, validateSocketConnect } from '@/core/HttpRequestService.js';
import { parsePrivateNetworks } from '@/config.js'; import { parsePrivateNetworks } from '@/config.js';
import { IPv4 } from 'ipaddr.js';
describe(HttpRequestService, () => { describe(HttpRequestService, () => {
let allowedPrivateNetworks: PrivateNetwork[] | undefined; let allowedPrivateNetworks: PrivateNetwork[] | undefined;
@ -21,56 +22,81 @@ describe(HttpRequestService, () => {
]); ]);
}); });
describe('isPrivateIp', () => { describe(isAllowedPrivateIp, () => {
it('should return false when ip public', () => { it('should return false when ip public', () => {
const result = isPrivateIp(allowedPrivateNetworks, '74.125.127.100', 80); const result = isAllowedPrivateIp(allowedPrivateNetworks, '74.125.127.100', 80);
expect(result).toBeFalsy(); expect(result).toBeFalsy();
}); });
it('should return false when ip private and port matches', () => { it('should return false when ip private and port matches', () => {
const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', 1); const result = isAllowedPrivateIp(allowedPrivateNetworks, '127.0.0.1', 1);
expect(result).toBeFalsy(); expect(result).toBeFalsy();
}); });
it('should return false when ip private and all ports undefined', () => { it('should return false when ip private and all ports undefined', () => {
const result = isPrivateIp(allowedPrivateNetworks, '10.0.0.1', undefined); const result = isAllowedPrivateIp(allowedPrivateNetworks, '10.0.0.1', undefined);
expect(result).toBeFalsy(); expect(result).toBeFalsy();
}); });
it('should return true when ip private and no ports specified', () => { it('should return true when ip private and no ports specified', () => {
const result = isPrivateIp(allowedPrivateNetworks, '10.0.0.2', 80); const result = isAllowedPrivateIp(allowedPrivateNetworks, '10.0.0.2', 80);
expect(result).toBeTruthy(); expect(result).toBeTruthy();
}); });
it('should return true when ip private and port does not match', () => { it('should return true when ip private and port does not match', () => {
const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', 80); const result = isAllowedPrivateIp(allowedPrivateNetworks, '127.0.0.1', 80);
expect(result).toBeTruthy(); expect(result).toBeTruthy();
}); });
it('should return true when ip private and port is null but ports are specified', () => { it('should return true when ip private and port is null but ports are specified', () => {
const result = isPrivateIp(allowedPrivateNetworks, '127.0.0.1', undefined); const result = isAllowedPrivateIp(allowedPrivateNetworks, '127.0.0.1', undefined);
expect(result).toBeTruthy(); expect(result).toBeTruthy();
}); });
}); });
describe('isPrivateUrl', () => { const fakeLookup = (host: string, _: unknown, callback: (err: Error | null, ip: string) => void) => {
it('should return false when URL is not an IP', () => { if (host === 'localhost') {
const result = isPrivateUrl(new URL('https://example.com')); callback(null, '127.0.0.1');
} else {
callback(null, '23.192.228.80');
}
};
describe(resolveIp, () => {
it('should parse inline IPs', async () => {
const result = await resolveIp(new URL('https://10.0.0.1'), fakeLookup);
expect(result.toString()).toEqual('10.0.0.1');
});
it('should resolve domain names', async () => {
const result = await resolveIp(new URL('https://localhost'), fakeLookup);
expect(result.toString()).toEqual('127.0.0.1');
});
});
describe(isPrivateUrl, () => {
it('should return false when URL is public host', async () => {
const result = await isPrivateUrl(new URL('https://example.com'), fakeLookup);
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('should return false when IP is public', () => { it('should return true when URL is private host', async () => {
const result = isPrivateUrl(new URL('https://23.192.228.80')); const result = await isPrivateUrl(new URL('https://localhost'), fakeLookup);
expect(result).toBe(false);
});
it('should return true when IP is private', () => {
const result = isPrivateUrl(new URL('https://127.0.0.1'));
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('should return true when IP is private with port and path', () => { it('should return false when IP is public', async () => {
const result = isPrivateUrl(new URL('https://127.0.0.1:443/some/path')); const result = await isPrivateUrl(new URL('https://23.192.228.80'), fakeLookup);
expect(result).toBe(false);
});
it('should return true when IP is private', async () => {
const result = await isPrivateUrl(new URL('https://127.0.0.1'), fakeLookup);
expect(result).toBe(true);
});
it('should return true when IP is private with port and path', async () => {
const result = await isPrivateUrl(new URL('https://127.0.0.1:443/some/path'), fakeLookup);
expect(result).toBe(true); expect(result).toBe(true);
}); });
}); });