Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion global.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/// <reference types="jest-extended" />

declare module 'forest-ip-utils';
declare module 'eventsource';
declare module 'fastify2';
declare module 'fastify4';
1 change: 0 additions & 1 deletion packages/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
"@koa/router": "^13.1.0",
"@paralleldrive/cuid2": "2.2.2",
"@types/koa__router": "^12.0.4",
"forest-ip-utils": "^1.0.1",
"json-api-serializer": "^2.6.6",
"json-stringify-pretty-compact": "^3.0.0",
"jsonwebtoken": "^9.0.3",
Expand Down
35 changes: 30 additions & 5 deletions packages/agent/src/routes/security/ip-whitelist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,52 @@ import type { IpWhitelistConfiguration } from '@forestadmin/forestadmin-client';
import type Router from '@koa/router';
import type { Context, Next } from 'koa';

import IpUtil from 'forest-ip-utils';
import { BlockList, isIPv6 } from 'net';

import { HttpCode, RouteType } from '../../types';
import BaseRoute from '../base-route';

type IpRule = IpWhitelistConfiguration['ipRules'][number];

const LOOPBACK_IPS = ['127.0.0.1', '::1', '::ffff:127.0.0.1'];

const ipVersion = (ip: string): 'ipv4' | 'ipv6' => (isIPv6(ip) ? 'ipv6' : 'ipv4');

export default class IpWhitelist extends BaseRoute {
type = RouteType.Authentication;

private configuration: IpWhitelistConfiguration;

private allowedIps: BlockList;

setupRoutes(router: Router): void {
router.use(this.checkIp.bind(this));
}

/** Load whitelist */
override async bootstrap(): Promise<void> {
this.configuration = await this.options.forestAdminClient.getIpWhitelistConfiguration();
this.allowedIps = new BlockList();

this.configuration.ipRules.forEach(rule => {
try {
IpWhitelist.allowRule(this.allowedIps, rule);
} catch (error) {
this.options.logger('Warn', `IP whitelist: ignoring invalid rule ${JSON.stringify(rule)}`);
}
});
}

private static allowRule(allowedIps: BlockList, rule: IpRule): void {
if (rule.type === 0) {
allowedIps.addAddress(rule.ip, ipVersion(rule.ip));
} else if (rule.type === 1) {
allowedIps.addRange(rule.ipMinimum, rule.ipMaximum, ipVersion(rule.ipMinimum));
} else {
const [ip, prefix] = rule.range.split('/');

allowedIps.addSubnet(ip, Number(prefix), ipVersion(ip));
}
}

async checkIp(context: Context, next: Next): Promise<boolean> {
Expand All @@ -31,11 +58,9 @@ export default class IpWhitelist extends BaseRoute {
return next();
}

const { ipRules } = this.configuration;
const currentIp = context.request.headers['x-forwarded-for'] ?? context.request.ip;
const allowed = ipRules.some(ipRule => IpUtil.isIpMatchesRule(currentIp, ipRule));
const currentIp = `${context.request.headers['x-forwarded-for'] ?? context.request.ip}`;

if (!allowed) {
if (!this.allowedIps.check(currentIp, ipVersion(currentIp))) {
return context.throw(HttpCode.Forbidden, `IP address rejected (${currentIp})`);
}
}
Expand Down
140 changes: 140 additions & 0 deletions packages/agent/test/routes/security/ip-whitelist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,146 @@ describe('IpWhitelist', () => {
expect(next).toHaveBeenCalled();
});
});

