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"