Skip to content
Closed
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
21 changes: 17 additions & 4 deletions src/context/services/context-get-default.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } => {
Expand All @@ -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 } => {
Expand Down
4 changes: 2 additions & 2 deletions src/context/services/context-prepare.service.ts
Original file line number Diff line number Diff line change
@@ -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
) => {
Expand Down
13 changes: 13 additions & 0 deletions src/context/services/request.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
16 changes: 14 additions & 2 deletions src/ips/services/ips-extract.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions test/context/services/context-get-default.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
});
});
47 changes: 47 additions & 0 deletions test/ips/services/ips-extract.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
});
Loading