iri-shield 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,299 @@
1
+ 'use strict';
2
+
3
+ const { createHash, randomUUID } = require('crypto');
4
+
5
+ /**
6
+ * Build a rich client context from the incoming request.
7
+ * Supports testing overrides via headers or req.body.__iri when config.testing.enabled = true.
8
+ */
9
+ function buildClientContext(req, res, config) {
10
+ const testingEnabled = config.testing?.enabled === true;
11
+ const override = testingEnabled ? getTestingOverride(req, config) : {};
12
+
13
+ // --- IP Resolution ---
14
+ const realIp = getClientIp(req);
15
+ const ip = override.ip || realIp;
16
+
17
+ // --- User Agent ---
18
+ const realUserAgent = req.headers['user-agent'] || '';
19
+ const userAgent = override.userAgent || realUserAgent;
20
+
21
+ // --- Cookie ---
22
+ const cookie = override.cookie || req.headers.cookie || '';
23
+
24
+ // --- Session ---
25
+ const sessionId =
26
+ override.sessionId ||
27
+ req.headers['x-session-id'] ||
28
+ readCookie(cookie, 'connect.sid') ||
29
+ readCookie(cookie, 'express.sid') ||
30
+ '';
31
+
32
+ // --- Declared User ---
33
+ const declaredUserId =
34
+ override.userId ||
35
+ req.headers['x-user-id'] ||
36
+ req.body?.userId ||
37
+ req.query?.userId ||
38
+ '';
39
+
40
+ // --- Device ID ---
41
+ const deviceId = override.deviceId || req.headers['x-device-id'] || '';
42
+
43
+ // --- Client ID (persistent cookie-based) ---
44
+ const existingClientId =
45
+ override.clientId ||
46
+ req.headers['x-iri-client-id'] ||
47
+ readCookie(cookie, 'iri_shield_uid');
48
+ const clientId = existingClientId || randomUUID();
49
+
50
+ // Set persistent client cookie if new
51
+ if (!existingClientId && res && !res.headersSent) {
52
+ res.setHeader(
53
+ 'Set-Cookie',
54
+ `iri_shield_uid=${clientId}; HttpOnly; SameSite=Lax; Path=/; Max-Age=31536000`
55
+ );
56
+ }
57
+
58
+ // --- Browser Signal Headers ---
59
+ const acceptLanguage = req.headers['accept-language'] || '';
60
+ const acceptEncoding = req.headers['accept-encoding'] || '';
61
+ const accept = req.headers['accept'] || '';
62
+ const dnt = req.headers['dnt'] || req.headers['sec-gpc'] || '';
63
+ const connection = req.headers['connection'] || '';
64
+ const referer = req.headers['referer'] || req.headers['referrer'] || '';
65
+
66
+ // --- Client Hints (modern browsers) ---
67
+ const secChUa = req.headers['sec-ch-ua'] || '';
68
+ const secChUaMobile = req.headers['sec-ch-ua-mobile'] || '';
69
+ const secChUaPlatform = req.headers['sec-ch-ua-platform'] || '';
70
+ const secChUaArch = req.headers['sec-ch-ua-arch'] || '';
71
+
72
+ // --- Fetch Metadata ---
73
+ const secFetchSite = req.headers['sec-fetch-site'] || '';
74
+ const secFetchMode = req.headers['sec-fetch-mode'] || '';
75
+ const secFetchDest = req.headers['sec-fetch-dest'] || '';
76
+ const secFetchUser = req.headers['sec-fetch-user'] || '';
77
+
78
+ // --- Network info ---
79
+ const xForwardedProto = req.headers['x-forwarded-proto'] || '';
80
+ const xRealIp = req.headers['x-real-ip'] || '';
81
+ const cfRay = req.headers['cf-ray'] || ''; // Cloudflare
82
+ const cfConnectingIp = req.headers['cf-connecting-ip'] || '';
83
+
84
+ // --- Header presence anomaly signals ---
85
+ const headerSignals = buildHeaderSignals(req, userAgent);
86
+
87
+ // --- Fingerprint (stable multi-signal hash) ---
88
+ const fingerprintSource = [
89
+ declaredUserId,
90
+ deviceId,
91
+ sessionId,
92
+ normalizeUa(userAgent),
93
+ normalizeIpFamily(ip),
94
+ acceptLanguage.slice(0, 20),
95
+ secChUaPlatform,
96
+ secChUaMobile
97
+ ]
98
+ .filter(Boolean)
99
+ .join('|');
100
+
101
+ // --- Browser fingerprint (volatile signals for anomaly, not for stable ID) ---
102
+ const browserFingerprint = buildBrowserFingerprint({
103
+ userAgent,
104
+ acceptLanguage,
105
+ acceptEncoding,
106
+ accept,
107
+ dnt,
108
+ connection,
109
+ secChUa,
110
+ secChUaMobile,
111
+ secChUaPlatform,
112
+ secFetchSite,
113
+ secFetchMode
114
+ });
115
+
116
+ return {
117
+ clientId,
118
+ userId: declaredUserId || clientId,
119
+ ip,
120
+ realIp,
121
+ userAgent,
122
+ cookie,
123
+ sessionId,
124
+ deviceId,
125
+ referer,
126
+ fingerprint: sha256(fingerprintSource || clientId),
127
+ browserFingerprint,
128
+ acceptLanguage,
129
+ secChUa,
130
+ secChUaPlatform,
131
+ secFetchSite,
132
+ secFetchMode,
133
+ secFetchDest,
134
+ headerSignals,
135
+ isTestingOverride: Boolean(override.used),
136
+ timestamp: new Date().toISOString()
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Detect suspicious identity changes for a returning client.
142
+ */
143
+ function detectIdentityChange(client, existing) {
144
+ if (!existing || existing.requestCount === 0) {
145
+ return { score: 0, reasons: [], threats: [] };
146
+ }
147
+
148
+ const reasons = [];
149
+ const threats = [];
150
+ let score = 0;
151
+
152
+ // IP change
153
+ if (client.ip && existing.ips?.length && !existing.ips.includes(client.ip)) {
154
+ score += 15;
155
+ threats.push('identity_ip_change');
156
+ reasons.push(`new_ip_for_client_${client.ip}`);
157
+ }
158
+
159
+ // User-Agent change
160
+ if (client.userAgent && existing.userAgents?.length && !existing.userAgents.includes(client.userAgent)) {
161
+ score += 20;
162
+ threats.push('identity_user_agent_change');
163
+ reasons.push('new_user_agent_for_client');
164
+ }
165
+
166
+ // Fingerprint change (strong signal)
167
+ if (client.fingerprint && existing.fingerprints?.length && !existing.fingerprints.includes(client.fingerprint)) {
168
+ score += 25;
169
+ threats.push('identity_fingerprint_change');
170
+ reasons.push('new_fingerprint_for_known_client');
171
+ }
172
+
173
+ // Platform change (Client Hints — very reliable in modern browsers)
174
+ if (
175
+ client.secChUaPlatform &&
176
+ existing.platforms?.length &&
177
+ !existing.platforms.includes(client.secChUaPlatform)
178
+ ) {
179
+ score += 10;
180
+ threats.push('identity_platform_change');
181
+ reasons.push(`new_platform_${client.secChUaPlatform}`);
182
+ }
183
+
184
+ return { score, reasons, threats };
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Helpers
189
+ // ---------------------------------------------------------------------------
190
+
191
+ function getTestingOverride(req, config) {
192
+ if (!config.testing?.allowClientOverrides) return {};
193
+ const body = req.body?.__iri || req.body?.iriShieldTest || {};
194
+ const output = {
195
+ ip: req.headers['x-iri-test-ip'] || body.ip,
196
+ userAgent: req.headers['x-iri-test-user-agent'] || body.userAgent,
197
+ cookie: req.headers['x-iri-test-cookie'] || body.cookie,
198
+ sessionId: req.headers['x-iri-test-session-id'] || body.sessionId,
199
+ userId: req.headers['x-iri-test-user-id'] || body.userId,
200
+ deviceId: req.headers['x-iri-test-device-id'] || body.deviceId,
201
+ clientId: req.headers['x-iri-test-client-id'] || body.clientId
202
+ };
203
+ output.used = Object.values(output).some(Boolean);
204
+ return output;
205
+ }
206
+
207
+ function getClientIp(req) {
208
+ // Trust proxy chain
209
+ const cfIp = req.headers['cf-connecting-ip'];
210
+ if (cfIp) return cfIp.trim();
211
+
212
+ const forwarded = req.headers['x-forwarded-for'];
213
+ if (typeof forwarded === 'string' && forwarded.trim()) {
214
+ return forwarded.split(',')[0].trim();
215
+ }
216
+
217
+ const realIp = req.headers['x-real-ip'];
218
+ if (realIp) return realIp.trim();
219
+
220
+ return req.ip || req.socket?.remoteAddress || req.connection?.remoteAddress || 'unknown';
221
+ }
222
+
223
+ function readCookie(cookieHeader, name) {
224
+ return String(cookieHeader || '')
225
+ .split(';')
226
+ .map((item) => item.trim())
227
+ .reduce((value, item) => {
228
+ const splitAt = item.indexOf('=');
229
+ if (splitAt === -1) return value;
230
+ const key = decodeURIComponent(item.slice(0, splitAt));
231
+ return key === name ? decodeURIComponent(item.slice(splitAt + 1)) : value;
232
+ }, '');
233
+ }
234
+
235
+ function normalizeIpFamily(ip) {
236
+ const value = String(ip || '');
237
+ // IPv6: keep first 4 groups (network prefix)
238
+ if (value.includes(':')) return value.split(':').slice(0, 4).join(':');
239
+ // IPv4: keep first 3 octets (subnet)
240
+ return value.split('.').slice(0, 3).join('.');
241
+ }
242
+
243
+ function normalizeUa(ua) {
244
+ // Strip version numbers for stable comparison
245
+ return String(ua || '')
246
+ .replace(/[\d.]+/g, 'X')
247
+ .slice(0, 80);
248
+ }
249
+
250
+ /**
251
+ * Build a secondary browser-signal fingerprint for anomaly detection.
252
+ * This is more volatile and used to detect spoofing.
253
+ */
254
+ function buildBrowserFingerprint(signals) {
255
+ const parts = Object.values(signals).filter(Boolean).join('|');
256
+ return sha256(parts || 'empty');
257
+ }
258
+
259
+ /**
260
+ * Analyze header presence for anomaly detection.
261
+ * Real browsers send specific combinations of headers.
262
+ */
263
+ function buildHeaderSignals(req, userAgent) {
264
+ const ua = String(userAgent || '').toLowerCase();
265
+ const isBrowserLike =
266
+ ua.includes('mozilla') || ua.includes('chrome') || ua.includes('safari') || ua.includes('firefox');
267
+
268
+ const hasAccept = Boolean(req.headers['accept']);
269
+ const hasAcceptLang = Boolean(req.headers['accept-language']);
270
+ const hasAcceptEncoding = Boolean(req.headers['accept-encoding']);
271
+ const hasSecFetch = Boolean(req.headers['sec-fetch-site']);
272
+ const hasSecChUa = Boolean(req.headers['sec-ch-ua']);
273
+
274
+ // Real browsers (Chrome/Edge) always send sec-fetch-* and sec-ch-ua
275
+ const missingBrowserHeaders =
276
+ isBrowserLike && (!hasAccept || !hasAcceptLang || !hasAcceptEncoding);
277
+
278
+ // Modern browsers almost always send sec-fetch headers
279
+ const claimsModernBrowser =
280
+ isBrowserLike && (ua.includes('chrome') || ua.includes('edge'));
281
+ const missingModernHeaders = claimsModernBrowser && !hasSecFetch && !hasSecChUa;
282
+
283
+ return {
284
+ hasAccept,
285
+ hasAcceptLang,
286
+ hasAcceptEncoding,
287
+ hasSecFetch,
288
+ hasSecChUa,
289
+ isBrowserLike,
290
+ missingBrowserHeaders,
291
+ missingModernHeaders
292
+ };
293
+ }
294
+
295
+ function sha256(value) {
296
+ return createHash('sha256').update(String(value)).digest('hex').slice(0, 24);
297
+ }
298
+
299
+ module.exports = { buildClientContext, detectIdentityChange, getClientIp };