diff --git a/README.md b/README.md index a687bdd..33885f1 100644 --- a/README.md +++ b/README.md @@ -36,15 +36,19 @@ const castle = Castle({ apiSecret: 'YOUR SECRET HERE' }); #### Config options -| Config option | Explanation | -| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| apiSecret | `string` - This can be found in the castle dashboard. | -| timeout | `number` - Time before returning the failover strategy. Default value is 500. | -| allowlisted | `string[]` - An array of strings matching the headers you want to pass fully to the service. | -| denylisted | `string[]` - An array of of strings matching the headers you do not want to pass fully to the service. | +| Config option | Explanation | +| ----------------- | ----------- | +| apiSecret | `string` - This can be found in the castle dashboard. | +| timeout | `number` - Time before returning the failover strategy. Default value is 500. | +| allowlisted | `string[]` - An array of strings matching the headers you want to pass fully to the service. | +| denylisted | `string[]` - An array of of strings matching the headers you do not want to pass fully to the service. | | failoverStrategy | `string` - If the request to our service would for some reason time out, this is where you select the automatic response from `authenticate`. Options are `allow`, `deny`, `challenge`. | -| logLevel | `string` - Corresponds to standard log levels: `trace`, `debug`, `info`, `warn`, `error`, `fatal`. Useful levels are `info` and `error`. | -| doNotTrack | `boolean` - False by default, setting it to true turns off all requests and triggers automatic failover on `authenticate`. Used for development and testing. | +| logLevel | `string` - Corresponds to standard log levels: `trace`, `debug`, `info`, `warn`, `error`, `fatal`. Useful levels are `info` and `error`. | +| doNotTrack | `boolean` - False by default, setting it to true turns off all requests and triggers automatic failover on `authenticate`. Used for development and testing. | +| ipHeaders | `string[]` - IP Headers to look for a client IP address. | +| trustedProxies | `string[]` - Trusted public proxies list. | +| trustProxyChain | `boolean` - False by default, defines if trusting all of the proxy IPs in X-Forwarded-For is enabled. | +| trustedProxyDepth | `number` - Number of trusted proxies used in the chain. | ## Actions diff --git a/src/Castle.ts b/src/Castle.ts index 6ddd2e1..3bd877c 100644 --- a/src/Castle.ts +++ b/src/Castle.ts @@ -2,7 +2,11 @@ import fetch from 'node-fetch'; import AbortController from 'abort-controller'; import pino from 'pino'; -import { DEFAULT_ALLOWLIST } from './constants'; +import { + DEFAULT_ALLOWLIST, + DEFAULT_API_URL, + DEFAULT_TIMEOUT, +} from './constants'; import { AuthenticateResult, Configuration, Payload } from './models'; import { CommandAuthenticateService, @@ -14,9 +18,6 @@ import { } from './failover/failover.module'; import { LoggerService } from './logger/logger.module'; -const DEFAULT_API_URL = 'https://api.castle.io/v1'; -const DEFAULT_TIMEOUT = 1000; - // The body on the request is a stream and can only be // read once, by default. This is a workaround so that the // logging functions can read the body independently @@ -51,6 +52,10 @@ export class Castle { failoverStrategy = FailoverStrategy.allow, logLevel = 'error', doNotTrack = false, + ipHeaders = [], + trustedProxies = [], + trustProxyChain = false, + trustedProxyDepth = 0, }: Configuration) { if (!apiSecret) { throw new Error( @@ -70,6 +75,10 @@ export class Castle { failoverStrategy, logLevel, doNotTrack, + ipHeaders, + trustedProxies: trustedProxies.map((proxy) => new RegExp(proxy)), + trustProxyChain, + trustedProxyDepth, }; this.logger = pino({ prettyPrint: { diff --git a/src/constants/default-allowlist.ts b/src/constants/configuration.ts similarity index 54% rename from src/constants/default-allowlist.ts rename to src/constants/configuration.ts index 8db3178..65a833a 100644 --- a/src/constants/default-allowlist.ts +++ b/src/constants/configuration.ts @@ -1,3 +1,5 @@ +export const DEFAULT_API_URL = 'https://api.castle.io/v1'; +export const DEFAULT_TIMEOUT = 1000; export const DEFAULT_ALLOWLIST = [ 'accept', 'accept-charset', @@ -23,3 +25,17 @@ export const DEFAULT_ALLOWLIST = [ 'x-castle-client-id', 'x-requested-with', ]; +export const TRUSTED_PROXIES = [ + new RegExp( + [ + /^127\.0\.0\.1$|/, + /^(10|172\.(1[6-9]|2[0-9]|30|31)|192\.168)\.|/, + /^::1\Z|\Afd[0-9a-f]{2}:.+|/, + /^localhost$|/, + /^unix$|/, + /^unix:/, + ] + .map((r) => r.source) + .join('') + ), +]; diff --git a/src/constants/index.ts b/src/constants/index.ts index 3fad63a..75a1114 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1 +1 @@ -export * from './default-allowlist'; +export * from './configuration'; diff --git a/src/context/services/context-get-default.service.ts b/src/context/services/context-get-default.service.ts index 3e51a77..33fe5c2 100644 --- a/src/context/services/context-get-default.service.ts +++ b/src/context/services/context-get-default.service.ts @@ -1,5 +1,6 @@ import { Configuration } from '../../models'; import { HeadersExtractService } from '../../headers/headers.module'; +import { IPsExtractService } from '../../ips/ips.module'; import { version } from '../../../package.json'; export const ContextGetDefaultService = { @@ -7,6 +8,7 @@ export const ContextGetDefaultService = { return { client_id: context.client_id || false, headers: HeadersExtractService.call(context.headers, configuration), + ip: IPsExtractService.call(context.headers, configuration), library: { name: 'castle-node', version, diff --git a/src/headers/services/headers-extract.service.ts b/src/headers/services/headers-extract.service.ts index 73316e2..5916ca8 100644 --- a/src/headers/services/headers-extract.service.ts +++ b/src/headers/services/headers-extract.service.ts @@ -8,7 +8,7 @@ const ALWAYS_DENYLISTED = ['cookie', 'authorization']; export const HeadersExtractService = { call: ( headers: IncomingHttpHeaders, - { allowlisted, denylisted }: Configuration + { allowlisted = [], denylisted = [] }: Configuration ) => { return reduce( headers, diff --git a/src/ips/ips.module.ts b/src/ips/ips.module.ts new file mode 100644 index 0000000..e371345 --- /dev/null +++ b/src/ips/ips.module.ts @@ -0,0 +1 @@ +export * from './services'; diff --git a/src/ips/services/index.ts b/src/ips/services/index.ts new file mode 100644 index 0000000..e806208 --- /dev/null +++ b/src/ips/services/index.ts @@ -0,0 +1 @@ +export * from './ips-extract.service'; diff --git a/src/ips/services/ips-extract.service.ts b/src/ips/services/ips-extract.service.ts new file mode 100644 index 0000000..3b1b85a --- /dev/null +++ b/src/ips/services/ips-extract.service.ts @@ -0,0 +1,67 @@ +import { IncomingHttpHeaders } from 'http'; +import { Configuration } from '../../models'; +import { TRUSTED_PROXIES } from '../../constants'; + +// ordered list of ip headers for ip extraction +const DEFAULT = ['x-forwarded-for', 'remote-addr']; +// list of header which are used with proxy depth setting +const DEPTH_RELATED = ['x-forwarded-for']; + +const checkInternal = (ipAddress: string, proxies: any[]) => { + return proxies.some((proxyRegexp) => proxyRegexp.test(ipAddress)); +}; + +const limitProxyDepth = (ips, ipHeader, trustedProxyDepth) => { + if (DEPTH_RELATED.includes(ipHeader)) { + ips.splice(ips.length - 1, trustedProxyDepth); + } + return ips; +}; + +const IPsFrom = (ipHeader, headers, trustedProxyDepth) => { + if (!headers) { + return []; + } + const headerValue = headers[ipHeader]; + + if (!headerValue) { + return []; + } + const ips = headerValue.trim().split(/[,\s]+/); + return limitProxyDepth(ips, ipHeader, trustedProxyDepth); +}; + +const removeProxies = (ips, trustProxyChain, proxiesList) => { + if (trustProxyChain) { + return ips[0]; + } + const filteredIps = ips.filter((ip) => !checkInternal(ip, proxiesList)); + return filteredIps[filteredIps.length - 1]; +}; + +export const IPsExtractService = { + call: ( + headers: IncomingHttpHeaders, + { + ipHeaders = [], + trustedProxies = [], + trustProxyChain = false, + trustedProxyDepth = 0, + }: Configuration + ) => { + const ipHeadersList = ipHeaders.length ? ipHeaders : DEFAULT; + const proxiesList = trustedProxies.concat(TRUSTED_PROXIES); + let allIPs = []; + for (const ipHeader of ipHeadersList) { + const IPs = IPsFrom(ipHeader, headers, trustedProxyDepth); + const IPValue = removeProxies(IPs, trustProxyChain, proxiesList); + if (IPValue) { + return IPValue; + } + allIPs = [...allIPs, ...IPs]; + } + + // fallback to first listed ip + return allIPs[0]; + }, +}; diff --git a/src/models/configuration.ts b/src/models/configuration.ts index 8e2b6e6..1815ba6 100644 --- a/src/models/configuration.ts +++ b/src/models/configuration.ts @@ -11,4 +11,8 @@ export type Configuration = { failoverStrategy?: FailoverStrategy; logLevel?: pino.Level; doNotTrack?: boolean; + ipHeaders?: string[]; + trustedProxies?: RegExp[]; + trustProxyChain?: boolean; + trustedProxyDepth?: number; }; diff --git a/test/Castle.test.ts b/test/Castle.test.ts index 9e3e353..46be12b 100644 --- a/test/Castle.test.ts +++ b/test/Castle.test.ts @@ -17,6 +17,7 @@ const sampleRequestData = { client_id: 'clientid', headers: { Cookie: 'SECRET=pleasedontbehere', + 'x-forwarded-for': '8.8.8.8', }, }, }; diff --git a/test/context/services/context-get-default.service.test.ts b/test/context/services/context-get-default.service.test.ts index eb63957..1995a26 100644 --- a/test/context/services/context-get-default.service.test.ts +++ b/test/context/services/context-get-default.service.test.ts @@ -4,21 +4,29 @@ import { version } from '../../../package.json'; describe('ContextGetDefaultService', () => { describe('call', () => { const expected = { - headers: {}, + headers: { + 'x-forwarded-for': '1.2.3.4', + }, library: { name: 'castle-node', version, }, client_id: 'client_id', + ip: '1.2.3.4', }; const config = { apiSecret: 'test', apiUrl: 'castle.io', + denylisted: [], + allowlisted: [], }; const context = { client_id: 'client_id', + headers: { + 'x-forwarded-for': '1.2.3.4', + }, }; it('generates default context', () => { diff --git a/test/core/services/core-generate-request-body.service.test.ts b/test/core/services/core-generate-request-body.service.test.ts index 0fa792b..05fe2dd 100644 --- a/test/core/services/core-generate-request-body.service.test.ts +++ b/test/core/services/core-generate-request-body.service.test.ts @@ -19,7 +19,9 @@ describe('CoreGenerateRequestBody', () => { context: { ip: '127.0.0.1', client_id: 'client_id', - headers: {}, + headers: { + 'x-forwarded-for': '127.0.0.1', + }, library: { name: 'castle-node', version, @@ -37,7 +39,9 @@ describe('CoreGenerateRequestBody', () => { context: { ip: '127.0.0.1', client_id: 'client_id', - headers: {}, + headers: { + 'x-forwarded-for': '127.0.0.1', + }, }, }; diff --git a/test/ips/services/ips-extract.service.test.ts b/test/ips/services/ips-extract.service.test.ts new file mode 100644 index 0000000..2830b90 --- /dev/null +++ b/test/ips/services/ips-extract.service.test.ts @@ -0,0 +1,139 @@ +import { IPsExtractService } from '../../../src/ips/ips.module'; + +describe('IPsExtractService', () => { + describe('call', () => { + describe('when regular IPs', () => { + const headers = { + 'x-forwarded-for': '1.2.3.5', + }; + + const config = { + apiSecret: 'test', + ipHeaders: [], + trustedProxies: [], + }; + + it('extracts correct IPs', () => { + expect(IPsExtractService.call(headers, config)).toEqual('1.2.3.5'); + }); + }); + + describe('when we need to use other IP header', () => { + const headers = { + 'cf-connecting-ip': '1.2.3.4', + 'x-forwarded-for': '1.1.1.1, 1.2.2.2, 1.2.3.5', + }; + + describe('regular format', () => { + const config = { + apiSecret: 'test', + ipHeaders: ['cf-connecting-ip', 'x-forwarded-for'], + trustedProxies: [], + }; + + it('extracts correct IPs', () => { + expect(IPsExtractService.call(headers, config)).toEqual('1.2.3.4'); + }); + }); + + describe('with value from trusted proxies it get seconds header', () => { + const config = { + apiSecret: 'test', + ipHeaders: ['cf-connecting-ip', 'x-forwarded-for'], + trustedProxies: [new RegExp('1.2.3.4')], + }; + + it('extracts correct IPs', () => { + expect(IPsExtractService.call(headers, config)).toEqual('1.2.3.5'); + }); + }); + }); + + describe('when all the trusted proxies', () => { + const headers = { + 'x-forwarded-for': '127.0.0.1,10.0.0.1,172.31.0.1,192.168.0.1', + 'remote-addr': '127.0.0.1', + }; + + const config = { + apiSecret: 'test', + ipHeaders: [], + trustedProxies: [], + }; + + it('fallbacks to first available header when all headers are marked trusted proxy', () => { + expect(IPsExtractService.call(headers, config)).toEqual('127.0.0.1'); + }); + }); + + describe('when trustProxyChain option', () => { + const headers = { + 'x-forwarded-for': '6.6.6.6, 2.2.2.3, 6.6.6.5', + 'remote-addr': '6.6.6.4', + }; + + const config = { + apiSecret: 'test', + ipHeaders: [], + trustedProxies: [], + trustProxyChain: true, + }; + + it('selects first available header', () => { + expect(IPsExtractService.call(headers, config)).toEqual('6.6.6.6'); + }); + }); + + describe('when trustedProxyDepth option', () => { + const headers = { + 'x-forwarded-for': '6.6.6.6, 2.2.2.3, 6.6.6.5', + 'remote-addr': '6.6.6.4', + }; + + const config = { + apiSecret: 'test', + ipHeaders: [], + trustedProxies: [], + trustedProxyDepth: 1, + }; + + it('selects first available header', () => { + expect(IPsExtractService.call(headers, config)).toEqual('2.2.2.3'); + }); + }); + + describe('when list of not trusted IPs provided in x-forwarded-for', () => { + const headers = { + 'x-forwarded-for': '6.6.6.6, 2.2.2.3, 192.168.0.7', + 'client-ip': '6.6.6.6', + }; + + const config = { + apiSecret: 'test', + ipHeaders: [], + trustedProxies: [], + }; + + it('does not allow to spoof IP', () => { + expect(IPsExtractService.call(headers, config)).toEqual('2.2.2.3'); + }); + }); + + describe('when marked 2.2.2.3 as trusted proxy', () => { + const headers = { + 'x-forwarded-for': '6.6.6.6, 2.2.2.3, 192.168.0.7', + 'client-ip': '6.6.6.6', + }; + + const config = { + apiSecret: 'test', + ipHeaders: [], + trustedProxies: [new RegExp(/^2.2.2.\d$/)], + }; + + it('does not allow to spoof IP', () => { + expect(IPsExtractService.call(headers, config)).toEqual('6.6.6.6'); + }); + }); + }); +});