Cleanup request handler

Squashed commit of the following:

commit 90368698c8
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Mon Feb 13 15:49:12 2023 -0500

    Cleanup

commit 61514f4509
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Mon Feb 13 15:44:15 2023 -0500

    Rename to index

commit 38fe6b84fa
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Mon Feb 13 15:41:23 2023 -0500

    Test coverage 100%

commit f2e36db95e
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Mon Feb 13 15:22:45 2023 -0500

    Split up handler from validator

commit 800ac1968a
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
This commit is contained in:
rijkvanzanten
2023-02-13 15:49:24 -05:00
parent ea91c40733
commit ff53d3e69a
11 changed files with 224 additions and 74 deletions

View File

@@ -0,0 +1,31 @@
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);
});

16
api/src/request/index.ts Normal file
View File

@@ -0,0 +1,16 @@
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;
}

View File

@@ -0,0 +1,44 @@
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);
});

View File

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

View File

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

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