Use shared axios instance with URL check for outgoing requests

This commit is contained in:
Pascal Jufer
2023-02-13 20:44:48 +01:00
parent ea91c40733
commit 800ac1968a
6 changed files with 137 additions and 73 deletions

View File

@@ -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 = '/';

View File

@@ -1,5 +1,6 @@
import { defineOperationApi, parseJSON } from '@directus/shared/utils';
import encodeUrl from 'encodeurl';
import { getAxios } from '../../request';
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,

55
api/src/request.test.ts Normal file
View File

@@ -0,0 +1,55 @@
import { test, expect, vi, describe } from 'vitest';
import { getAxios } from './request';
vi.mock('./env', () => ({
default: {
IMPORT_IP_DENY_LIST: ['0.0.0.0', '192.0.2.1'],
},
}));
vi.mock('axios', async () => {
const actual = (await vi.importActual('axios')) as any;
return {
default: {
...actual.default,
create: (option: any) => actual.default.create({ ...option, adapter: async () => ({ status: 200 }) }),
},
};
});
vi.mock('node:dns/promises', () => ({
lookup: async (hostname: any) => {
if (hostname === 'nonexisting.example.com') throw new Error(`getaddrinfo ENOTFOUND ${hostname}`);
return '198.51.100.1';
},
}));
vi.mock('node:os', () => ({
default: {
networkInterfaces: () => ({
lo: [
{
address: '127.0.0.1',
},
],
}),
},
}));
describe('should fail on invalid / denied URLs', async () => {
const axios = await getAxios();
test.each([
['example.com', 'Requested URL "example.com" is invalid'],
['127.0.0.1', 'Requested URL "127.0.0.1" is invalid'],
['https://nonexisting.example.com', `Couldn't lookup the DNS for URL "https://nonexisting.example.com"`],
['http://127.0.0.1', 'Requested URL "http://127.0.0.1" resolves to localhost'],
['http://192.0.2.1', 'Requested URL "http://192.0.2.1" resolves to a denied IP address'],
])('should block URL "%s"', async (url, expectedError) => {
await expect(axios.get(url)).rejects.toThrow(expectedError);
});
test.each(['https://example.com', 'http://192.0.2.2'])('should pass URL "%s"', async (url) => {
await expect(axios.get(url)).resolves.toContain({ status: 200 });
});
});

62
api/src/request.ts Normal file
View File

@@ -0,0 +1,62 @@
import type { AxiosInstance } from 'axios';
import { lookup } from 'node:dns/promises';
import net from 'node:net';
import os from 'node:os';
import env from './env';
let axiosInstance: AxiosInstance;
export async function getAxios() {
if (!axiosInstance) {
const axios = (await import('axios')).default;
axiosInstance = axios.create();
axiosInstance.interceptors.request.use(async (config) => {
if (config.url) {
await checkUrl(config.url);
}
return config;
});
}
return axiosInstance;
}
async function checkUrl(url: string) {
let parsedUrl;
try {
parsedUrl = new URL(url);
} catch {
throw new Error(`Requested URL "${url}" is invalid`);
}
let ip = parsedUrl.hostname;
if (net.isIP(ip) === 0) {
try {
ip = (await lookup(ip)).address;
} catch (err: any) {
throw new Error(`Couldn't lookup the DNS for URL "${url}" (${err.message || err})`);
}
}
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 localhost`);
}
}
}
}
if (env.IMPORT_IP_DENY_LIST.includes(ip)) {
throw new Error(`Requested URL "${url}" resolves to a denied IP address`);
}
}

View File

@@ -1,18 +1,14 @@
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';
@@ -24,8 +20,7 @@ import { ItemsService } from './items';
// @ts-ignore
import formatTitle from '@directus/format-title';
const lookupDNS = promisify(lookup);
import { getAxios } from '../request';
export class FilesService extends ItemsService {
constructor(options: AbstractServiceOptions) {
@@ -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',
});

View File

@@ -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 { WebhooksService } from './services';
import { getSchema } from './utils/get-schema';
import { ActionHandler } from '@directus/shared/types';
import { getMessenger } from './messenger';
import { getAxios } from './request';
import { WebhooksService } from './services';
import { Webhook, WebhookHeader } from './types';
import { getSchema } from './utils/get-schema';
import { JobQueue } from './utils/job-queue';
let registered: { event: string; handler: ActionHandler }[] = [];
@@ -55,8 +56,6 @@ 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 webhookPayload = {
@@ -71,6 +70,7 @@ function createHandler(webhook: Webhook, event: string): ActionHandler {
};
try {
const axios = await getAxios();
await axios({
url: webhook.url,
method: webhook.method,