Split up handler from validator

This commit is contained in:
rijkvanzanten
2023-02-13 15:22:45 -05:00
parent 800ac1968a
commit f2e36db95e
8 changed files with 126 additions and 65 deletions

View File

@@ -1,6 +1,6 @@
import { defineOperationApi, parseJSON } from '@directus/shared/utils';
import encodeUrl from 'encodeurl';
import { getAxios } from '../../request';
import { getAxios } from '../../request/request';
type Options = {
url: string;

View File

@@ -1,62 +0,0 @@
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

@@ -0,0 +1,19 @@
import type { AxiosInstance } from 'axios';
import { validateIP } from './validate-ip';
let axiosInstance: AxiosInstance;
export async function getAxios() {
if (!axiosInstance) {
const axios = (await import('axios')).default;
axiosInstance = axios.create();
axiosInstance.interceptors.response.use(async (config) => {
await validateIP(config.request.socket.remoteAddress, config.request.url);
return config;
});
}
return axiosInstance;
}

View File

@@ -0,0 +1,80 @@
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({
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`);
}
});

View File

@@ -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`);
}
}
}
}
};

View File

@@ -20,7 +20,7 @@ import { ItemsService } from './items';
// @ts-ignore
import formatTitle from '@directus/format-title';
import { getAxios } from '../request';
import { getAxios } from '../request/request';
export class FilesService extends ItemsService {
constructor(options: AbstractServiceOptions) {

View File

@@ -3,7 +3,7 @@ import getDatabase from './database';
import emitter from './emitter';
import logger from './logger';
import { getMessenger } from './messenger';
import { getAxios } from './request';
import { getAxios } from './request/request';
import { WebhooksService } from './services';
import { Webhook, WebhookHeader } from './types';
import { getSchema } from './utils/get-schema';