Skip to content
Merged
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: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ NODE_ENV=production
PORT=3000
HOST=0.0.0.0
LINKFORTY_PORT=3000
# When behind a reverse proxy, set so client IP is read from X-Forwarded-For (e.g. 1 or number of proxy hops)
# TRUST_PROXY=1

# -----------------------------------------------------------------------------
# CORS Configuration
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ cp .env.example .env
# Edit .env with your local database credentials
```

For self-hosted deployments behind a reverse proxy, set `TRUST_PROXY=1` (or the number of proxy hops) so client IP is taken from `X-Forwarded-For`. Client-provided `ipAddress` in the SDK install body is not used as the trusted IP (debug metadata only).

4. **Start PostgreSQL and Redis**

```bash
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,9 +312,14 @@ interface ServerOptions {
origin: string | string[]; // CORS allowed origins (default: '*')
};
logger?: boolean; // Enable Fastify logger (default: true)
trustProxy?: boolean | number; // Trust X-Forwarded-For when behind a proxy (default: false)
}
```

### Running behind a reverse proxy

When Core runs behind a reverse proxy, CDN, or load balancer, set `trustProxy` so the server uses the real client IP from `X-Forwarded-For` for redirect targeting, geo, attribution, and fingerprinting. Pass it when creating the server (e.g. `trustProxy: true` or a number of proxy hops) or set the `TRUST_PROXY` environment variable (e.g. `TRUST_PROXY=1`). Client-provided `ipAddress` in the SDK install request body is **not** used as the trusted IP; it is optional debug metadata only and must not be relied on for attribution.

### Environment Variables

```bash
Expand All @@ -323,6 +328,8 @@ REDIS_URL=redis://localhost:6379
PORT=3000
NODE_ENV=production
CORS_ORIGIN=*
# When behind a reverse proxy: TRUST_PROXY=1 (or number of hops) so client IP is read from X-Forwarded-For
# TRUST_PROXY=1

# Mobile SDK (optional — for iOS Universal Links and Android App Links)
IOS_TEAM_ID=ABC123XYZ
Expand Down
10 changes: 10 additions & 0 deletions examples/basic-server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { createServer } from '@linkforty/core';

function getTrustProxy(): boolean | number | undefined {
const v = process.env.TRUST_PROXY;
if (v === undefined || v === '') return undefined;
if (v === '1' || v.toLowerCase() === 'true') return true;
const n = Number(v);
if (!Number.isNaN(n) && n >= 0) return n;
return undefined;
}