describe('with IPv6 rules', () => {
test.each([
[{ type: 0, ip: '2001:db8::ff00:42:8329' }, '2001:0db8:0000:0000:0000:ff00:0042:8329'],
[{ type: 0, ip: '2001:0db8:0000:0000:0000:ff00:0042:8329' }, '2001:db8::ff00:42:8329'],
[{ type: 1, ipMinimum: '2001::1', ipMaximum: '2001:1::1' }, '2001::2'],
[{ type: 2, range: '2001:db8::/32' }, '2001:db8::1'],
])('should let pass an ip matching %j whatever its notation', async (rule, ip) => {
const ipWhitelistService = await setupIpWhitelistService({
isFeatureEnabled: true,
ipRules: [rule],
});

const context = createMockContext({ headers: { 'x-forwarded-for': ip } });
const next = jest.fn() as Next;

await ipWhitelistService.checkIp(context, next);

expect(next).toHaveBeenCalled();
expect(context.throw).not.toHaveBeenCalled();
});

test('should reject an ipv6 caller that is outside the rule', async () => {
const ipWhitelistService = await setupIpWhitelistService({
isFeatureEnabled: true,
ipRules: [{ type: 2, range: '2001:db8::/32' }],
});

const context = createMockContext({ headers: { 'x-forwarded-for': '2001:db9::1' } });
const next = jest.fn() as Next;

await ipWhitelistService.checkIp(context, next);

expect(next).not.toHaveBeenCalled();
expect(context.throw).toHaveBeenCalledWith(
HttpCode.Forbidden,
'IP address rejected (2001:db9::1)',
);
});

// An IPv4 caller reaching an IPv6 socket is seen as ::ffff:a.b.c.d and must still match the
// IPv4 rule it comes from.
test('should let pass an ipv4-mapped caller matching an ipv4 rule', async () => {
const ipWhitelistService = await setupIpWhitelistService({
isFeatureEnabled: true,
ipRules: [{ type: 2, range: '10.20.15.0/24' }],
});

const context = createMockContext({
headers: { 'x-forwarded-for': '::ffff:10.20.15.10' },
});
const next = jest.fn() as Next;

await ipWhitelistService.checkIp(context, next);

expect(next).toHaveBeenCalled();
expect(context.throw).not.toHaveBeenCalled();
});
});

describe('when a rule is malformed', () => {
test.each([
{ type: 0, ip: 'not-an-ip' },
{ type: 1, ipMinimum: '10.20.15.11', ipMaximum: '10.20.15.10' },
{ type: 1, ipMinimum: '10.20.15.10', ipMaximum: '2001::2' },
{ type: 2, range: '10.20.15.0/33' },
{ type: 2, range: '10.20.15.0' },
{ type: 9, ip: '10.20.15.10' },
])('should ignore %j instead of crashing the bootstrap', async rule => {
const ipWhitelistService = await setupIpWhitelistService({
isFeatureEnabled: true,
ipRules: [rule],
});

const context = createMockContext({ headers: { 'x-forwarded-for': '10.20.15.10' } });
const next = jest.fn() as Next;

await ipWhitelistService.checkIp(context, next);

expect(next).not.toHaveBeenCalled();
expect(context.throw).toHaveBeenCalledWith(
HttpCode.Forbidden,
'IP address rejected (10.20.15.10)',
);
});

test('should warn about the ignored rule and keep the valid ones', async () => {
const services = factories.forestAdminHttpDriverServices.build();
const logger = jest.fn();
const options = factories.forestAdminHttpDriverOptions.build({
logger,
forestAdminClient: factories.forestAdminClient.build({
getIpWhitelistConfiguration: jest.fn().mockResolvedValue({
isFeatureEnabled: true,
ipRules: [
{ type: 0, ip: 'not-an-ip' },
{ type: 0, ip: '10.20.15.10' },
],
}),
}),
});

const ipWhitelistService = new IpWhitelist(services, options);
await ipWhitelistService.bootstrap();

expect(logger).toHaveBeenCalledWith(
'Warn',
'IP whitelist: ignoring invalid rule {"type":0,"ip":"not-an-ip"}',
);

const context = createMockContext({ headers: { 'x-forwarded-for': '10.20.15.10' } });
const next = jest.fn() as Next;

await ipWhitelistService.checkIp(context, next);

expect(next).toHaveBeenCalled();
});
});

describe('when the ip cannot be read', () => {
test.each(['', 'not-an-ip', '10.20.15.10, 10.20.15.11'])(
'should reject the caller sending %s',
async forwardedFor => {
const ipWhitelistService = await setupIpWhitelistService({
isFeatureEnabled: true,
ipRules: [{ type: 0, ip: '10.20.15.10' }],
});

const context = createMockContext({
headers: { 'x-forwarded-for': forwardedFor },
});
const next = jest.fn() as Next;

await ipWhitelistService.checkIp(context, next);

expect(next).not.toHaveBeenCalled();
expect(context.throw).toHaveBeenCalledWith(HttpCode.Forbidden, expect.any(String));
},
);
});
});

