diff --git a/.env.example b/.env.example index b509fb5..132f6ef 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec50c52..eea379f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index e6ab9fe..74aad59 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/examples/basic-server.ts b/examples/basic-server.ts index e93c064..cb435c9 100644 --- a/examples/basic-server.ts +++ b/examples/basic-server.ts @@ -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: { @@ -11,6 +20,7 @@ async function start() { cors: { origin: process.env.CORS_ORIGIN || '*', }, + trustProxy: getTrustProxy(), }); await server.listen({ diff --git a/src/index.ts b/src/index.ts index c4d6ed0..3830baa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 @@ -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'; diff --git a/src/lib/client-ip.test.ts b/src/lib/client-ip.test.ts new file mode 100644 index 0000000..56f4dcf --- /dev/null +++ b/src/lib/client-ip.test.ts @@ -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; + 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'); + }); +}); diff --git a/src/lib/client-ip.ts b/src/lib/client-ip.ts new file mode 100644 index 0000000..1b7ce6e --- /dev/null +++ b/src/lib/client-ip.ts @@ -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'; +} diff --git a/src/routes/redirect.ts b/src/routes/redirect.ts index 018d7b0..ed13c67 100644 --- a/src/routes/redirect.ts +++ b/src/routes/redirect.ts @@ -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'; @@ -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 @@ -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'] || ''; diff --git a/src/routes/sdk.ts b/src/routes/sdk.ts index 1dbefa5..3eb0947 100644 --- a/src/routes/sdk.ts +++ b/src/routes/sdk.ts @@ -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, @@ -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") @@ -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, @@ -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}`); @@ -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'] || '';