diff --git a/api/src/request/index.ts b/api/src/request/index.ts index 67e7eb3a00..e51266be57 100644 --- a/api/src/request/index.ts +++ b/api/src/request/index.ts @@ -1,4 +1,5 @@ import type { AxiosInstance } from 'axios'; +import { requestInterceptor } from './request-interceptor'; import { responseInterceptor } from './response-interceptor'; export const _cache: { axiosInstance: AxiosInstance | null } = { @@ -9,6 +10,7 @@ export async function getAxios() { if (!_cache.axiosInstance) { const axios = (await import('axios')).default; _cache.axiosInstance = axios.create(); + _cache.axiosInstance.interceptors.request.use(requestInterceptor); _cache.axiosInstance.interceptors.response.use(responseInterceptor); } diff --git a/api/src/request/request-interceptor.test.ts b/api/src/request/request-interceptor.test.ts new file mode 100644 index 0000000000..9c6515d527 --- /dev/null +++ b/api/src/request/request-interceptor.test.ts @@ -0,0 +1,104 @@ +import { randIp, randUrl, randWord } from '@ngneat/falso'; +import type { InternalAxiosRequestConfig } from 'axios'; +import axios from 'axios'; +import type { LookupAddress } from 'node:dns'; +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; +import { URL } from 'node:url'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import logger from '../logger'; +import { requestInterceptor } from './request-interceptor'; +import { validateIP } from './validate-ip'; + +vi.mock('axios'); +vi.mock('node:net'); +vi.mock('node:url'); +vi.mock('node:dns/promises'); +vi.mock('./validate-ip'); +vi.mock('../logger'); + +let sample: { + config: InternalAxiosRequestConfig; + url: string; + hostname: string; + ip: string; +}; + +beforeEach(() => { + sample = { + config: {} as InternalAxiosRequestConfig, + url: randUrl(), + hostname: randWord(), + ip: randIp(), + }; + + vi.mocked(axios.getUri).mockReturnValue(sample.url); + vi.mocked(URL).mockReturnValue({ hostname: sample.hostname } as URL); + vi.mocked(lookup).mockResolvedValue({ address: sample.ip } as LookupAddress); + vi.mocked(isIP).mockReturnValue(0); +}); + +afterEach(() => { + vi.resetAllMocks(); +}); + +test('Uses axios getUri to get full URI', async () => { + await requestInterceptor(sample.config); + expect(axios.getUri).toHaveBeenCalledWith(sample.config); +}); + +test('Gets hostname using URL', async () => { + await requestInterceptor(sample.config); + expect(URL).toHaveBeenCalledWith(sample.url); +}); + +test('Checks if hostname is IP', async () => { + await requestInterceptor(sample.config); + expect(isIP).toHaveBeenCalledWith(sample.hostname); +}); + +test('Looks up IP address using dns lookup if hostname is not an IP address', async () => { + await requestInterceptor(sample.config); + expect(lookup).toHaveBeenCalledWith(sample.hostname); +}); + +test('Logs when the lookup throws an error', async () => { + const mockError = new Error(); + vi.mocked(lookup).mockRejectedValue(mockError); + + try { + await requestInterceptor(sample.config); + } catch { + // Expect to error + } finally { + expect(logger.warn).toHaveBeenCalledWith(mockError, `Couldn't lookup the DNS for url "${sample.url}"`); + } +}); + +test('Throws error when dns lookup fails', async () => { + const mockError = new Error(); + vi.mocked(lookup).mockRejectedValue(mockError); + + try { + await requestInterceptor(sample.config); + } catch (err: any) { + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe(`Requested URL "${sample.url}" resolves to a denied IP address`); + } +}); + +test('Validates IP', async () => { + await requestInterceptor(sample.config); + expect(validateIP).toHaveBeenCalledWith(sample.ip, sample.url); +}); + +test('Validates IP from hostname if URL hostname is IP', async () => { + vi.mocked(isIP).mockReturnValue(4); + await requestInterceptor(sample.config); + expect(validateIP).toHaveBeenCalledWith(sample.hostname, sample.url); +}); + +test('Returns config unmodified', async () => { + const config = await requestInterceptor(sample.config); + expect(config).toBe(config); +}); diff --git a/api/src/request/request-interceptor.ts b/api/src/request/request-interceptor.ts new file mode 100644 index 0000000000..90c6b1a069 --- /dev/null +++ b/api/src/request/request-interceptor.ts @@ -0,0 +1,31 @@ +import type { InternalAxiosRequestConfig } from 'axios'; +import axios from 'axios'; +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; +import { URL } from 'node:url'; +import logger from '../logger'; +import { validateIP } from './validate-ip'; + +export const requestInterceptor = async (config: InternalAxiosRequestConfig) => { + const uri = axios.getUri(config); + + const { hostname } = new URL(uri); + + let ip; + + if (isIP(hostname) === 0) { + try { + const dns = await lookup(hostname); + ip = dns.address; + } catch (err: any) { + logger.warn(err, `Couldn't lookup the DNS for url "${uri}"`); + throw new Error(`Requested URL "${uri}" resolves to a denied IP address`); + } + } else { + ip = hostname; + } + + await validateIP(ip, uri); + + return config; +};