diff --git a/global.d.ts b/global.d.ts
index 5c83a997cd..46ba1b68b5 100644
--- a/global.d.ts
+++ b/global.d.ts
@@ -1,6 +1,5 @@
///
-declare module 'forest-ip-utils';
declare module 'eventsource';
declare module 'fastify2';
declare module 'fastify4';
diff --git a/packages/agent/package.json b/packages/agent/package.json
index 98566039b2..c5724ed572 100644
--- a/packages/agent/package.json
+++ b/packages/agent/package.json
@@ -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",
diff --git a/packages/agent/src/routes/security/ip-whitelist.ts b/packages/agent/src/routes/security/ip-whitelist.ts
index ca685571f2..0b8fb94755 100644
--- a/packages/agent/src/routes/security/ip-whitelist.ts
+++ b/packages/agent/src/routes/security/ip-whitelist.ts
@@ -2,18 +2,24 @@ 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));
}
@@ -21,6 +27,27 @@ export default class IpWhitelist extends BaseRoute {
/** Load whitelist */
override async bootstrap(): Promise {
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 {
@@ -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})`);
}
}
diff --git a/packages/agent/test/routes/security/ip-whitelist.test.ts b/packages/agent/test/routes/security/ip-whitelist.test.ts
index 096a1d8241..330515c2fd 100644
--- a/packages/agent/test/routes/security/ip-whitelist.test.ts
+++ b/packages/agent/test/routes/security/ip-whitelist.test.ts
@@ -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)', () => {
diff --git a/yarn.lock b/yarn.lock
index 3fcd58674b..7255e857e9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"