diff --git a/packages/agent/src/routes/security/ip-whitelist.ts b/packages/agent/src/routes/security/ip-whitelist.ts index ab88476127..ca685571f2 100644 --- a/packages/agent/src/routes/security/ip-whitelist.ts +++ b/packages/agent/src/routes/security/ip-whitelist.ts @@ -7,6 +7,8 @@ import IpUtil from 'forest-ip-utils'; import { HttpCode, RouteType } from '../../types'; import BaseRoute from '../base-route'; +const LOOPBACK_IPS = ['127.0.0.1', '::1', '::ffff:127.0.0.1']; + export default class IpWhitelist extends BaseRoute { type = RouteType.Authentication; @@ -23,6 +25,12 @@ export default class IpWhitelist extends BaseRoute { async checkIp(context: Context, next: Next): Promise { if (this.configuration.isFeatureEnabled) { + if (this.isTrustedInternalCaller(context)) { + this.options.logger('Debug', 'IP whitelist: exempting trusted internal caller'); + + 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)); @@ -34,4 +42,14 @@ export default class IpWhitelist extends BaseRoute { return next(); } + + // A caller reaching us directly over a loopback socket with no proxy hop (no x-forwarded-for) is + // on this host and already JWT-authenticated (koa-jwt runs before this) — e.g. the embedded + // executor's loopback call. Keys off the socket peer (req.socket.remoteAddress), never + // request.ip / x-forwarded-for, which follow the spoofable header once the host app sets app.proxy. + private isTrustedInternalCaller(context: Context): boolean { + const socketIp = context.req?.socket?.remoteAddress ?? ''; + + return LOOPBACK_IPS.includes(socketIp) && !context.request.headers['x-forwarded-for']; + } } diff --git a/packages/agent/test/routes/security/ip-whitelist.test.ts b/packages/agent/test/routes/security/ip-whitelist.test.ts index cc7c236a1a..096a1d8241 100644 --- a/packages/agent/test/routes/security/ip-whitelist.test.ts +++ b/packages/agent/test/routes/security/ip-whitelist.test.ts @@ -154,5 +154,91 @@ describe('IpWhitelist', () => { }); }); }); + + describe('when the caller is a trusted internal caller (feature enabled, restrictive rules)', () => { + const restrictiveRules = { + isFeatureEnabled: true, + ipRules: [{ type: 0, ip: '10.20.15.10' }], + }; + + // socketIp = the true socket peer (req.socket.remoteAddress); xff is the proxy hop marker. + const forgeContext = (opts: { socketIp: string; xff?: string | null }): Context => + ({ + req: { socket: { remoteAddress: opts.socketIp } }, + request: { + ip: opts.socketIp, + headers: { 'x-forwarded-for': opts.xff ?? null }, + }, + throw: jest.fn(), + } as unknown as Context); + + test.each(['127.0.0.1', '::1', '::ffff:127.0.0.1'])( + 'exempts a direct loopback caller with no proxy hop from socket %s', + async socketIp => { + const service = await setupIpWhitelistService(restrictiveRules); + const context = forgeContext({ socketIp }); + const next = jest.fn() as Next; + + await service.checkIp(context, next); + + expect(next).toHaveBeenCalled(); + expect(context.throw).not.toHaveBeenCalled(); + }, + ); + + test('does NOT exempt a non-loopback socket', async () => { + const service = await setupIpWhitelistService(restrictiveRules); + const context = forgeContext({ socketIp: '203.0.113.5' }); + const next = jest.fn() as Next; + + await service.checkIp(context, next); + + expect(next).not.toHaveBeenCalled(); + expect(context.throw).toHaveBeenCalledWith(HttpCode.Forbidden, expect.any(String)); + }); + + // A proxied user's socket peer is the same-host proxy (loopback), but the proxy sets + // x-forwarded-for → the exemption must NOT fire, so the real IP is checked normally. + test('does NOT exempt a loopback socket that carries x-forwarded-for (proxy hop)', async () => { + const service = await setupIpWhitelistService(restrictiveRules); + const context = forgeContext({ socketIp: '127.0.0.1', xff: '203.0.113.5' }); + const next = jest.fn() as Next; + + await service.checkIp(context, next); + + expect(next).not.toHaveBeenCalled(); + expect(context.throw).toHaveBeenCalledWith( + HttpCode.Forbidden, + `IP address rejected (203.0.113.5)`, + ); + }); + + // Anti-spoof: an external socket carrying x-forwarded-for: 127.0.0.1 must NOT be exempted + // (exemption reads the socket, not the header) and the header IP is what gets rejected. + test('ignores a spoofed x-forwarded-for loopback (socket is external)', async () => { + const service = await setupIpWhitelistService(restrictiveRules); + const context = forgeContext({ socketIp: '203.0.113.5', xff: '127.0.0.1' }); + const next = jest.fn() as Next; + + await service.checkIp(context, next); + + expect(next).not.toHaveBeenCalled(); + expect(context.throw).toHaveBeenCalledWith( + HttpCode.Forbidden, + `IP address rejected (127.0.0.1)`, + ); + }); + + test('a proxied user from a whitelisted IP still passes the normal check', async () => { + const service = await setupIpWhitelistService(restrictiveRules); + const context = forgeContext({ socketIp: '127.0.0.1', xff: '10.20.15.10' }); + const next = jest.fn() as Next; + + await service.checkIp(context, next); + + expect(next).toHaveBeenCalled(); + expect(context.throw).not.toHaveBeenCalled(); + }); + }); }); });