From f2e36db95eb2ac744667f9d57cedbb7b883a574e Mon Sep 17 00:00:00 2001 From: rijkvanzanten Date: Mon, 13 Feb 2023 15:22:45 -0500 Subject: [PATCH] Split up handler from validator --- api/src/operations/request/index.ts | 2 +- api/src/request.ts | 62 --------------------- api/src/{ => request}/request.test.ts | 0 api/src/request/request.ts | 19 +++++++ api/src/request/validate-ip.test.ts | 80 +++++++++++++++++++++++++++ api/src/request/validate-ip.ts | 24 ++++++++ api/src/services/files.ts | 2 +- api/src/webhooks.ts | 2 +- 8 files changed, 126 insertions(+), 65 deletions(-) delete mode 100644 api/src/request.ts rename api/src/{ => request}/request.test.ts (100%) create mode 100644 api/src/request/request.ts create mode 100644 api/src/request/validate-ip.test.ts create mode 100644 api/src/request/validate-ip.ts diff --git a/api/src/operations/request/index.ts b/api/src/operations/request/index.ts index e3414b62ef..7ae74d1582 100644 --- a/api/src/operations/request/index.ts +++ b/api/src/operations/request/index.ts @@ -1,6 +1,6 @@ import { defineOperationApi, parseJSON } from '@directus/shared/utils'; import encodeUrl from 'encodeurl'; -import { getAxios } from '../../request'; +import { getAxios } from '../../request/request'; type Options = { url: string; diff --git a/api/src/request.ts b/api/src/request.ts deleted file mode 100644 index 0cb816ad57..0000000000 --- a/api/src/request.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { AxiosInstance } from 'axios'; -import { lookup } from 'node:dns/promises'; -import net from 'node:net'; -import os from 'node:os'; -import env from './env'; - -let axiosInstance: AxiosInstance; - -export async function getAxios() { - if (!axiosInstance) { - const axios = (await import('axios')).default; - - axiosInstance = axios.create(); - - axiosInstance.interceptors.request.use(async (config) => { - if (config.url) { - await checkUrl(config.url); - } - return config; - }); - } - - return axiosInstance; -} - -async function checkUrl(url: string) { - let parsedUrl; - - try { - parsedUrl = new URL(url); - } catch { - throw new Error(`Requested URL "${url}" is invalid`); - } - - let ip = parsedUrl.hostname; - - if (net.isIP(ip) === 0) { - try { - ip = (await lookup(ip)).address; - } catch (err: any) { - throw new Error(`Couldn't lookup the DNS for URL "${url}" (${err.message || err})`); - } - } - - if (env.IMPORT_IP_DENY_LIST.includes('0.0.0.0')) { - const networkInterfaces = os.networkInterfaces(); - - for (const networkInfo of Object.values(networkInterfaces)) { - if (!networkInfo) continue; - - for (const info of networkInfo) { - if (info.address === ip) { - throw new Error(`Requested URL "${url}" resolves to localhost`); - } - } - } - } - - if (env.IMPORT_IP_DENY_LIST.includes(ip)) { - throw new Error(`Requested URL "${url}" resolves to a denied IP address`); - } -} diff --git a/api/src/request.test.ts b/api/src/request/request.test.ts similarity index 100% rename from api/src/request.test.ts rename to api/src/request/request.test.ts diff --git a/api/src/request/request.ts b/api/src/request/request.ts new file mode 100644 index 0000000000..d7473e39b4 --- /dev/null +++ b/api/src/request/request.ts @@ -0,0 +1,19 @@ +import type { AxiosInstance } from 'axios'; +import { validateIP } from './validate-ip'; + +let axiosInstance: AxiosInstance; + +export async function getAxios() { + if (!axiosInstance) { + const axios = (await import('axios')).default; + + axiosInstance = axios.create(); + + axiosInstance.interceptors.response.use(async (config) => { + await validateIP(config.request.socket.remoteAddress, config.request.url); + return config; + }); + } + + return axiosInstance; +} diff --git a/api/src/request/validate-ip.test.ts b/api/src/request/validate-ip.test.ts new file mode 100644 index 0000000000..18d3c365c1 --- /dev/null +++ b/api/src/request/validate-ip.test.ts @@ -0,0 +1,80 @@ +import { randIp, randUrl } from '@ngneat/falso'; +import os from 'node:os'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { getEnv } from '../env'; +import { validateIP } from './validate-ip'; + +vi.mock('../env'); +vi.mock('node:os'); + +let sample: { + ip: string; + url: string; +}; + +beforeEach(() => { + sample = { + ip: randIp(), + url: randUrl(), + }; +}); + +afterEach(() => { + vi.resetAllMocks(); +}); + +test(`Does nothing if IP is valid`, async () => { + vi.mocked(getEnv).mockReturnValue({ IMPORT_IP_DENY_LIST: [] }); + await validateIP(sample.ip, sample.url); +}); + +test(`Throws error if passed IP is denylisted`, async () => { + vi.mocked(getEnv).mockReturnValue({ IMPORT_IP_DENY_LIST: [sample.ip] }); + + try { + await validateIP(sample.ip, sample.url); + } catch (err: any) { + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe(`Requested URL "${sample.url}" resolves to a denied IP address`); + } +}); + +test(`Checks against IPs of local networkInterfaces if IP deny list contains 0.0.0.0`, async () => { + vi.mocked(getEnv).mockReturnValue({ IMPORT_IP_DENY_LIST: ['0.0.0.0'] }); + vi.mocked(os.networkInterfaces).mockReturnValue({}); + await validateIP(sample.ip, sample.url); + expect(os.networkInterfaces).toHaveBeenCalledOnce(); +}); + +test(`Throws error if IP address matches resolved localhost IP`, async () => { + vi.mocked(getEnv).mockReturnValue({ IMPORT_IP_DENY_LIST: ['0.0.0.0'] }); + vi.mocked(os.networkInterfaces).mockReturnValue({ + lo0: [ + { + address: '127.0.0.1', + netmask: '255.0.0.0', + family: 'IPv4', + mac: '00:00:00:00:00:00', + internal: true, + cidr: '127.0.0.1/8', + }, + ], + en0: [ + { + address: sample.ip, + netmask: '255.0.0.0', + family: 'IPv4', + mac: '00:00:00:00:00:00', + internal: true, + cidr: '127.0.0.1/8', + }, + ], + }); + + try { + await validateIP(sample.ip, sample.url); + } catch (err: any) { + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe(`Requested URL "${sample.url}" resolves to a denied IP address`); + } +}); diff --git a/api/src/request/validate-ip.ts b/api/src/request/validate-ip.ts new file mode 100644 index 0000000000..e3e7b7dbdc --- /dev/null +++ b/api/src/request/validate-ip.ts @@ -0,0 +1,24 @@ +import os from 'node:os'; +import { getEnv } from '../env'; + +export const validateIP = async (ip: string, url: string) => { + const env = getEnv(); + + if (env.IMPORT_IP_DENY_LIST.includes(ip)) { + throw new Error(`Requested URL "${url}" resolves to a denied IP address`); + } + + if (env.IMPORT_IP_DENY_LIST.includes('0.0.0.0')) { + const networkInterfaces = os.networkInterfaces(); + + for (const networkInfo of Object.values(networkInterfaces)) { + if (!networkInfo) continue; + + for (const info of networkInfo) { + if (info.address === ip) { + throw new Error(`Requested URL "${url}" resolves to a denied IP address`); + } + } + } + } +}; diff --git a/api/src/services/files.ts b/api/src/services/files.ts index 7056426825..f94b5c77fc 100644 --- a/api/src/services/files.ts +++ b/api/src/services/files.ts @@ -20,7 +20,7 @@ import { ItemsService } from './items'; // @ts-ignore import formatTitle from '@directus/format-title'; -import { getAxios } from '../request'; +import { getAxios } from '../request/request'; export class FilesService extends ItemsService { constructor(options: AbstractServiceOptions) { diff --git a/api/src/webhooks.ts b/api/src/webhooks.ts index bd6cbcaacc..0594d3d654 100644 --- a/api/src/webhooks.ts +++ b/api/src/webhooks.ts @@ -3,7 +3,7 @@ import getDatabase from './database'; import emitter from './emitter'; import logger from './logger'; import { getMessenger } from './messenger'; -import { getAxios } from './request'; +import { getAxios } from './request/request'; import { WebhooksService } from './services'; import { Webhook, WebhookHeader } from './types'; import { getSchema } from './utils/get-schema';