fix: use correct client ip header

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-13 23:58:23 +01:00
parent c1801adaa2
commit 8fbe944df0
20 changed files with 255 additions and 61 deletions

View File

@@ -41,7 +41,6 @@
"groupmq": "1.0.0-next.19", "groupmq": "1.0.0-next.19",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"ramda": "^0.29.1", "ramda": "^0.29.1",
"request-ip": "^3.3.0",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"sqlstring": "^2.3.3", "sqlstring": "^2.3.3",
@@ -58,7 +57,6 @@
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.9", "@types/jsonwebtoken": "^9.0.9",
"@types/ramda": "^0.30.2", "@types/ramda": "^0.30.2",
"@types/request-ip": "^0.0.41",
"@types/source-map-support": "^0.5.10", "@types/source-map-support": "^0.5.10",
"@types/sqlstring": "^2.3.2", "@types/sqlstring": "^2.3.2",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",

View File

@@ -1,4 +1,3 @@
import { getClientIp } from '@/utils/get-client-ip';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server'; import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
@@ -21,7 +20,7 @@ export async function postEvent(
request.timestamp, request.timestamp,
request.body, request.body,
); );
const ip = getClientIp(request)!; const ip = request.clientIp;
const ua = request.headers['user-agent']!; const ua = request.headers['user-agent']!;
const projectId = request.client?.projectId; const projectId = request.client?.projectId;
const headers = getStringHeaders(request.headers); const headers = getStringHeaders(request.headers);

View File

