CVE-2023-26492 (TypeScript, published 2023-03-03T22:15Z)
Description: 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.
Severity: nan (CVSS v3 base score: 7.5)
Weakness: CWE-918 \u2014 Server-Side Request Forgery (SSRF)
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.
Repository: https://github.com/directus/directus
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 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


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();