Skip to content

Commit 58684e1

Browse files
authored
fix(attribution): ignore shared/NAT IPs in fingerprint matching + support CDN client-IP header (#26)
Two root-cause fixes for cross-tenant install mis-attribution: 1. Shared-IP filter: calculateConfidenceScore no longer awards the IP score when either IP is in a shared/non-routable range (CGNAT 100.64.0.0/10, RFC1918, loopback, link-local, IPv6 ULA/link-local). Those IPs don't identify a single device, so unrelated users behind the same NAT could collide on IP (40pts) + UA (30pts) = the 70 threshold and cross orgs. Without the IP score such installs top out at 60 and no longer match. New exported isAttributableIp() with tests. 2. getClientIp honors a configurable authoritative client-IP header (TRUSTED_CLIENT_IP_HEADER, e.g. cf-connecting-ip) so installs behind Cloudflare are fingerprinted on the real device IP instead of a CDN/NAT hop. Opt-in; default behavior unchanged. Existing /24-match tests repointed from private to public IPs (their intent was to verify /24 matching, which now correctly requires routable IPs). Full suite green (125 tests).
1 parent aa65bff commit 58684e1

4 files changed

Lines changed: 212 additions & 15 deletions

File tree

src/lib/client-ip.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, vi } from 'vitest';
1+
import { describe, it, expect, vi, afterEach } from 'vitest';
22
import type { FastifyRequest } from 'fastify';
33
import { getClientIp } from './client-ip';
44

@@ -36,6 +36,47 @@ describe('getClientIp', () => {
3636
});
3737
});
3838