async function start() {
const server = await createServer({
database: {
Expand All @@ -11,6 +20,7 @@ async function start() {
cors: {
origin: process.env.CORS_ORIGIN || '*',
},
trustProxy: getTrustProxy(),
});

await server.listen({
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,14 @@ export interface ServerOptions {
origin: string | string[];
};
logger?: boolean;
/** When true or a number (proxy hop count), Fastify trusts X-Forwarded-For so request.ip is the real client IP. Set when behind a reverse proxy. */
trustProxy?: boolean | number;
}

export async function createServer(options: ServerOptions = {}) {
const fastify = Fastify({
logger: options.logger !== undefined ? options.logger : true,
trustProxy: options.trustProxy,
});

// CORS
Expand Down Expand Up @@ -57,6 +60,7 @@ export async function createServer(options: ServerOptions = {}) {

// Re-export utilities and types
export * from './lib/utils.js';
export * from './lib/client-ip.js';
export * from './lib/database.js';
export * from './lib/fingerprint.js';
export * from './lib/webhook.js';
Expand Down
88 changes: 88 additions & 0 deletions src/lib/client-ip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, it, expect, vi } from 'vitest';
import type { FastifyRequest } from 'fastify';
import { getClientIp } from './client-ip';

declare global {
var __capturedInstallFingerprint: { ipAddress: string } | null;
}

describe('getClientIp', () => {
it('returns request.ip when set', () => {
const request = { ip: '192.168.1.1' } as unknown as FastifyRequest;
expect(getClientIp(request)).toBe('192.168.1.1');
});

it('returns socket.remoteAddress when request.ip is undefined', () => {
const request = {
ip: undefined,
socket: { remoteAddress: '10.0.0.2' },
} as unknown as FastifyRequest;
expect(getClientIp(request)).toBe('10.0.0.2');
});

it('unwraps IPv6-mapped IPv4', () => {
const request = { ip: '::ffff:192.168.1.1' } as unknown as FastifyRequest;
expect(getClientIp(request)).toBe('192.168.1.1');
});

it('returns "unknown" when neither ip nor socket.remoteAddress is available', () => {
const request = { ip: undefined, socket: {} } as unknown as FastifyRequest;
expect(getClientIp(request)).toBe('unknown');
});

it('returns "unknown" when request has no socket', () => {
const request = { ip: undefined } as unknown as FastifyRequest;
expect(getClientIp(request)).toBe('unknown');
});
});

describe('getClientIp with Fastify trustProxy (proxied request)', () => {
it('uses X-Forwarded-For when trustProxy is true', async () => {
const Fastify = (await import('fastify')).default;
const { getClientIp: getIp } = await import('./client-ip.js');
const app = Fastify({ trustProxy: true });
app.get('/ip', async (request, reply) => {
return reply.send({ ip: getIp(request) });
});
const res = await app.inject({
method: 'GET',
url: '/ip',
headers: { 'x-forwarded-for': '203.0.113.50' },
});
expect(res.statusCode).toBe(200);
const body = res.json() as { ip: string };
expect(body.ip).toBe('203.0.113.50');
});
});

vi.mock('./fingerprint.js', async (importOriginal) => {
const mod = (await importOriginal()) as Record<string, unknown>;
return {
...mod,
recordInstallEvent: vi.fn().mockImplementation(async (data: { ipAddress: string }) => {
globalThis.__capturedInstallFingerprint = data;
return { installId: 'test-id', match: null, deepLinkData: null };
}),
};
});

describe('SDK install does not trust client-provided ipAddress', () => {
it('uses connection/proxy IP for attribution, not body ipAddress', async () => {
globalThis.__capturedInstallFingerprint = null;
const Fastify = (await import('fastify')).default;
const { sdkRoutes } = await import('../routes/sdk.js');
const app = Fastify({ trustProxy: true });
await app.register(sdkRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/sdk/v1/install',
payload: { ipAddress: '1.2.3.4', userAgent: 'Mozilla/5.0 Test' },
headers: { 'x-forwarded-for': '203.0.113.50', 'content-type': 'application/json' },
});
expect(res.statusCode).toBe(200);
expect(globalThis.__capturedInstallFingerprint).not.toBeNull();
expect(globalThis.__capturedInstallFingerprint!.ipAddress).toBe('203.0.113.50');
const body = res.json() as { clientReportedIp?: string };
expect(body.clientReportedIp).toBe('1.2.3.4');
});
});
18 changes: 18 additions & 0 deletions src/lib/client-ip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { FastifyRequest } from 'fastify';

/**
* Returns the trusted client IP for the request.
* Use this everywhere client IP is needed (targeting, attribution, fingerprinting).
* When the server is behind a reverse proxy, set Fastify's trustProxy option
* so that request.ip is populated from X-Forwarded-For; this helper then
* returns that trusted value.
*/
export function getClientIp(request: FastifyRequest): string {
const ip = request.ip ?? (request as any).socket?.remoteAddress;
if (ip && typeof ip === 'string') {
// IPv6-mapped IPv4: ::ffff:192.168.1.1 -> 192.168.1.1
if (ip.startsWith('::ffff:')) return ip.slice(7);
return ip;
}
return 'unknown';
}
5 changes: 3 additions & 2 deletions src/routes/redirect.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FastifyInstance } from 'fastify';
import { db } from '../lib/database.js';
import { getClientIp } from '../lib/client-ip.js';
import { parseUserAgent, getLocationFromIP, buildRedirectUrl, detectDevice } from '../lib/utils.js';
import { storeFingerprintForClick, type FingerprintData } from '../lib/fingerprint.js';
import { emitClickEvent } from '../lib/event-emitter.js';
Expand Down Expand Up @@ -128,7 +129,7 @@ export async function redirectRoutes(fastify: FastifyInstance) {
// Check targeting rules BEFORE redirecting
if (link.targeting_rules) {
const userAgent = request.headers['user-agent'] || '';
const ip = request.ip;
const ip = getClientIp(request);
const acceptLanguage = request.headers['accept-language'] || '';

// Get user's actual data for targeting checks
Expand Down Expand Up @@ -174,7 +175,7 @@ export async function redirectRoutes(fastify: FastifyInstance) {
setImmediate(async () => {
try {
const userAgent = request.headers['user-agent'] || '';
const ip = request.ip;
const ip = getClientIp(request);
const referrer = request.headers.referer || null;
const acceptLanguage = request.headers['accept-language'] || '';

Expand Down
10 changes: 6 additions & 4 deletions src/routes/sdk.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { db } from '../lib/database.js';
import { getClientIp } from '../lib/client-ip.js';
import {
recordInstallEvent,
generateFingerprintHash,
Expand All @@ -21,7 +22,7 @@ export async function sdkRoutes(fastify: FastifyInstance) {
* Report app installation and retrieve deferred deep link data
*
* Request body:
* - ipAddress: Client IP (auto-detected if not provided)
* - ipAddress: Optional, for debug only (untrusted; server uses connection/proxy headers for trusted IP)
* - userAgent: Client user agent
* - timezone: Device timezone (e.g., "America/New_York")
* - language: Device language (e.g., "en-US")
Expand Down Expand Up @@ -55,8 +56,8 @@ export async function sdkRoutes(fastify: FastifyInstance) {

const body = schema.parse(request.body);

// Use client-provided IP or fallback to request IP
const ipAddress = body.ipAddress || request.ip;
// Trusted IP from connection/proxy headers only; never use body.ipAddress for attribution/fingerprint
const ipAddress = getClientIp(request);

const fingerprintData: FingerprintData = {
ipAddress,
Expand All @@ -82,6 +83,7 @@ export async function sdkRoutes(fastify: FastifyInstance) {
confidenceScore: result.match?.confidenceScore || 0,
matchedFactors: result.match?.matchedFactors || [],
deepLinkData: result.deepLinkData,
...(body.ipAddress != null && { clientReportedIp: body.ipAddress }),
});
} catch (error: any) {
fastify.log.error(`Error recording install event: ${error}`);
Expand Down Expand Up @@ -408,7 +410,7 @@ export async function sdkRoutes(fastify: FastifyInstance) {
setImmediate(async () => {
try {
const userAgent = request.headers['user-agent'] || '';
const ip = request.ip;
const ip = getClientIp(request);
const referrer = request.headers.referer || null;
const acceptLanguage = request.headers['accept-language'] || '';

Expand Down
Loading