From 0b025ac970f30e7e7261e1db89e5d2068d28033c Mon Sep 17 00:00:00 2001 From: Bartosz Date: Fri, 5 Jun 2026 21:01:40 +0200 Subject: [PATCH] Resolve client IP from the socket peer address as a fallback The IP extractor now accepts an optional remote address and surfaces it as `remote-addr` when no `x-forwarded-for` or `remote-addr` header is present, so extraction precedence, trusted-proxy filtering, and custom `ipHeaders` are unchanged. The default context derives this value from the request connection (`socket.remoteAddress`, `connection.remoteAddress`, or `info.remoteAddress`), covering the raw Node `IncomingMessage`, Express, Fastify, Koa, Next, and Hapi. A shared `CastleRequest` type describes the request shape consumed by the context services. --- README.md | 2 +- .../services/context-get-default.service.ts | 21 +++++++-- .../services/context-prepare.service.ts | 4 +- src/context/services/request.ts | 13 +++++ src/ips/services/ips-extract.service.ts | 16 ++++++- .../context-get-default.service.test.ts | 18 +++++++ test/ips/services/ips-extract.service.test.ts | 47 +++++++++++++++++++ 7 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 src/context/services/request.ts diff --git a/README.md b/README.md index 3976ff4..5bb4496 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,7 @@ Header names are case-insensitive and accept both `_` and `-` as separators. A l ### Client IP detection -Castle needs the original client IP, not the IP of your proxy or load balancer. The SDK reads `X-Forwarded-For` and `Remote-Addr` by default; pick **one** of the strategies below depending on your infrastructure: +Castle needs the original client IP, not the IP of your proxy or load balancer. The SDK reads `X-Forwarded-For` and `Remote-Addr` by default, and falls back to the connection's peer address (`request.socket.remoteAddress`, with `request.connection.remoteAddress` and `request.info.remoteAddress` also supported) when neither header is present. Pick **one** of the strategies below depending on your infrastructure: ```js const castle = new Castle({ diff --git a/src/context/services/context-get-default.service.ts b/src/context/services/context-get-default.service.ts index 8e557fd..adfe997 100644 --- a/src/context/services/context-get-default.service.ts +++ b/src/context/services/context-get-default.service.ts @@ -4,10 +4,19 @@ import { ClientIdExtractService } from '../../client-id/client-id.module'; import { HeadersExtractService } from '../../headers/headers.module'; import { IPsExtractService } from '../../ips/ips.module'; import { version } from '../../../package.json'; -import type { IncomingHttpHeaders } from 'http2'; +import type { CastleRequest } from './request'; + +// Framework-agnostic lookup for the connection's peer address. Covers the raw +// Node `IncomingMessage` (and Express/Fastify/Koa/Next, which build on it) via +// `socket`/`connection`, and Hapi via `info`. +const remoteAddressFrom = (request: CastleRequest): string | undefined => + request?.socket?.remoteAddress ?? + request?.connection?.remoteAddress ?? + request?.info?.remoteAddress ?? + undefined; const requestContextData = ( - request: { headers: IncomingHttpHeaders }, + request: CastleRequest, cookies: string | undefined, configuration: Configuration ): { [key: string]: any } => { @@ -22,13 +31,17 @@ const requestContextData = ( ClientIdExtractService.call(request.headers, cookiesForClientId) || false, active: true, headers: HeadersExtractService.call(request.headers, configuration), - ip: IPsExtractService.call(request.headers, configuration), + ip: IPsExtractService.call( + request.headers, + configuration, + remoteAddressFrom(request) + ), }; }; export const ContextGetDefaultService = { call: ( - request: { headers: IncomingHttpHeaders }, + request: CastleRequest, cookies: string | undefined, configuration: Configuration ): { [key: string]: any } => { diff --git a/src/context/services/context-prepare.service.ts b/src/context/services/context-prepare.service.ts index fe67d76..6c073d9 100644 --- a/src/context/services/context-prepare.service.ts +++ b/src/context/services/context-prepare.service.ts @@ -1,11 +1,11 @@ import { deepMerge } from '../../utils/object'; import { Configuration } from '../../configuration'; import { ContextGetDefaultService } from './context-get-default.service'; -import type { IncomingHttpHeaders } from 'http2'; +import type { CastleRequest } from './request'; export const ContextPrepareService = { call: ( - request: { headers: IncomingHttpHeaders }, + request: CastleRequest, options: undefined | { [key: string]: any }, configuration: Configuration ) => { diff --git a/src/context/services/request.ts b/src/context/services/request.ts new file mode 100644 index 0000000..c7a9235 --- /dev/null +++ b/src/context/services/request.ts @@ -0,0 +1,13 @@ +import type { IncomingHttpHeaders } from 'http2'; + +// Minimal request shape the context services rely on. `headers` is always +// required; the optional fields expose the connection's peer address across +// frameworks (raw Node `IncomingMessage`, Express, Fastify, Koa, Next via +// `socket`/`connection`; Hapi via `info`) so the IP can be resolved even when +// no forwarding header is present. +export interface CastleRequest { + headers: IncomingHttpHeaders; + socket?: { remoteAddress?: string }; + connection?: { remoteAddress?: string }; + info?: { remoteAddress?: string }; +} diff --git a/src/ips/services/ips-extract.service.ts b/src/ips/services/ips-extract.service.ts index 812402f..3810263 100644 --- a/src/ips/services/ips-extract.service.ts +++ b/src/ips/services/ips-extract.service.ts @@ -47,13 +47,25 @@ export const IPsExtractService = { trustedProxies = [], trustProxyChain = false, trustedProxyDepth = 0, - }: Configuration + }: Configuration, + remoteAddress?: string ) => { const ipHeadersList = ipHeaders.length ? ipHeaders : DEFAULT; const proxiesList = trustedProxies.concat(TRUSTED_PROXIES); + + // Node keeps the connection's peer address on the socket rather than in a + // header, unlike the WSGI/Rack `REMOTE_ADDR` the Python/Ruby SDKs read. When + // the caller supplies it and no `remote-addr` header is present, surface it + // as `remote-addr` so it takes part in extraction with the usual precedence + // (after `x-forwarded-for`). An existing `remote-addr` header is preserved. + const effectiveHeaders = + remoteAddress && !headers?.['remote-addr'] + ? { ...headers, 'remote-addr': remoteAddress } + : headers; + let allIPs: any[] = []; for (const ipHeader of ipHeadersList) { - const IPs = IPsFrom(ipHeader, headers, trustedProxyDepth); + const IPs = IPsFrom(ipHeader, effectiveHeaders, trustedProxyDepth); const IPValue = removeProxies(IPs, trustProxyChain, proxiesList); if (IPValue) { return IPValue; diff --git a/test/context/services/context-get-default.service.test.ts b/test/context/services/context-get-default.service.test.ts index eb0e8e7..fad1d2e 100644 --- a/test/context/services/context-get-default.service.test.ts +++ b/test/context/services/context-get-default.service.test.ts @@ -42,5 +42,23 @@ describe('ContextGetDefaultService', () => { ); expect(received).toMatchObject(expected); }); + + describe('when no forwarding header is present', () => { + const socketRequest = { + headers: { + 'x-castle-client-id': 'client_id', + }, + socket: { remoteAddress: '8.8.8.8' }, + } as unknown as ExpressRequest; + + it('derives the ip from the socket peer address', () => { + const received = ContextGetDefaultService.call( + socketRequest, + undefined, + config + ); + expect(received).toMatchObject({ ip: '8.8.8.8' }); + }); + }); }); }); diff --git a/test/ips/services/ips-extract.service.test.ts b/test/ips/services/ips-extract.service.test.ts index 9ff9e86..1efc2ab 100644 --- a/test/ips/services/ips-extract.service.test.ts +++ b/test/ips/services/ips-extract.service.test.ts @@ -136,5 +136,52 @@ describe('IPsExtractService', () => { expect(IPsExtractService.call(headers, config)).toEqual('6.6.6.6'); }); }); + + describe('when a remoteAddress fallback is provided', () => { + const config = new Configuration({ + apiSecret: 'test', + ipHeaders: [], + trustedProxies: [], + }); + + it('uses it as remote-addr when no ip headers are present', () => { + expect(IPsExtractService.call({}, config, '8.8.8.8')).toEqual( + '8.8.8.8' + ); + }); + + it('falls back to a loopback remoteAddress when nothing else is available', () => { + expect(IPsExtractService.call({}, config, '::1')).toEqual('::1'); + }); + + it('prefers x-forwarded-for over the remoteAddress fallback', () => { + expect( + IPsExtractService.call( + { 'x-forwarded-for': '1.2.3.5' }, + config, + '8.8.8.8' + ) + ).toEqual('1.2.3.5'); + }); + + it('preserves an existing remote-addr header over the fallback', () => { + expect( + IPsExtractService.call( + { 'remote-addr': '4.4.4.4' }, + config, + '8.8.8.8' + ) + ).toEqual('4.4.4.4'); + }); + + it('ignores the fallback when custom ipHeaders exclude remote-addr', () => { + const cfConfig = new Configuration({ + apiSecret: 'test', + ipHeaders: ['cf-connecting-ip'], + trustedProxies: [], + }); + expect(IPsExtractService.call({}, cfConfig, '8.8.8.8')).toBeUndefined(); + }); + }); }); });