describe('when the caller is a trusted internal caller (feature enabled, restrictive rules)', () => {
Expand Down
59 changes: 0 additions & 59 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1758,20 +1758,6 @@
"@fastify/forwarded" "^3.0.0"
ipaddr.js "^2.1.0"

"@forestadmin/ai-proxy@1.12.1":
version "1.12.1"
resolved "https://registry.yarnpkg.com/@forestadmin/ai-proxy/-/ai-proxy-1.12.1.tgz#84db6328e29b505c5bbd4b3afec0b9f420107cf2"
integrity sha512-CHZ+XIZTDxThFL0zTf14yusREW5sYJscgvBgBbdayTYYE4w6q2mxT3nwCx9zkS/VkrkV+JD0FEuA+VW4o0J6AQ==
dependencies:
"@forestadmin/agent-toolkit" "1.2.0"
"@forestadmin/datasource-toolkit" "1.54.0"
"@langchain/anthropic" "1.4.0"
"@langchain/core" "1.1.48"
"@langchain/langgraph" "1.3.7"
"@langchain/mcp-adapters" "1.1.3"
"@langchain/openai" "1.4.7"
zod "^4.3.5"

"@forestadmin/context@1.37.1":
version "1.37.1"
resolved "https://registry.yarnpkg.com/@forestadmin/context/-/context-1.37.1.tgz#301486c456061d43cb653b3e8be60644edb3f71a"
Expand Down Expand Up @@ -8816,14 +8802,6 @@ forest-cli@5.3.9:
uuid "8.0.0"
validate-npm-package-name "3.0.0"

forest-ip-utils@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/forest-ip-utils/-/forest-ip-utils-1.0.1.tgz#4c53a4c1e16f20beed71ee862315e18b34508d0c"
integrity sha512-m/pXGliPvJ6pt5/kyTgNT3X4AKHBdeKJX+cg1RVHWrQiqvD7Qs6WbSaP8/l1nJz1FhrLC/EQJAWXTj/kdJjDEQ==
dependencies:
ip-address "^5.8.9"
range_check "^1.4.0"

form-data@4.0.6, form-data@^4.0.0, form-data@^4.0.4, form-data@^4.0.5, form-data@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827"
Expand Down Expand Up @@ -9949,30 +9927,11 @@ ip-address@10.1.0, ip-address@^10.0.1, ip-address@^10.1.1:
resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.2.0.tgz#805fc178b20c518bd4c8548b24fe30892d7f3206"
integrity sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==

ip-address@^5.8.9:
version "5.9.4"
resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-5.9.4.tgz#4660ac261ad61bd397a860a007f7e98e4eaee386"
integrity sha512-dHkI3/YNJq4b/qQaz+c8LuarD3pY24JqZWfjB8aZx1gtpc2MDILu9L9jpZe1sHpzo/yWFweQVn+U//FhazUxmw==
dependencies:
jsbn "1.1.0"
lodash "^4.17.15"
sprintf-js "1.1.2"

ip-regex@5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-5.0.0.tgz#cd313b2ae9c80c07bd3851e12bf4fa4dc5480632"
integrity sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw==

ip6@0.0.4:
version "0.0.4"
resolved "https://registry.yarnpkg.com/ip6/-/ip6-0.0.4.tgz#44c5a9db79e39d405201b4d78d13b3870e48db31"
integrity sha512-0FJ0rKtTQYCp92ru/hNzNpmy2sa8nINqbuyiiVOI75+ltMtAly9dtEparm+xh//kndXL/s6FOdWzSYvUDiBpbg==

ipaddr.js@1.2:
version "1.2.0"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.2.0.tgz#8aba49c9192799585bdd643e0ccb50e8ae777ba4"
integrity sha512-ku//LD7ie/m9UVGCm9KweBIIHP4mB0maNGvav6Hz778fQCNLQF7iZ+H/UuHuqmjJCHCpA5hw8hOeRKxZl8IlXw==

ipaddr.js@1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
Expand Down Expand Up @@ -11026,11 +10985,6 @@ js-yaml@^3.13.1, js-yaml@^3.14.1:
argparse "^1.0.7"
esprima "^4.0.0"

jsbn@1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040"
integrity sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==

jsesc@^2.5.1:
version "2.5.2"
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
Expand Down Expand Up @@ -14810,14 +14764,6 @@ range-parser@^1.2.0, range-parser@^1.2.1, range-parser@~1.2.1:
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==

range_check@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/range_check/-/range_check-1.4.0.tgz#cd87c7ac62c40ba9df69b8703c604f60c3748635"
integrity sha512-UhRtGxKoAm7NQqAPj3P6JVUlgs3hehoV+pA593il2FiHYZUK49/535laoi3Rvz7lP04XLCAdgbL4TprpVZZ/IQ==
dependencies:
ip6 "0.0.4"
ipaddr.js "1.2"

raw-body@^2.3.3:
version "2.5.2"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a"
Expand Down Expand Up @@ -16051,11 +15997,6 @@ split@^1.0.1:
dependencies:
through "2"

sprintf-js@1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.2.tgz#da1765262bf8c0f571749f2ad6c26300207ae673"
integrity sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==

sprintf-js@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a"
Expand Down
Loading