diff --git a/api/src/env.ts b/api/src/env.ts index ae4b57937b..5f9ce24219 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -257,7 +257,7 @@ const defaults: Record = { IP_TRUST_PROXY: true, IP_CUSTOM_HEADER: false, - IMPORT_IP_DENY_LIST: '0.0.0.0', + IMPORT_IP_DENY_LIST: ['0.0.0.0', '169.254.169.254'], SERVE_APP: true, diff --git a/api/src/operations/request/index.test.ts b/api/src/operations/request/index.test.ts index cfc64465e2..98e1b0b4ae 100644 --- a/api/src/operations/request/index.test.ts +++ b/api/src/operations/request/index.test.ts @@ -2,13 +2,14 @@ import { afterEach, expect, test, vi } from 'vitest'; const axiosDefault = vi.fn(); -vi.mock('axios', () => ({ - default: axiosDefault.mockResolvedValue({ - status: 200, - statusText: 'OK', - headers: {}, - data: {}, - }), +vi.mock('../../request', () => ({ + getAxios: () => + axiosDefault.mockResolvedValue({ + status: 200, + statusText: 'OK', + headers: {}, + data: {}, + }), })); const url = '/'; diff --git a/api/src/operations/request/index.ts b/api/src/operations/request/index.ts index 202ca54bb4..638dc1f683 100644 --- a/api/src/operations/request/index.ts +++ b/api/src/operations/request/index.ts @@ -1,5 +1,6 @@ import { defineOperationApi, parseJSON } from '@directus/shared/utils'; import encodeUrl from 'encodeurl'; +import { getAxios } from '../../request/index'; type Options = { url: string; @@ -12,8 +13,6 @@ export default defineOperationApi({ id: 'request', handler: async ({ url, method, body, headers }) => { - const axios = (await import('axios')).default; - const customHeaders = headers?.reduce((acc, { header, value }) => { acc[header] = value; @@ -24,6 +23,7 @@ export default defineOperationApi({ customHeaders['Content-Type'] = 'application/json'; } + const axios = await getAxios(); const result = await axios({ url: encodeUrl(url), method, diff --git a/api/src/request/index.test.ts b/api/src/request/index.test.ts new file mode 100644 index 0000000000..8db72c0fe6 --- /dev/null +++ b/api/src/request/index.test.ts @@ -0,0 +1,31 @@ +import { test, vi, afterEach, beforeEach, expect } from 'vitest'; +import { getAxios, _cache } from './index'; +import axios from 'axios'; +import type { AxiosInstance } from 'axios'; + +vi.mock('axios'); + +let mockAxiosInstance: AxiosInstance; + +beforeEach(() => { + mockAxiosInstance = { + interceptors: { + response: { + use: vi.fn(), + }, + }, + } as unknown as AxiosInstance; + + vi.mocked(axios.create).mockReturnValue(mockAxiosInstance); +}); + +afterEach(() => { + vi.resetAllMocks(); + _cache.axiosInstance = null; +}); + +test('Creates and returns new axios instance if cache is empty', async () => { + const instance = await getAxios(); + expect(axios.create).toHaveBeenCalled(); + expect(instance).toBe(mockAxiosInstance); +}); diff --git a/api/src/request/index.ts b/api/src/request/index.ts new file mode 100644 index 0000000000..67e7eb3a00 --- /dev/null +++ b/api/src/request/index.ts @@ -0,0 +1,16 @@ +import type { AxiosInstance } from 'axios'; +import { responseInterceptor } from './response-interceptor'; + +export const _cache: { axiosInstance: AxiosInstance | null } = { + axiosInstance: null, +}; + +export async function getAxios() { + if (!_cache.axiosInstance) { + const axios = (await import('axios')).default; + _cache.axiosInstance = axios.create(); + _cache.axiosInstance.interceptors.response.use(responseInterceptor); + } + + return _cache.axiosInstance; +} diff --git a/api/src/request/response-interceptor.test.ts b/api/src/request/response-interceptor.test.ts new file mode 100644 index 0000000000..bda433ee6c --- /dev/null +++ b/api/src/request/response-interceptor.test.ts @@ -0,0 +1,44 @@ +import { randIp, randUrl } from '@ngneat/falso'; +import type { AxiosResponse } from 'axios'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { responseInterceptor } from './response-interceptor'; +import { validateIP } from './validate-ip'; + +vi.mock('./validate-ip'); + +let sample: { + remoteAddress: string; + url: string; +}; + +let sampleResponseConfig: AxiosResponse; + +beforeEach(() => { + sample = { + remoteAddress: randIp(), + url: randUrl(), + }; + + sampleResponseConfig = { + request: { + socket: { + remoteAddress: sample.remoteAddress, + }, + url: sample.url, + }, + } as AxiosResponse; +}); + +afterEach(() => { + vi.resetAllMocks(); +}); + +test(`Calls validateIP with IP/url from axios request config`, async () => { + await responseInterceptor(sampleResponseConfig); + expect(validateIP).toHaveBeenCalledWith(sample.remoteAddress, sample.url); +}); + +test(`Returns passed in config as-is`, async () => { + const config = await responseInterceptor(sampleResponseConfig); + expect(config).toBe(sampleResponseConfig); +}); diff --git a/api/src/request/response-interceptor.ts b/api/src/request/response-interceptor.ts new file mode 100644 index 0000000000..bfad826fa0 --- /dev/null +++ b/api/src/request/response-interceptor.ts @@ -0,0 +1,7 @@ +import type { AxiosResponse } from 'axios'; +import { validateIP } from './validate-ip'; + +export const responseInterceptor = async (config: AxiosResponse) => { + await validateIP(config.request.socket.remoteAddress, config.request.url); + return config; +}; diff --git a/api/src/request/validate-ip.test.ts b/api/src/request/validate-ip.test.ts new file mode 100644 index 0000000000..4cf3982ad4 --- /dev/null +++ b/api/src/request/validate-ip.test.ts @@ -0,0 +1,81 @@ +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({ + fa0: undefined, + 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 291e1eaab6..47d546a0ec 100644 --- a/api/src/services/files.ts +++ b/api/src/services/files.ts @@ -1,22 +1,19 @@ import { toArray } from '@directus/shared/utils'; -import { lookup } from 'dns'; import encodeURL from 'encodeurl'; import exif from 'exif-reader'; import { parse as parseIcc } from 'icc'; import { clone, pick } from 'lodash'; import { extension } from 'mime-types'; -import net from 'net'; import type { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; -import os from 'os'; import path from 'path'; import sharp from 'sharp'; -import url, { URL } from 'url'; -import { promisify } from 'util'; +import url from 'url'; import emitter from '../emitter'; import env from '../env'; import { ForbiddenException, InvalidPayloadException, ServiceUnavailableException } from '../exceptions'; import logger from '../logger'; +import { getAxios } from '../request/index'; import { getStorage } from '../storage'; import { AbstractServiceOptions, File, Metadata, MutationOptions, PrimaryKey } from '../types'; import { parseIptc, parseXmp } from '../utils/parse-image-metadata'; @@ -25,8 +22,6 @@ import { ItemsService } from './items'; // @ts-ignore import formatTitle from '@directus/format-title'; -const lookupDNS = promisify(lookup); - export class FilesService extends ItemsService { constructor(options: AbstractServiceOptions) { super('directus_files', options); @@ -224,8 +219,6 @@ export class FilesService extends ItemsService { * Import a single file from an external URL */ async importOne(importURL: string, body: Partial): Promise { - const axios = (await import('axios')).default; - const fileCreatePermissions = this.accountability?.permissions?.find( (permission) => permission.collection === 'directus_files' && permission.action === 'create' ); @@ -234,62 +227,15 @@ export class FilesService extends ItemsService { throw new ForbiddenException(); } - let resolvedUrl; - - try { - resolvedUrl = new URL(importURL); - } catch (err: any) { - logger.warn(err, `Requested URL ${importURL} isn't a valid URL`); - throw new ServiceUnavailableException(`Couldn't fetch file from url "${importURL}"`, { - service: 'external-file', - }); - } - - let ip = resolvedUrl.hostname; - - if (net.isIP(ip) === 0) { - try { - ip = (await lookupDNS(ip)).address; - } catch (err: any) { - logger.warn(err, `Couldn't lookup the DNS for url ${importURL}`); - throw new ServiceUnavailableException(`Couldn't fetch file from url "${importURL}"`, { - service: 'external-file', - }); - } - } - - 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) { - logger.warn(`Requested URL ${importURL} resolves to localhost.`); - throw new ServiceUnavailableException(`Couldn't fetch file from url "${importURL}"`, { - service: 'external-file', - }); - } - } - } - } - - if (env.IMPORT_IP_DENY_LIST.includes(ip)) { - logger.warn(`Requested URL ${importURL} resolves to a denied IP address.`); - throw new ServiceUnavailableException(`Couldn't fetch file from url "${importURL}"`, { - service: 'external-file', - }); - } - let fileResponse; try { + const axios = await getAxios(); fileResponse = await axios.get(encodeURL(importURL), { responseType: 'stream', }); } catch (err: any) { - logger.warn(err, `Couldn't fetch file from url "${importURL}"`); + logger.warn(err, `Couldn't fetch file from URL "${importURL}"`); throw new ServiceUnavailableException(`Couldn't fetch file from url "${importURL}"`, { service: 'external-file', }); diff --git a/api/src/webhooks.ts b/api/src/webhooks.ts index a15e000cb7..d8a0cba190 100644 --- a/api/src/webhooks.ts +++ b/api/src/webhooks.ts @@ -1,11 +1,12 @@ +import { ActionHandler } from '@directus/shared/types'; import getDatabase from './database'; import emitter from './emitter'; import logger from './logger'; -import { Webhook, WebhookHeader } from './types'; -import { WebhooksService } from './services'; -import { getSchema } from './utils/get-schema'; -import { ActionHandler } from '@directus/shared/types'; import { getMessenger } from './messenger'; +import { getAxios } from './request/index'; +import { WebhooksService } from './services'; +import { Webhook, WebhookHeader } from './types'; +import { getSchema } from './utils/get-schema'; import { JobQueue } from './utils/job-queue'; let registered: { event: string; handler: ActionHandler }[] = []; @@ -55,9 +56,8 @@ export function unregister(): void { function createHandler(webhook: Webhook, event: string): ActionHandler { return async (meta, context) => { - const axios = (await import('axios')).default; - if (webhook.collections.includes(meta.collection) === false) return; + const axios = await getAxios(); const webhookPayload = { event,