CVE-2022-23080: Server-Side Request Forgery (SSRF) (CWE-918)
value: In directus versions v9.0.0-beta.2 through 9.6.0 are vulnerable to server-side request forgery (SSRF) in the media upload functionality which allows a low privileged user to perform internal network port scans.
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: 5
Published: 2022-06-22T16:15Z
Repository: https://github.com/directus/directus
Language: TypeScript
Files changed: api/src/env.ts, api/src/services/files.ts, docs/configuration/config-options.md
Commit date: 2022-03-07 18:09:08 -0500
Commit message: Add support for import ip deny list (#12025)

* Add support for import ip deny list

* Fix typo
Diff: diff --git a/api/src/env.ts b/api/src/env.ts
index 474a403ff..0a35b3d27 100644
--- a/api/src/env.ts
+++ b/api/src/env.ts
@@ -75,10 +75,12 @@ const defaults: Record<string, any> = {
 	ASSETS_TRANSFORM_MAX_OPERATIONS: 5,
 
 	IP_TRUST_PROXY: true,
 	IP_CUSTOM_HEADER: false,
 
+	IMPORT_IP_DENY_LIST: '0.0.0.0',
+
 	SERVE_APP: true,
 
 	RELATIONAL_BATCH_SIZE: 25000,
 };
 
@@ -93,10 +95,11 @@ const typeMap: Record<string, string> = {
 	DB_PASSWORD: 'string',
 	DB_DATABASE: 'string',
 	DB_PORT: 'number',
 
 	DB_EXCLUDE_TABLES: 'array',
+	IMPORT_IP_DENY_LIST: 'array',
 };
 
 let env: Record<string, any> = {
 	...defaults,
 	...getEnv(),
diff --git a/api/src/services/files.ts b/api/src/services/files.ts
index af284eaa2..a6f169ea7 100644
--- a/api/src/services/files.ts
+++ b/api/src/services/files.ts
@@ -3,19 +3,25 @@ import axios, { AxiosResponse } from 'axios';
 import exifr from 'exifr';
 import { clone } from 'lodash';
 import { extension } from 'mime-types';
 import path from 'path';
 import sharp from 'sharp';
-import url from 'url';
+import url, { URL } from 'url';
+import { promisify } from 'util';
+import { lookup } from 'dns';
 import emitter from '../emitter';
 import env from '../env';
 import { ForbiddenException, ServiceUnavailableException } from '../exceptions';
 import logger from '../logger';
 import storage from '../storage';
 import { AbstractServiceOptions, File, PrimaryKey, MutationOptions } from '../types';
 import { toArray } from '@directus/shared/utils';
 import { ItemsService } from './items';
+import net from 'net';
+import os from 'os';
+
+const lookupDNS = promisify(lookup);
 
 export class FilesService extends ItemsService {
 	constructor(options: AbstractServiceOptions) {
 		super('directus_files', options);
 	}
@@ -159,19 +165,66 @@ export class FilesService extends ItemsService {
 
 		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: AxiosResponse<NodeJS.ReadableStream>;
 
 		try {
 			fileResponse = await axios.get<NodeJS.ReadableStream>(importURL, {
 				responseType: 'stream',
 			});
 		} catch (err: any) {
-			logger.warn(`Couldn't fetch file from url "${importURL}"`);
-			logger.warn(err);
+			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/docs/configuration/config-options.md b/docs/configuration/config-options.md
index 6b84f51a9..6acfb0ef2 100644
--- a/docs/configuration/config-options.md
+++ b/docs/configuration/config-options.md
@@ -278,10 +278,11 @@ All the `DB_POOL_` prefixed options are passed to [`tarn.js`](https://github.com
 | `USER_INVITE_URL_ALLOW_LIST`     | List of URLs that can be used [as `invite_url` in /users/invite](/reference/system/users/#invite-a-new-user)                                                     | --                       |
 | `IP_TRUST_PROXY`                 | Settings for [express' trust proxy setting](https://expressjs.com/en/guide/behind-proxies.html)                                                                  | true                     |
 | `IP_CUSTOM_HEADER`               | What custom request header to use for the IP address                                                                                                             | false                    |
 | `CONTENT_SECURITY_POLICY`        | Custom overrides for the Content-Security-Policy header. See [helmet's documentation](https://helmetjs.github.io) for more information.                          | --                       |
 | `ASSETS_CONTENT_SECURITY_POLICY` | Custom overrides for the Content-Security-Policy header for the /assets endpoint. See [helmet's documentation](https://helmetjs.github.io) for more information. | --                       |
+| `IMPORT_IP_DENY_LIST`            | Deny importing files from these IP addresses. Use `0.0.0.0` for any local IP address                                                                             | `0.0.0.0`                |
 
 ::: tip Cookie Strictness
 
 Browser are pretty strict when it comes to third-party cookies. If you're running into unexpected problems when running
 your project and API on different domains, make sure to verify your configuration for `REFRESH_TOKEN_COOKIE_NAME`,

Vulnerable code: import url from 'url';
			logger.warn(`Couldn't fetch file from url "${importURL}"`);
			logger.warn(err);
Fixed code: 	IMPORT_IP_DENY_LIST: '0.0.0.0',

	IMPORT_IP_DENY_LIST: 'array',
import url, { URL } from 'url';
import { promisify } from 'util';
import { lookup } from 'dns';
import net from 'net';
import os from 'os';

const lookupDNS = promisify(lookup);
		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_IP_DENY_LIST`            | Deny importing files from these IP addresses. Use `0.0.0.0` for any local IP address                                                                             | `0.0.0.0`                |