39+
describe('getClientIp with TRUSTED_CLIENT_IP_HEADER', () => {
40+
const ORIGINAL = process.env.TRUSTED_CLIENT_IP_HEADER;
41+
afterEach(() => {
42+
if (ORIGINAL === undefined) delete process.env.TRUSTED_CLIENT_IP_HEADER;
43+
else process.env.TRUSTED_CLIENT_IP_HEADER = ORIGINAL;
44+
});
45+
46+
it('prefers the configured header over request.ip', () => {
47+
process.env.TRUSTED_CLIENT_IP_HEADER = 'cf-connecting-ip';
48+
const request = {
49+
ip: '162.158.23.75', // a Cloudflare edge IP
50+
headers: { 'cf-connecting-ip': '203.0.113.50' },
51+
} as unknown as FastifyRequest;
52+
expect(getClientIp(request)).toBe('203.0.113.50');
53+
});
54+
55+
it('falls back to request.ip when the configured header is absent', () => {
56+
process.env.TRUSTED_CLIENT_IP_HEADER = 'cf-connecting-ip';
57+
const request = { ip: '203.0.113.50', headers: {} } as unknown as FastifyRequest;
58+
expect(getClientIp(request)).toBe('203.0.113.50');
59+
});
60+
61+
it('takes the first entry of a comma-separated header value', () => {
62+
process.env.TRUSTED_CLIENT_IP_HEADER = 'true-client-ip';
63+
const request = {
64+
ip: '10.0.0.1',
65+
headers: { 'true-client-ip': '203.0.113.50, 70.0.0.1' },
66+
} as unknown as FastifyRequest;
67+
expect(getClientIp(request)).toBe('203.0.113.50');
68+
});
69+
70+
it('ignores the header when the env var is unset (default behavior)', () => {
71+
delete process.env.TRUSTED_CLIENT_IP_HEADER;
72+
const request = {
73+
ip: '203.0.113.50',
74+
headers: { 'cf-connecting-ip': '1.2.3.4' },
75+
} as unknown as FastifyRequest;
76+
expect(getClientIp(request)).toBe('203.0.113.50');
77+
});
78+
});
79+
3980
describe('getClientIp with Fastify trustProxy (proxied request)', () => {
4081
it('uses X-Forwarded-For when trustProxy is true', async () => {
4182
const Fastify = (await import('fastify')).default;

src/lib/client-ip.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,40 @@
11
import type { FastifyRequest } from 'fastify';
22

3+
function normalizeIp(ip: string): string {
4+
// IPv6-mapped IPv4: ::ffff:192.168.1.1 -> 192.168.1.1
5+
return ip.startsWith('::ffff:') ? ip.slice(7) : ip;
6+
}
7+
38
/**
49
* Returns the trusted client IP for the request.
510
* Use this everywhere client IP is needed (targeting, attribution, fingerprinting).
6-
* When the server is behind a reverse proxy, set Fastify's trustProxy option
7-
* so that request.ip is populated from X-Forwarded-For; this helper then
8-
* returns that trusted value.
11+
*
12+
* Behind a CDN/proxy that terminates the connection (e.g. Cloudflare), the
13+
* left-most `X-Forwarded-For` entry that Fastify exposes as `request.ip` is not
14+
* reliably the real client — it can be a CDN edge or NAT hop. When the proxy
15+
* sends an authoritative client-IP header (Cloudflare's `CF-Connecting-IP`, or
16+
* `True-Client-IP`), prefer it.
17+
*
18+
* Set `TRUSTED_CLIENT_IP_HEADER` to that header name to opt in (e.g.
19+
* `cf-connecting-ip`). IMPORTANT: only enable this when the origin is reachable
20+
* ONLY through that proxy — otherwise a direct client could spoof the header.
21+
* When unset, behavior is unchanged (uses `request.ip`).
922
*/
1023
export function getClientIp(request: FastifyRequest): string {
24+
const headerName = process.env.TRUSTED_CLIENT_IP_HEADER?.toLowerCase().trim();
25+
if (headerName) {
26+
const raw = request.headers[headerName];
27+
const value = Array.isArray(raw) ? raw[0] : raw;
28+
if (value && typeof value === 'string') {
29+
// Some proxies send a comma-separated list — take the first entry.
30+
const first = value.split(',')[0]?.trim();
31+
if (first) return normalizeIp(first);
32+
}
33+
}
34+
1135
const ip = request.ip ?? request.raw.socket?.remoteAddress;
1236
if (ip && typeof ip === 'string') {
13-
// IPv6-mapped IPv4: ::ffff:192.168.1.1 -> 192.168.1.1
14-
if (ip.startsWith('::ffff:')) return ip.slice(7);
15-
return ip;
37+
return normalizeIp(ip);
1638
}
1739
return '';
1840
}

src/lib/fingerprint.test.ts

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { db } from './database';
1313
const mockDbQuery = db.query as Mock;
1414

1515
const baseFingerprint = {
16-
ipAddress: '192.168.1.100',
16+
ipAddress: '24.5.10.100',
1717
userAgent:
1818
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36',
1919
timezone: 'America/Los_Angeles',
@@ -66,7 +66,7 @@ describe('calculateConfidenceScore', () => {
6666
it('matches IP within the same /24 subnet and normalizes user agent', () => {
6767
const click = {
6868
...baseFingerprint,
69-
ipAddress: '192.168.1.250',
69+
ipAddress: '24.5.10.250',
7070
timezone: 'Africa/Cairo',
7171
language: 'fr-FR',
7272
screenWidth: 800,
@@ -76,7 +76,7 @@ describe('calculateConfidenceScore', () => {
7676

7777
const install = {
7878
...baseFingerprint,
79-
ipAddress: '192.168.1.123',
79+
ipAddress: '24.5.10.123',
8080
userAgent: baseFingerprint.userAgent.replace('Chrome/95.0.4638.69', 'Chrome/116.0.0.0'),
8181
timezone: 'Europe/London',
8282
language: 'de-DE',
@@ -149,6 +149,66 @@ describe('calculateConfidenceScore', () => {
149149
});
150150
});
151151

152+
describe('isAttributableIp', () => {
153+
it('treats public IPv4 as attributable', () => {
154+
expect(fingerprint.isAttributableIp('24.5.10.100')).toBe(true);
155+
expect(fingerprint.isAttributableIp('8.8.8.8')).toBe(true);
156+
});
157+
158+
it('rejects CGNAT (100.64.0.0/10) — the Marriage365 case', () => {
159+
expect(fingerprint.isAttributableIp('100.64.0.5')).toBe(false);
160+
expect(fingerprint.isAttributableIp('100.127.255.254')).toBe(false);
161+
});
162+
163+
it('rejects RFC1918 private, loopback and link-local', () => {
164+
expect(fingerprint.isAttributableIp('10.0.0.1')).toBe(false);
165+
expect(fingerprint.isAttributableIp('172.16.0.1')).toBe(false);
166+
expect(fingerprint.isAttributableIp('192.168.1.1')).toBe(false);
167+
expect(fingerprint.isAttributableIp('127.0.0.1')).toBe(false);
168+
expect(fingerprint.isAttributableIp('169.254.1.1')).toBe(false);
169+
});
170+
171+
it('handles IPv6: public attributable, ULA/link-local/loopback not', () => {
172+
expect(fingerprint.isAttributableIp('2600:1700:6508:8040::1')).toBe(true);
173+
expect(fingerprint.isAttributableIp('fd00::1')).toBe(false); // ULA
174+
expect(fingerprint.isAttributableIp('fe80::1')).toBe(false); // link-local
175+
expect(fingerprint.isAttributableIp('::1')).toBe(false); // loopback
176+
});
177+
178+
it('unwraps IPv4-mapped IPv6 before classifying', () => {
179+
expect(fingerprint.isAttributableIp('::ffff:100.64.0.5')).toBe(false);
180+
expect(fingerprint.isAttributableIp('::ffff:24.5.10.100')).toBe(true);
181+
});
182+
183+
it('returns false for empty/garbage input', () => {
184+
expect(fingerprint.isAttributableIp('')).toBe(false);
185+
expect(fingerprint.isAttributableIp('not-an-ip')).toBe(false);
186+
});
187+
});
188+
189+
describe('calculateConfidenceScore — shared-IP filter', () => {
190+
it('does NOT award the IP score for two devices sharing a CGNAT /24', () => {
191+
// Exactly the Marriage365 leak: unrelated installs collapsing onto 100.64.0.x.
192+
const click = { ...baseFingerprint, ipAddress: '100.64.0.5' };
193+
const install = { ...baseFingerprint, ipAddress: '100.64.0.9' };
194+
195+
const { score, matchedFactors } = fingerprint.calculateConfidenceScore(click, install);
196+
197+
expect(matchedFactors).not.toContain('ip');
198+
// UA(30)+TZ(10)+lang(10)+screen(10) = 60, below the 70 threshold → no match.
199+
expect(score).toBe(60);
200+
expect(score).toBeLessThan(fingerprint.CONFIDENCE_THRESHOLD);
201+
});
202+
203+
it('still awards the IP score for two devices sharing a public /24', () => {
204+
const click = { ...baseFingerprint, ipAddress: '24.5.10.5' };
205+
const install = { ...baseFingerprint, ipAddress: '24.5.10.9' };
206+
207+
const { matchedFactors } = fingerprint.calculateConfidenceScore(click, install);
208+
expect(matchedFactors).toContain('ip');
209+
});
210+
});
211+
152212
describe('matchInstallToClick', () => {
153213
beforeEach(() => {
154214
vi.useFakeTimers();
@@ -176,7 +236,7 @@ describe('matchInstallToClick', () => {
176236
link_id: 'link-a',
177237
clicked_at: clickTime.toISOString(),
178238
attribution_window_hours: 24,
179-
ip_address: '192.168.1.200',
239+
ip_address: '24.5.10.200',
180240
user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36',
181241
timezone: 'Asia/Tokyo',
182242
language: 'ja-JP',
@@ -192,7 +252,7 @@ describe('matchInstallToClick', () => {
192252
link_id: 'link-b',
193253
clicked_at: clickTime.toISOString(),
194254
attribution_window_hours: 24,
195-
ip_address: '192.168.1.250',
255+
ip_address: '24.5.10.250',
196256
user_agent:
197257
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36',
198258
timezone: 'America/Los_Angeles',
@@ -207,7 +267,7 @@ describe('matchInstallToClick', () => {
207267

208268
const installFingerprint = {
209269
...baseFingerprint,
210-
ipAddress: '192.168.1.123',
270+
ipAddress: '24.5.10.123',
211271
userAgent: baseFingerprint.userAgent.replace('Chrome/95.0.4638.69', 'Chrome/116.0.0.0'),
212272
};
213273

src/lib/fingerprint.ts

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,73 @@ export function generateFingerprintHash(data: FingerprintData): string {
7272
* Normalize IP address for comparison
7373
* Handles IPv4 and IPv6, removes subnet variations
7474
*/
75+
function ipv4ToInt(ip: string): number | null {
76+
const parts = ip.split('.');
77+
if (parts.length !== 4) return null;
78+
let n = 0;
79+
for (const p of parts) {
80+
const o = Number(p);
81+
if (!Number.isInteger(o) || o < 0 || o > 255) return null;
82+
n = (n << 8) | o;
83+
}
84+
return n >>> 0;
85+
}
86+
87+
function inCidr4(ipInt: number, baseIp: string, prefix: number): boolean {
88+
const base = ipv4ToInt(baseIp);
89+
if (base === null) return false;
90+
const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
91+
return (ipInt & mask) === (base & mask);
92+
}
93+
94+
// Shared / non-routable IPv4 ranges that can't identify a single device:
95+
// carrier-grade NAT, RFC1918 private, loopback, link-local, etc. Two different
96+
// users routinely share these (mobile carrier NAT, office Wi-Fi, VPN egress).
97+
const NON_ATTRIBUTABLE_V4: ReadonlyArray<readonly [string, number]> = [
98+
['0.0.0.0', 8],
99+
['10.0.0.0', 8],
100+
['100.64.0.0', 10], // CGNAT (RFC 6598)
101+
['127.0.0.0', 8], // loopback
102+
['169.254.0.0', 16], // link-local
103+
['172.16.0.0', 12], // private
104+
['192.0.0.0', 24], // IETF protocol assignments
105+
['192.168.0.0', 16], // private
106+
['198.18.0.0', 15], // benchmarking
107+
];
108+
109+
/**
110+
* Whether an IP is specific enough to use as an attribution signal. Returns
111+
* false for shared/non-routable ranges (CGNAT, RFC1918, loopback, link-local,
112+
* IPv6 ULA/link-local) where the IP does NOT identify a single device, so it
113+
* must not contribute to a fingerprint match. Public IPs return true.
114+
*/
115+
export function isAttributableIp(ip: string): boolean {
116+
if (!ip) return false;
117+
let addr = ip.trim();
118+
if (addr.startsWith('::ffff:')) addr = addr.slice(7); // IPv4-mapped IPv6
119+
120+
// IPv4
121+
if (addr.includes('.') && !addr.includes(':')) {
122+
const n = ipv4ToInt(addr);
123+
if (n === null) return false;
124+
return !NON_ATTRIBUTABLE_V4.some(([base, prefix]) => inCidr4(n, base, prefix));
125+
}
126+
127+
// IPv6
128+
if (addr.includes(':')) {
129+
const low = addr.toLowerCase();
130+
if (low === '::' || low === '::1') return false; // unspecified / loopback
131+
if (low.startsWith('fc') || low.startsWith('fd')) return false; // fc00::/7 ULA
132+
if (low.startsWith('fe8') || low.startsWith('fe9') || low.startsWith('fea') || low.startsWith('feb')) {
133+
return false; // fe80::/10 link-local
134+
}
135+
return true;
136+
}
137+
138+
// Not a recognizable IPv4 or IPv6 address.
139+
return false;
140+
}
141+
75142
function normalizeIP(ip: string): string {
76143
if (!ip) return '';
77144

@@ -119,8 +186,15 @@ export function calculateConfidenceScore(
119186
let score = 0;
120187
const matchedFactors: string[] = [];
121188

122-
// Compare IP addresses (normalized to /24 subnet for IPv4)
123-
if (fingerprint1.ipAddress && fingerprint2.ipAddress) {
189+
// Compare IP addresses (normalized to /24 subnet for IPv4). Only count IPs
190+
// that actually identify a device — shared/NAT ranges (CGNAT, RFC1918, etc.)
191+
// are skipped so unrelated users behind the same NAT can't match on IP.
192+
if (
193+
fingerprint1.ipAddress &&
194+
fingerprint2.ipAddress &&
195+
isAttributableIp(fingerprint1.ipAddress) &&
196+
isAttributableIp(fingerprint2.ipAddress)
197+
) {
124198
const ip1 = normalizeIP(fingerprint1.ipAddress);
125199
const ip2 = normalizeIP(fingerprint2.ipAddress);
126200

0 commit comments

Comments
 (0)