Repository: directus/directus
File: api/src/services/files.ts (TypeScript)

Code before:

			fileResponse = await axios.get<Readable>(encodeURL(importURL), {
				responseType: 'stream',
				decompress: false,
			});
		} catch (error: any) {
			logger.warn(`Couldn't fetch file from URL "${importURL}"${error.message ? `: ${error.message}` : ''}`);
			logger.trace(error);

			throw new ServiceUnavailableError({
				service: 'external-file',
				reason: `Couldn't fetch file from URL "${importURL}"`,
			});
		}

		const parsedURL = url.parse(fileResponse.request.res.responseUrl);
		const filename = decodeURI(path.basename(parsedURL.pathname as string));

		const mimeType = fileResponse.headers['content-type']?.split(';')[0]?.trim() || 'application/octet-stream';

		// Check against global MIME type allow list from env
		const globalAllowedPatterns = toArray(env['FILES_MIME_TYPE_ALLOW_LIST'] as string | string[]);
		const globalMimeTypeAllowed = globalAllowedPatterns.some((pattern) => minimatch(mimeType, pattern));

		if (globalMimeTypeAllowed === false) {
			throw new InvalidPayloadError({ reason: `File is of invalid content type` });
		}

		// Check against interface-level MIME type restrictions if provided
		if (allowedMimeTypes && allowedMimeTypes.length > 0) {
			const interfaceMimeTypeAllowed = allowedMimeTypes.some((pattern) => minimatch(mimeType, pattern));

			if (interfaceMimeTypeAllowed === false) {
				throw new InvalidPayloadError({ reason: `File is of invalid content type` });
			}
		}

		const payload = {
			filename_download: filename,
			storage: toArray(env['STORAGE_LOCATIONS'] as string)[0]!,
			type: mimeType,
			title: formatTitle(filename),
			...(body || {}),
		};

		return await this.uploadOne(decompressResponse(fileResponse.data, fileResponse.headers), payload, payload.id);
	}

	/**
	 * Create a file (only applicable when it is not a multipart/data POST request)
	 * Useful for associating metadata with existing file in storage

Review comment (bug):
Unless there are security concerns here, it would be nice if the error provided a little more detail, such as whether it is restricted globally or at the interface level. It's a little confusing when setting an allowed MIME type on the interface and then have it fail because it is excluded from the env var.

Code after:
		let fileResponse;

		try {
			const axios = await getAxios();

			fileResponse = await axios.get<Readable>(encodeURL(importURL), {
				responseType: 'stream',
				decompress: false,
			});
		} catch (error: any) {
			logger.warn(`Couldn't fetch file from URL "${importURL}"${error.message ? `: ${error.message}` : ''}`);
			logger.trace(error);

			throw new ServiceUnavailableError({
				service: 'external-file',
				reason: `Couldn't fetch file from URL "${importURL}"`,
			});
		}

		const parsedURL = url.parse(fileResponse.request.res.responseUrl);
		const filename = decodeURI(path.basename(parsedURL.pathname as string));

		const mimeType = fileResponse.headers['content-type']?.split(';')[0]?.trim() || 'application/octet-stream';

		// Check against global MIME type allow list from env
		const globalAllowedPatterns = toArray(env['FILES_MIME_TYPE_ALLOW_LIST'] as string | string[]);
		const globalMimeTypeAllowed = globalAllowedPatterns.some((pattern) => minimatch(mimeType, pattern));

		if (globalMimeTypeAllowed === false) {
			throw new InvalidPayloadError({
				reason: `File content type "${mimeType}" is not allowed for upload by your global file type restrictions`,
			});
		}

		const { filterMimeType } = options;

		// Check against interface-level MIME type restrictions if provided
		if (filterMimeType && filterMimeType.length > 0) {
			const interfaceMimeTypeAllowed = filterMimeType.some((pattern: string) => minimatch(mimeType, pattern));

			if (interfaceMimeTypeAllowed === false) {
				throw new InvalidPayloadError({
					reason: `File content type "${mimeType}" is not allowed for upload by this field's file type restrictions`,
				});
			}
		}

		const payload = {
			filename_download: filename,
			storage: toArray(env['STORAGE_LOCATIONS'] as string)[0]!,
			type: mimeType,