CVE-2023-26492 (published 2023-03-03T22:15Z)

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.

CVSS v3 base score: 7.5, HIGH (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N)

Weakness information:
cwe_id: CWE-918
cwe_name: Server-Side Request Forgery (SSRF)
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.
extended_description: By providing URLs to unexpected hosts or ports, attackers can make it appear that the server is sending the request, possibly bypassing access controls such as firewalls that prevent the attackers from accessing the URLs directly. The server can be used as a proxy to conduct port scanning of hosts in internal networks, use other URLs such as that can access documents on the system (using file://), or use other protocols such as gopher:// or tftp://, which may provide greater control over the contents of requests.
         

Fix information:
repo_url: https://github.com/directus/directus
commit_details: repo_url: https://github.com/directus/directus
author: rijkvanzanten
author_date: 2023-02-13 15:49:24-05:00
msg: 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
repository: repo_url: https://github.com/directus/directus
repo_name: directus/directus
description: The Modern Data Stack 🐰 — Directus is an instant REST+GraphQL API and intuitive no-code data collaboration app for any SQL database.
repo_language: TypeScript
owner: directus
file_changes: filename: env.ts
change_type: ModificationType.MODIFY
diff: @@ -257,7 +257,7 @@ const defaults: Record<string, any> = {
 	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,
 

programming_language: JavaScript
filename: index.test.ts
change_type: ModificationType.MODIFY
diff: @@ -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 = '/';

programming_language: TypeScript
filename: index.ts
change_type: ModificationType.MODIFY
diff: @@ -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<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;
@@ -24,6 +23,7 @@ export default defineOperationApi<Options>({
 			customHeaders['Content-Type'] = 'application/json';
 		}
 
+		const axios = await getAxios();
 		const result = await axios({
 			url: encodeUrl(url),
 			method,

programming_language: TypeScript
filename: index.test.ts
change_type: ModificationType.ADD
diff: @@ -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);
+});

programming_language: TypeScript
filename: index.ts
change_type: ModificationType.ADD
diff: @@ -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;
+}

programming_language: TypeScript
filename: response-interceptor.test.ts
change_type: ModificationType.ADD
diff: @@ -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);
+});

programming_language: TypeScript
filename: response-interceptor.ts
change_type: ModificationType.ADD
diff: @@ -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;
+};

programming_language: TypeScript
filename: validate-ip.test.ts
change_type: ModificationType.ADD
diff: @@ -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`);
+	}
+});

programming_language: TypeScript
filename: validate-ip.ts
change_type: ModificationType.ADD
diff: @@ -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`);
+				}
+			}
+		}
+	}
+};

programming_language: TypeScript
filename: files.ts
change_type: ModificationType.MODIFY
diff: @@ -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<File>): Promise<PrimaryKey> {
-		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<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',
 			});

programming_language: TypeScript
filename: webhooks.ts
change_type: ModificationType.MODIFY
diff: @@ -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 { 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 }[] = [];
@@ -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,

programming_language: TypeScript