@@ -4,9 +4,12 @@ import { parseUrlMeta } from '@/utils/parseUrlMeta';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import sharp from 'sharp'; import sharp from 'sharp';
import { getClientIp } from '@/utils/get-client-ip'; import {
DEFAULT_HEADER_ORDER,
getClientIpFromHeaders,
} from '@openpanel/common/server/get-client-ip';
import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db'; import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db';
import { getGeoLocation } from '@openpanel/geo'; import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
import { getCache, getRedisCache } from '@openpanel/redis'; import { getCache, getRedisCache } from '@openpanel/redis';
interface GetFaviconParams { interface GetFaviconParams {
@@ -394,12 +397,35 @@ export async function stats(request: FastifyRequest, reply: FastifyReply) {
} }
export async function getGeo(request: FastifyRequest, reply: FastifyReply) { export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
const ip = getClientIp(request); const ip = getClientIpFromHeaders(request.headers);
const others = await Promise.all(
DEFAULT_HEADER_ORDER.map(async (header) => {
const ip = getClientIpFromHeaders(request.headers, header);
return {
header,
ip,
geo: await getGeoLocation(ip),
};
}),
);
if (!ip) { if (!ip) {
return reply.status(400).send('Bad Request'); return reply.status(400).send('Bad Request');
} }
const geo = await getGeoLocation(ip); const geo = await getGeoLocation(ip);
return reply.status(200).send(geo); return reply.status(200).send({
selected: {
geo,
ip,
},
...others.reduce(
(acc, other) => {
acc[other.header] = other;
return acc;
},
{} as Record<string, { ip: string; geo: GeoLocation }>,
),
});
} }
export async function getOgImage( export async function getOgImage(

View File

@@ -1,4 +1,3 @@
import { getClientIp } from '@/utils/get-client-ip';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { assocPath, pathOr } from 'ramda'; import { assocPath, pathOr } from 'ramda';
@@ -22,7 +21,7 @@ export async function updateProfile(
if (!projectId) { if (!projectId) {
return reply.status(400).send('No projectId'); return reply.status(400).send('No projectId');
} }
const ip = getClientIp(request)!; const ip = request.clientIp;
const ua = request.headers['user-agent']!; const ua = request.headers['user-agent']!;
const uaInfo = parseUserAgent(ua, properties); const uaInfo = parseUserAgent(ua, properties);
const geo = await getGeoLocation(ip); const geo = await getGeoLocation(ip);

View File

@@ -1,4 +1,3 @@
import { getClientIp } from '@/utils/get-client-ip';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { path, assocPath, pathOr, pick } from 'ramda'; import { path, assocPath, pathOr, pick } from 'ramda';
@@ -91,7 +90,7 @@ export async function handler(
const timestamp = getTimestamp(request.timestamp, request.body.payload); const timestamp = getTimestamp(request.timestamp, request.body.payload);
const ip = const ip =
path<string>(['properties', '__ip'], request.body.payload) || path<string>(['properties', '__ip'], request.body.payload) ||
getClientIp(request)!; request.clientIp;
const ua = request.headers['user-agent']!; const ua = request.headers['user-agent']!;
const projectId = request.client?.projectId; const projectId = request.client?.projectId;

View File

@@ -1,13 +1,12 @@
import { getClientIp } from '@/utils/get-client-ip'; import { getClientIpFromHeaders } from '@openpanel/common/server/get-client-ip';
import type { import type { FastifyRequest } from 'fastify';
FastifyReply,
FastifyRequest,
HookHandlerDoneFunction,
} from 'fastify';
export async function ipHook(request: FastifyRequest) { export async function ipHook(request: FastifyRequest) {
const ip = getClientIp(request); const ip = getClientIpFromHeaders(request.headers);
if (ip) { if (ip) {
request.clientIp = ip; request.clientIp = ip;
} else {
request.clientIp = '';
} }
} }

View File

@@ -55,7 +55,7 @@ process.env.TZ = 'UTC';
declare module 'fastify' { declare module 'fastify' {
interface FastifyRequest { interface FastifyRequest {
client: IServiceClientWithProject | null; client: IServiceClientWithProject | null;
clientIp?: string; clientIp: string;
timestamp?: number; timestamp?: number;
session: SessionValidationResult; session: SessionValidationResult;
} }

View File

@@ -1,8 +0,0 @@
import type { FastifyRequest } from 'fastify';
import requestIp from 'request-ip';
const ignore = ['127.0.0.1', '::1'];
export function getClientIp(req: FastifyRequest) {
return requestIp.getClientIp(req);
}

View File

@@ -0,0 +1,67 @@
import { createHash } from 'node:crypto';
import { getClientIpFromHeaders } from '@openpanel/common/server/get-client-ip';
// adding .js next/script import fixes an issues
// with esm and nextjs (when using pages dir)
import { NextResponse } from 'next/server.js';
type CreateNextRouteHandlerOptions = {
apiUrl?: string;
};
function createNextRouteHandler(options: CreateNextRouteHandlerOptions) {
return async function POST(req: Request) {
const apiUrl = options.apiUrl ?? 'https://api.openpanel.dev';
const headers = new Headers(req.headers);
const clientIp = getClientIpFromHeaders(headers);
console.log('debug', {
clientIp,
userAgent: req.headers.get('user-agent'),
});
try {
const res = await fetch(`${apiUrl}/track`, {
method: 'POST',
headers,
body: JSON.stringify(await req.json()),
});
return NextResponse.json(await res.text(), { status: res.status });
} catch (e) {
return NextResponse.json(e);
}
};
}
function createScriptHandler() {
return async function GET(req: Request) {
if (!req.url.endsWith('op1.js')) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const scriptUrl = 'https://openpanel.dev/op1.js';
try {
const res = await fetch(scriptUrl, {
next: { revalidate: 86400 },
});
const text = await res.text();
const etag = `"${createHash('md5').update(text).digest('hex')}"`;
return new NextResponse(text, {
headers: {
'Content-Type': 'text/javascript',
'Cache-Control':
'public, max-age=86400, stale-while-revalidate=86400',
ETag: etag,
},
});
} catch (e) {
return NextResponse.json(
{
error: 'Failed to fetch script',
message: e instanceof Error ? e.message : String(e),
},
{ status: 500 },
);
}
};
}
export const POST = createNextRouteHandler({});
export const GET = createScriptHandler();

View File

@@ -61,12 +61,9 @@ export default async function Layout({ children }: { children: ReactNode }) {
<RootProvider> <RootProvider>
<TooltipProvider>{children}</TooltipProvider> <TooltipProvider>{children}</TooltipProvider>
</RootProvider> </RootProvider>
<Script
defer
src="http://localhost:3000/script.js"
data-website-id="44d65df1-e9cb-4c2c-917d-4bf1c7850948"
/>
<OpenPanelComponent <OpenPanelComponent
apiUrl="/api/op"
cdnUrl="/api/op/op1.js"
clientId="301c6dc1-424c-4bc3-9886-a8beab09b615" clientId="301c6dc1-424c-4bc3-9886-a8beab09b615"
trackAttributes trackAttributes
trackScreenViews trackScreenViews

View File

@@ -273,19 +273,21 @@ export function GET() {
### Proxy events ### Proxy events
With `createNextRouteHandler` you can proxy your events through your server, this will ensure all events are tracked since there is a lot of adblockers that block requests to third party domains. With `createNextRouteHandler` you can proxy your events through your server, this will ensure all events are tracked since there is a lot of adblockers that block requests to third party domains. You'll also need to either host our tracking script or you can use `createScriptHandler` function which proxies this as well.
```typescript title="/app/api/[...op]/route.ts" ```typescript title="/app/api/[...op]/route.ts"
import { createNextRouteHandler } from '@openpanel/nextjs/server'; import { createNextRouteHandler, createScriptHandler } from '@openpanel/nextjs/server';
export const POST = createNextRouteHandler(); export const POST = createNextRouteHandler();
export const GET = createScriptHandler()
``` ```
Remember to change the `apiUrl` in the `OpenPanelComponent` to your own server. Remember to change the `apiUrl` and `cdnUrl` in the `OpenPanelComponent` to your own server.
```tsx ```tsx
<OpenPanelComponent <OpenPanelComponent
apiUrl="/api/op" // [!code highlight] apiUrl="/api/op" // [!code highlight]
cdnUrl="/api/op/op1.js" // [!code highlight]
clientId="your-client-id" clientId="your-client-id"
trackScreenViews={true} trackScreenViews={true}
/> />

View File

@@ -13,6 +13,7 @@
"dependencies": { "dependencies": {
"@hyperdx/node-opentelemetry": "^0.8.1", "@hyperdx/node-opentelemetry": "^0.8.1",
"@number-flow/react": "0.3.5", "@number-flow/react": "0.3.5",
"@openpanel/common": "workspace:*",
"@openpanel/nextjs": "^1.0.5", "@openpanel/nextjs": "^1.0.5",
"@openpanel/payments": "workspace:^", "@openpanel/payments": "workspace:^",
"@openpanel/sdk-info": "workspace:^", "@openpanel/sdk-info": "workspace:^",

View File

@@ -9,3 +9,4 @@ export * from './src/url';
export * from './src/id'; export * from './src/id';
export * from './src/get-previous-metric'; export * from './src/get-previous-metric';
export * from './src/group-by-labels'; export * from './src/group-by-labels';
export * from './server/get-client-ip';

View File

@@ -5,7 +5,8 @@
"main": "index.ts", "main": "index.ts",
"exports": { "exports": {
".": "./index.ts", ".": "./index.ts",
"./server": "./server/index.ts" "./server": "./server/index.ts",
"./server/get-client-ip": "./server/get-client-ip.ts"
}, },
"scripts": { "scripts": {
"test": "vitest", "test": "vitest",

View File

@@ -0,0 +1,86 @@
/**
* Get client IP from headers
*
* Can be configured via IP_HEADER_ORDER env variable
* Example: IP_HEADER_ORDER="cf-connecting-ip,x-real-ip,x-forwarded-for"
*/
export const DEFAULT_HEADER_ORDER = [
'cf-connecting-ip',
'true-client-ip',
'x-real-ip',
'x-client-ip',
'fastly-client-ip',
'x-cluster-client-ip',
'x-appengine-user-ip',
'do-connecting-ip',
'x-nf-client-connection-ip',
'x-forwarded-for',
'x-forwarded',
'forwarded',
];
function getHeaderOrder(): string[] {
if (typeof process !== 'undefined' && process.env?.IP_HEADER_ORDER) {
return process.env.IP_HEADER_ORDER.split(',').map((h) => h.trim());
}
return DEFAULT_HEADER_ORDER;
}
function isValidIp(ip: string): boolean {
// Basic IP validation
const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/;
const ipv6 = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
return ipv4.test(ip) || ipv6.test(ip);
}
export function getClientIpFromHeaders(
headers: Record<string, string | string[] | undefined> | Headers,
overrideHeaderName?: string,
): string {
let headerOrder = getHeaderOrder();
if (overrideHeaderName) {
headerOrder = [overrideHeaderName];
}
for (const headerName of headerOrder) {
let value: string | null = null;
// Get header value
if (headers instanceof Headers) {
value = headers.get(headerName);
} else {
const headerValue = headers[headerName];
if (Array.isArray(headerValue)) {
value = headerValue[0] || null;
} else {
value = headerValue || null;
}
}
if (!value) continue;
// Handle x-forwarded-for (comma separated)
if (headerName === 'x-forwarded-for') {
const firstIp = value.split(',')[0]?.trim();
if (firstIp && isValidIp(firstIp)) {
return firstIp;
}
}
// Handle forwarded header (RFC 7239)
else if (headerName === 'forwarded') {
const match = value.match(/for=(?:"?\[?([^\]"]+)\]?"?)/i);
const ip = match?.[1];
if (ip && isValidIp(ip)) {
return ip;
}
}
// Regular headers
else if (isValidIp(value)) {
return value;
}
}
return '';
}

View File

@@ -1,5 +1,5 @@
import { getClientIpFromHeaders } from '@openpanel/common/server/get-client-ip';
import type { NextFunction, Request, Response } from 'express'; import type { NextFunction, Request, Response } from 'express';
import { getClientIp } from 'request-ip';
import type { OpenPanelOptions } from '@openpanel/sdk'; import type { OpenPanelOptions } from '@openpanel/sdk';
import { OpenPanel } from '@openpanel/sdk'; import { OpenPanel } from '@openpanel/sdk';
@@ -22,7 +22,7 @@ export type OpenpanelOptions = OpenPanelOptions & {
export default function createMiddleware(options: OpenpanelOptions) { export default function createMiddleware(options: OpenpanelOptions) {
return function middleware(req: Request, res: Response, next: NextFunction) { return function middleware(req: Request, res: Response, next: NextFunction) {
const sdk = new OpenPanel(options); const sdk = new OpenPanel(options);
const ip = getClientIp(req); const ip = getClientIpFromHeaders(req.headers);
if (ip) { if (ip) {
sdk.api.addHeader('x-client-ip', ip); sdk.api.addHeader('x-client-ip', ip);
} }
@@ -30,20 +30,15 @@ export default function createMiddleware(options: OpenpanelOptions) {
sdk.api.addHeader('x-user-agent', req.headers['user-agent'] as string); sdk.api.addHeader('x-user-agent', req.headers['user-agent'] as string);
} }
if (options.getProfileId) {
const profileId = options.getProfileId(req);
if (profileId) {
sdk.identify({
profileId,
});
}
}
if (options.trackRequest?.(req.url)) { if (options.trackRequest?.(req.url)) {
const profileId = options.getProfileId
? options.getProfileId(req)
: undefined;
sdk.track('request', { sdk.track('request', {
url: req.url, url: req.url,
method: req.method, method: req.method,
query: req.query, query: req.query,
profileId,
}); });
} }

View File

@@ -8,7 +8,7 @@
}, },
"dependencies": { "dependencies": {
"@openpanel/sdk": "workspace:1.0.0-local", "@openpanel/sdk": "workspace:1.0.0-local",
"request-ip": "^3.3.0" "@openpanel/common": "workspace:*"
}, },
"peerDependencies": { "peerDependencies": {
"express": "^4.17.0 || ^5.0.0" "express": "^4.17.0 || ^5.0.0"

View File

@@ -8,4 +8,6 @@ export default defineConfig({
sourcemap: false, sourcemap: false,
clean: true, clean: true,
minify: true, minify: true,
// Bundle @openpanel/common into the output instead of treating it as external
noExternal: ['@openpanel/common'],
}); });

View File

@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
// adding .js next/script import fixes an issues // adding .js next/script import fixes an issues
// with esm and nextjs (when using pages dir) // with esm and nextjs (when using pages dir)
import { NextResponse } from 'next/server.js'; import { NextResponse } from 'next/server.js';
@@ -16,9 +17,43 @@ export function createNextRouteHandler(options: CreateNextRouteHandlerOptions) {
headers, headers,
body: JSON.stringify(await req.json()), body: JSON.stringify(await req.json()),
}); });
return NextResponse.json(await res.text()); return NextResponse.json(await res.text(), { status: res.status });
} catch (e) { } catch (e) {
return NextResponse.json(e); return NextResponse.json(e);
} }
}; };
} }
export function createScriptHandler() {
return async function GET(req: Request) {
if (!req.url.endsWith('op1.js')) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const scriptUrl = 'https://openpanel.dev/op1.js';
try {
const res = await fetch(scriptUrl, {
// @ts-expect-error
next: { revalidate: 86400 },
});
const text = await res.text();
const etag = `"${createHash('md5').update(text).digest('hex')}"`;
return new NextResponse(text, {
headers: {
'Content-Type': 'text/javascript',
'Cache-Control':
'public, max-age=86400, stale-while-revalidate=86400',
ETag: etag,
},
});
} catch (e) {
return NextResponse.json(
{
error: 'Failed to fetch script',
message: e instanceof Error ? e.message : String(e),
},
{ status: 500 },
);
}
};
}

17
pnpm-lock.yaml generated
View File

@@ -198,9 +198,6 @@ importers:
ramda: ramda:
specifier: ^0.29.1 specifier: ^0.29.1
version: 0.29.1 version: 0.29.1
request-ip:
specifier: ^3.3.0
version: 3.3.0
sharp: sharp:
specifier: ^0.33.5 specifier: ^0.33.5
version: 0.33.5 version: 0.33.5
@@ -277,6 +274,9 @@ importers:
'@number-flow/react': '@number-flow/react':
specifier: 0.3.5 specifier: 0.3.5
version: 0.3.5(react-dom@19.1.1(react@19.1.1))(react@19.1.1) version: 0.3.5(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@openpanel/common':
specifier: workspace:*
version: link:../../packages/common
'@openpanel/nextjs': '@openpanel/nextjs':
specifier: ^1.0.5 specifier: ^1.0.5
version: 1.0.5(next@15.0.3(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1) version: 1.0.5(next@15.0.3(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -1410,15 +1410,15 @@ importers:
packages/sdks/express: packages/sdks/express:
dependencies: dependencies:
'@openpanel/common':
specifier: workspace:*
version: link:../../common
'@openpanel/sdk': '@openpanel/sdk':
specifier: workspace:1.0.0-local specifier: workspace:1.0.0-local
version: link:../sdk version: link:../sdk
express: express:
specifier: ^4.17.0 || ^5.0.0 specifier: ^4.17.0 || ^5.0.0
version: 4.19.2 version: 4.19.2
request-ip:
specifier: ^3.3.0
version: 3.3.0
devDependencies: devDependencies:
'@openpanel/tsconfig': '@openpanel/tsconfig':
specifier: workspace:* specifier: workspace:*
@@ -14604,9 +14604,6 @@ packages:
remove-trailing-slash@0.1.1: remove-trailing-slash@0.1.1:
resolution: {integrity: sha512-o4S4Qh6L2jpnCy83ysZDau+VORNvnFw07CKSAymkd6ICNVEPisMyzlc00KlvvicsxKck94SEwhDnMNdICzO+tA==} resolution: {integrity: sha512-o4S4Qh6L2jpnCy83ysZDau+VORNvnFw07CKSAymkd6ICNVEPisMyzlc00KlvvicsxKck94SEwhDnMNdICzO+tA==}
request-ip@3.3.0:
resolution: {integrity: sha512-cA6Xh6e0fDBBBwH77SLJaJPBmD3nWVAcF9/XAcsrIHdjhFzFiB5aNQFytdjCGPezU3ROwrR11IddKAM08vohxA==}
require-directory@2.1.1: require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -32008,8 +32005,6 @@ snapshots:
remove-trailing-slash@0.1.1: {} remove-trailing-slash@0.1.1: {}
request-ip@3.3.0: {}
require-directory@2.1.1: {} require-directory@2.1.1: {}
require-from-string@2.0.2: {} require-from-string@2.0.2: {}