CVE-2023-26492: Server-Side Request Forgery (SSRF) (CWE-918)
value: Directus is a real-time API and App dashboard for managing SQL database content. Directus is vulnerable to Server-Side Request Forgery (SSRF) when importing a file from a remote web server (POST to `/files/import`). An attacker can bypass the security controls by performing a DNS rebinding attack and view sensitive data from internal servers or perform a local port scan. An attacker can exploit this vulnerability to access highly sensitive internal server(s) and steal sensitive information. This issue was fixed in version 9.23.0.
CWE description: The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
CVSS v3 base score: 7.5
Published: 2023-03-03T22:15Z
Repository: https://github.com/directus/directus
Language: TypeScript
Files changed: api/src/env.ts, api/src/operations/request/index.test.ts, api/src/operations/request/index.ts, api/src/request/index.test.ts, api/src/request/index.ts, api/src/request/response-interceptor.test.ts, api/src/request/response-interceptor.ts, api/src/request/validate-ip.test.ts, api/src/request/validate-ip.ts, api/src/services/files.ts, api/src/webhooks.ts
Commit date: 2023-02-13 15:49:24 -0500
Commit message: Cleanup request handler

Squashed commit of the following:

commit 90368698c81ed731c6d9be8b02bfa8ad1b5edd3f
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Mon Feb 13 15:49:12 2023 -0500

    Cleanup

commit 61514f45096c22878527f4813fcb78d3b19e4687
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Mon Feb 13 15:44:15 2023 -0500

    Rename to index

commit 38fe6b84facf17409586d5103ff46081121232cc
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Mon Feb 13 15:41:23 2023 -0500

    Test coverage 100%

commit f2e36db95eb2ac744667f9d57cedbb7b883a574e
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Mon Feb 13 15:22:45 2023 -0500

    Split up handler from validator

commit 800ac1968afe065464fbd399dcb33861ce9fd5b2
Author: Pascal Jufer <pascal-jufer@bluewin.ch>
Date:   Mon Feb 13 20:44:48 2023 +0100

    Use shared axios instance with URL check for outgoing requests

Diff: 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
@@ -255,11 +255,11 @@ const defaults: Record<string, any> = {
 	ASSETS_INVALID_IMAGE_SENSITIVITY_LEVEL: 'warning',
 
 	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,
 
 	RELATIONAL_BATCH_SIZE: 25000,
 
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
@@ -1,16 +1,17 @@
 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 = '/';
 const method = 'POST';
 
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,7 +1,8 @@
 import { defineOperationApi, parseJSON } from '@directus/shared/utils';
 import encodeUrl from 'encodeurl';
+import { getAxios } from '../../request/index';
 
 type Options = {
 	url: string;
 	method: string;
 	body: Record<string, any> | string | null;
@@ -10,22 +11,21 @@ type Options = {
 
 export default defineOperationApi<Options>({
 	id: 'request',
 
 	handler: async ({ url, method, body, headers }) => {
-		const axios = (await import('axios')).default;
-
 		const customHeaders =
 			headers?.reduce((acc, { header, value }) => {
 				acc[header] = value;
 				return acc;
 			}, {} as Record<string, string>) ?? {};
 
 		if (!customHeaders['Content-Type'] && isValidJSON(body)) {
 			customHeaders['Content-Type'] = 'application/json';
 		}
 
+		const axios = await getAxios();
 		const result = await axios({
 			url: encodeUrl(url),
 			method,
 			data: body,
 			headers: customHeaders,
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<any, any>;
+
+beforeEach(() => {
+	sample = {
+		remoteAddress: randIp(),
+		url: randUrl(),
+	};
+
+	sampleResponseConfig = {
+		request: {
+			socket: {
+				remoteAddress: sample.remoteAddress,
+			},
+			url: sample.url,
+		},
+	} as AxiosResponse<any, any>;
+});
+
+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<any, any>) => {
+	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,34 +1,29 @@
 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';
 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);
 	}
 
@@ -222,76 +217,27 @@ export class FilesService extends ItemsService {
 
 	/**
 	 * Import a single file from an external URL
 	 */
 	async importOne(importURL: string, body: Partial<File>): Promise<PrimaryKey> {
-		const axios = (await import('axios')).default;
-
 		const fileCreatePermissions = this.accountability?.permissions?.find(
 			(permission) => permission.collection === 'directus_files' && permission.action === 'create'
 		);
 
 		if (this.accountability && this.accountability?.admin !== true && !fileCreatePermissions) {
 			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<Readable>(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,13 +1,14 @@
+import { ActionHandler } from '@directus/shared/types';
 import getDatabase from './database';
 import emitter from './emitter';
 import logger from './logger';
-import { Webhook, WebhookHeader } from './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 { ActionHandler } from '@directus/shared/types';
-import { getMessenger } from './messenger';
 import { JobQueue } from './utils/job-queue';
 
 let registered: { event: string; handler: ActionHandler }[] = [];
 
 const reloadQueue = new JobQueue();
@@ -53,13 +54,12 @@ export function unregister(): void {
 	registered = [];
 }
 
 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,
 			accountability: context.accountability
 				? {

Vulnerable code: 	IMPORT_IP_DENY_LIST: '0.0.0.0',
vi.mock('axios', () => ({
	default: axiosDefault.mockResolvedValue({
		status: 200,
		statusText: 'OK',
		headers: {},
		data: {},
	}),
		const axios = (await import('axios')).default;

import { lookup } from 'dns';
import net from 'net';
import os from 'os';
import url, { URL } from 'url';
import { promisify } from 'util';
const lookupDNS = promisify(lookup);

		const axios = (await import('axios')).default;

		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',
			});
		}

			logger.warn(err, `Couldn't fetch file from url "${importURL}"`);
import { Webhook, WebhookHeader } from './types';
import { ActionHandler } from '@directus/shared/types';
import { getMessenger } from './messenger';
		const axios = (await import('axios')).default;

Fixed code: 	IMPORT_IP_DENY_LIST: ['0.0.0.0', '169.254.169.254'],
vi.mock('../../request', () => ({
	getAxios: () =>
		axiosDefault.mockResolvedValue({
			status: 200,
			statusText: 'OK',
			headers: {},
			data: {},
		}),
import { getAxios } from '../../request/index';
		const axios = await getAxios();
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);
});
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;
}
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<any, any>;

beforeEach(() => {
	sample = {
		remoteAddress: randIp(),
		url: randUrl(),
	};

	sampleResponseConfig = {
		request: {
			socket: {
				remoteAddress: sample.remoteAddress,
			},
			url: sample.url,
		},
	} as AxiosResponse<any, any>;
});

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);
});
import type { AxiosResponse } from 'axios';
import { validateIP } from './validate-ip';

export const responseInterceptor = async (config: AxiosResponse<any, any>) => {
	await validateIP(config.request.socket.remoteAddress, config.request.url);
	return config;
};
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`);
	}
});
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`);
				}
			}
		}
	}
};
import url from 'url';
import { getAxios } from '../request/index';
			const axios = await getAxios();
			logger.warn(err, `Couldn't fetch file from URL "${importURL}"`);
import { ActionHandler } from '@directus/shared/types';
import { getMessenger } from './messenger';
import { getAxios } from './request/index';
import { Webhook, WebhookHeader } from './types';
		const axios = await getAxios();