diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index bbc127fa86..34aaada9f2 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -27,16 +27,27 @@ export type HttpRequestSendOptions = { validators?: ((res: Response) => void)[]; }; -export function isPrivateUrl(url: URL): boolean { - if (!ipaddr.isValid(url.hostname)) { - return false; - } - - const ip = ipaddr.parse(url.hostname); +export async function isPrivateUrl(url: URL, lookup: net.LookupFunction): Promise { + const ip = await resolveIp(url, lookup); 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((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); 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 { const address = socket.remoteAddress; if (address && ipaddr.isValid(address)) { - if (isPrivateIp(allowedPrivateNetworks, address, socket.remotePort)) { + if (isAllowedPrivateIp(allowedPrivateNetworks, address, socket.remotePort)) { socket.destroy(new Error(`Blocked address: ${address}`)); } } @@ -142,6 +153,7 @@ export class HttpRequestService { private config: Config, private readonly apUtilityService: ApUtilityService, private readonly utilityService: UtilityService, + private readonly lookup: net.LookupFunction, ) { const cache = new CacheableLookup({ maxTtl: 3600, // 1hours @@ -149,6 +161,8 @@ export class HttpRequestService { lookup: false, // nativeのdns.lookupにfallbackしない }); + this.lookup = cache.lookup as unknown as net.LookupFunction; + const agentOption = { keepAlive: true, keepAliveMsecs: 30 * 1000, @@ -321,7 +335,7 @@ export class HttpRequestService { const timeout = args.timeout ?? 5000; 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); const controller = new AbortController(); diff --git a/packages/backend/test/unit/core/HttpRequestService.ts b/packages/backend/test/unit/core/HttpRequestService.ts index 5aeeb1e26f..0759306666 100644 --- a/packages/backend/test/unit/core/HttpRequestService.ts +++ b/packages/backend/test/unit/core/HttpRequestService.ts @@ -7,8 +7,9 @@ import { describe, jest } from '@jest/globals'; import type { Mock } from 'jest-mock'; import type { PrivateNetwork } from '@/config.js'; 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 { IPv4 } from 'ipaddr.js'; describe(HttpRequestService, () => { let allowedPrivateNetworks: PrivateNetwork[] | undefined; @@ -21,56 +22,81 @@ describe(HttpRequestService, () => { ]); }); - describe('isPrivateIp', () => { + describe(isAllowedPrivateIp, () => { 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(); }); 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(); }); 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(); }); 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(); }); 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(); }); 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(); }); }); - describe('isPrivateUrl', () => { - it('should return false when URL is not an IP', () => { - const result = isPrivateUrl(new URL('https://example.com')); + const fakeLookup = (host: string, _: unknown, callback: (err: Error | null, ip: string) => void) => { + if (host === 'localhost') { + 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); }); - it('should return false when IP is public', () => { - const result = isPrivateUrl(new URL('https://23.192.228.80')); - expect(result).toBe(false); - }); - - it('should return true when IP is private', () => { - const result = isPrivateUrl(new URL('https://127.0.0.1')); + it('should return true when URL is private host', async () => { + const result = await isPrivateUrl(new URL('https://localhost'), fakeLookup); expect(result).toBe(true); }); - it('should return true when IP is private with port and path', () => { - const result = isPrivateUrl(new URL('https://127.0.0.1:443/some/path')); + it('should return false when IP is public', async () => { + 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); }); });