@chdlabs/my-shield 0.1.3 → 0.3.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,144 @@
1
+ /**
2
+ * @file src/biometrics/ridge.js
3
+ * Dual Regularized Ridge Regression Solver with Cholesky Decomposition
4
+ * Solves ridge regression in dual space when feature dimension P >> N, recovering
5
+ * primal weights for O(P) single dot-product inference latency.
6
+ *
7
+ * @author ChdLabs
8
+ * @license MIT
9
+ */
10
+
11
+ export class DualRidgeClassifier {
12
+ /**
13
+ * @param {number} [alpha=10.0] L2 regularization strength
14
+ */
15
+ constructor(alpha = 10.0) {
16
+ this.alpha = alpha;
17
+ this.weights = null;
18
+ this.bias = 0.0;
19
+ }
20
+
21
+ /**
22
+ * Load pre-fitted weights and bias
23
+ * @param {number[]|Float32Array} weightsArray
24
+ * @param {number} [bias=0.0]
25
+ */
26
+ loadWeights(weightsArray, bias = 0.0) {
27
+ this.weights = Float32Array.from(weightsArray);
28
+ this.bias = bias;
29
+ }
30
+
31
+ /**
32
+ * Fit Dual Ridge classifier from feature matrix X and labels y
33
+ * @param {Array<Float32Array|number[]>} X Feature matrix (N samples x P features)
34
+ * @param {number[]} y Target labels (-1 for bot, +1 for human)
35
+ */
36
+ fit(X, y) {
37
+ const N = X.length;
38
+ if (N === 0) throw new Error('Training data X cannot be empty.');
39
+ const P = X[0].length;
40
+
41
+ let meanY = 0;
42
+ for (let i = 0; i < N; i++) meanY += y[i];
43
+ meanY /= N;
44
+ this.bias = meanY;
45
+
46
+ // Gram Matrix K = X * X^T + alpha * I
47
+ const K = Array.from({ length: N }, () => new Float64Array(N));
48
+ for (let i = 0; i < N; i++) {
49
+ for (let j = i; j < N; j++) {
50
+ let dot = 0;
51
+ for (let k = 0; k < P; k++) dot += X[i][k] * X[j][k];
52
+ K[i][j] = dot;
53
+ K[j][i] = dot;
54
+ }
55
+ K[i][i] += this.alpha;
56
+ }
57
+
58
+ // Cholesky Solve L * L^T * beta = (y - meanY)
59
+ const yC = y.map(v => v - this.bias);
60
+ const beta = this._choleskySolve(K, yC);
61
+
62
+ // Primal Recovery w = X^T * beta
63
+ this.weights = new Float32Array(P);
64
+ for (let j = 0; j < P; j++) {
65
+ let sum = 0;
66
+ for (let i = 0; i < N; i++) sum += beta[i] * X[i][j];
67
+ this.weights[j] = sum;
68
+ }
69
+
70
+ // Exact intercept b = mean(y) - w^T * mean(X)
71
+ let wMeanX = 0;
72
+ for (let j = 0; j < P; j++) {
73
+ let meanX_j = 0;
74
+ for (let i = 0; i < N; i++) meanX_j += X[i][j];
75
+ meanX_j /= N;
76
+ wMeanX += this.weights[j] * meanX_j;
77
+ }
78
+ this.bias = meanY - wMeanX;
79
+ }
80
+
81
+ /**
82
+ * Predict human classification probability for a feature vector
83
+ * @param {Float32Array|number[]} features
84
+ * @returns {{ score: number, probability: number, isHuman: boolean }}
85
+ */
86
+ predict(features) {
87
+ if (!this.weights) {
88
+ throw new Error('DualRidgeClassifier requires fitted or loaded weights.');
89
+ }
90
+
91
+ let score = this.bias;
92
+ const P = this.weights.length;
93
+ for (let i = 0; i < P; i++) {
94
+ score += this.weights[i] * features[i];
95
+ }
96
+
97
+ // Sigmoid mapping: P(Human) = 1 / (1 + exp(-score))
98
+ const probability = 1 / (1 + Math.exp(-Math.max(-50, Math.min(50, score))));
99
+ return {
100
+ score,
101
+ probability,
102
+ isHuman: score >= 0
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Solve A * x = b via Cholesky decomposition A = L * L^T
108
+ * @private
109
+ */
110
+ _choleskySolve(A, b) {
111
+ const n = b.length;
112
+ const L = Array.from({ length: n }, () => new Float64Array(n));
113
+
114
+ for (let i = 0; i < n; i++) {
115
+ for (let j = 0; j <= i; j++) {
116
+ let sum = 0;
117
+ for (let k = 0; k < j; k++) sum += L[i][k] * L[j][k];
118
+ if (i === j) {
119
+ const val = A[i][i] - sum;
120
+ L[i][j] = Math.sqrt(val > 0 ? val : 1e-9);
121
+ } else {
122
+ L[i][j] = (A[i][j] - sum) / L[j][j];
123
+ }
124
+ }
125
+ }
126
+
127
+ // Forward solve: L * z = b
128
+ const z = new Float64Array(n);
129
+ for (let i = 0; i < n; i++) {
130
+ let sum = 0;
131
+ for (let k = 0; k < i; k++) sum += L[i][k] * z[k];
132
+ z[i] = (b[i] - sum) / L[i][i];
133
+ }
134
+
135
+ // Back substitution: L^T * x = z
136
+ const x = new Float64Array(n);
137
+ for (let i = n - 1; i >= 0; i--) {
138
+ let sum = 0;
139
+ for (let k = i + 1; k < n; k++) sum += L[k][i] * x[k];
140
+ x[i] = (z[i] - sum) / L[i][i];
141
+ }
142
+ return x;
143
+ }
144
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @file src/client/collector.js
3
+ * Browser Client Kinematics Telemetry Collector
4
+ * Captures pointer dynamics and normalizes trajectories to equispaced kinematic series
5
+ * for behavioral biometric classification. Zero external dependencies.
6
+ *
7
+ * @author ChdLabs
8
+ * @license MIT
9
+ */
10
+
11
+ export class MouseTelemetryCollector {
12
+ /**
13
+ * @param {Object} [options={}]
14
+ * @param {number} [options.windowLength=128] Number of equispaced time points
15
+ * @param {number} [options.sampleIntervalMs=10] Equispaced time delta (10ms = 100Hz)
16
+ * @param {number} [options.maxBufferSize=500] Raw event sliding buffer size
17
+ * @param {number} [options.maxIdleMs=2500] Maximum acceptable idle gap before trajectory considered stale
18
+ * @param {number} [options.minPoints=15] Minimum raw points required to avoid flat interpolation
19
+ * @param {boolean} [options.flushOnConsumption=true] Flush buffer after getPayload to avoid replaying identical vectors
20
+ */
21
+ constructor(options = {}) {
22
+ this.windowLength = options.windowLength || 128;
23
+ this.sampleIntervalMs = options.sampleIntervalMs || 10;
24
+ this.maxBufferSize = options.maxBufferSize || 500;
25
+ this.maxIdleMs = options.maxIdleMs ?? 2500;
26
+ this.minPoints = options.minPoints ?? 15;
27
+ this.flushOnConsumption = options.flushOnConsumption !== false;
28
+ this.buffer = [];
29
+ this.isListening = false;
30
+ this._onMove = this._onMove.bind(this);
31
+ }
32
+
33
+ /**
34
+ * Start capturing mouse movement events
35
+ */
36
+ start() {
37
+ if (this.isListening || typeof window === 'undefined') return;
38
+ window.addEventListener('mousemove', this._onMove, { passive: true });
39
+ this.isListening = true;
40
+ }
41
+
42
+ /**
43
+ * Stop capturing mouse movement events
44
+ */
45
+ stop() {
46
+ if (!this.isListening || typeof window === 'undefined') return;
47
+ window.removeEventListener('mousemove', this._onMove);
48
+ this.isListening = false;
49
+ }
50
+
51
+ /**
52
+ * Event listener callback
53
+ * @private
54
+ */
55
+ _onMove(e) {
56
+ this.buffer.push({
57
+ x: e.clientX,
58
+ y: e.clientY,
59
+ t: typeof performance !== 'undefined' ? performance.now() : Date.now(),
60
+ trusted: e.isTrusted !== false
61
+ });
62
+
63
+ if (this.buffer.length > this.maxBufferSize) {
64
+ this.buffer.shift();
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Resamples trajectory to fixed length and derives kinematic velocity vector.
70
+ * @returns {{ velocity: number[], isTrusted: boolean, timestamp: number } | null}
71
+ */
72
+ getPayload() {
73
+ if (this.buffer.length < this.minPoints) return null;
74
+
75
+ const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
76
+ const latest = this.buffer[this.buffer.length - 1];
77
+
78
+ // 1. Freshness Guard: If pointer has been stationary for > maxIdleMs (e.g. 2.5s), discard stale vectors
79
+ if (this.maxIdleMs > 0 && (now - latest.t > this.maxIdleMs)) {
80
+ if (this.flushOnConsumption) this.buffer = [];
81
+ return null;
82
+ }
83
+
84
+ const requiredDuration = this.windowLength * this.sampleIntervalMs;
85
+ const threshold = latest.t - requiredDuration;
86
+ const points = this.buffer.filter(p => p.t >= threshold);
87
+
88
+ if (points.length < this.minPoints) return null;
89
+
90
+ // Check synthetic dispatch
91
+ const allTrusted = points.every(p => p.trusted === true);
92
+
93
+ const startTime = points[0].t;
94
+ const L = this.windowLength;
95
+ const dt = this.sampleIntervalMs;
96
+
97
+ const resampledX = new Float64Array(L);
98
+ const resampledY = new Float64Array(L);
99
+ let rawIdx = 0;
100
+
101
+ // 2. Equispaced linear interpolation
102
+ for (let i = 0; i < L; i++) {
103
+ const targetT = startTime + i * dt;
104
+ while (rawIdx < points.length - 2 && points[rawIdx + 1].t < targetT) {
105
+ rawIdx++;
106
+ }
107
+ const p0 = points[rawIdx];
108
+ const p1 = points[Math.min(rawIdx + 1, points.length - 1)];
109
+ const segmentDt = p1.t - p0.t;
110
+ const alpha = segmentDt > 0 ? Math.min(Math.max((targetT - p0.t) / segmentDt, 0), 1) : 0;
111
+
112
+ resampledX[i] = p0.x + alpha * (p1.x - p0.x);
113
+ resampledY[i] = p0.y + alpha * (p1.y - p0.y);
114
+ }
115
+
116
+ // 3. Velocity Derivation
117
+ const velocity = new Float64Array(L);
118
+ for (let i = 1; i < L; i++) {
119
+ const dx = resampledX[i] - resampledX[i - 1];
120
+ const dy = resampledY[i] - resampledY[i - 1];
121
+ velocity[i] = Math.hypot(dx, dy) / dt;
122
+ }
123
+ velocity[0] = velocity[1];
124
+
125
+ // 4. Buffer Flush on Consumption (Stops replay of identical kinematics across multiple clicks)
126
+ if (this.flushOnConsumption) {
127
+ this.buffer = [];
128
+ }
129
+
130
+ return {
131
+ velocity: Array.from(velocity),
132
+ isTrusted: allTrusted,
133
+ timestamp: Date.now()
134
+ };
135
+ }
136
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @file src/client/index.js
3
+ * Browser Client Suite for @chdlabs/my-shield
4
+ * Exports behavioral biometrics kinematics collector and DOM tripwire decoy.
5
+ *
6
+ * @author ChdLabs
7
+ * @license MIT
8
+ */
9
+
10
+ export { MouseTelemetryCollector } from './collector.js';
11
+ export { ShieldClient } from './shieldClient.js';
12
+
13
+ /**
14
+ * Injects invisible DOM tripwire decoy forms and links to trap automated scrapers
15
+ */
16
+ export function injectTripwire() {
17
+ if (typeof document === 'undefined') return;
18
+
19
+ const trapContainer = document.createElement('div');
20
+ trapContainer.setAttribute('aria-hidden', 'true');
21
+ trapContainer.style.position = 'absolute';
22
+ trapContainer.style.left = '-9999px';
23
+ trapContainer.style.top = '-9999px';
24
+ trapContainer.style.width = '1px';
25
+ trapContainer.style.height = '1px';
26
+ trapContainer.style.overflow = 'hidden';
27
+ trapContainer.style.opacity = '0';
28
+ trapContainer.style.pointerEvents = 'none';
29
+
30
+ // Decoy form
31
+ const form = document.createElement('form');
32
+ form.setAttribute('method', 'POST');
33
+ form.setAttribute('action', '/api/__shield_trap');
34
+
35
+ const inputUser = document.createElement('input');
36
+ inputUser.setAttribute('type', 'text');
37
+ inputUser.setAttribute('name', 'admin_override');
38
+ inputUser.setAttribute('tabindex', '-1');
39
+ inputUser.setAttribute('autocomplete', 'off');
40
+
41
+ const inputPass = document.createElement('input');
42
+ inputPass.setAttribute('type', 'password');
43
+ inputPass.setAttribute('name', 'debug_pass');
44
+ inputPass.setAttribute('tabindex', '-1');
45
+
46
+ form.appendChild(inputUser);
47
+ form.appendChild(inputPass);
48
+ trapContainer.appendChild(form);
49
+
50
+ // Decoy link
51
+ const link = document.createElement('a');
52
+ link.setAttribute('href', '/api/__shield_trap');
53
+ link.setAttribute('rel', 'nofollow');
54
+ link.setAttribute('tabindex', '-1');
55
+ link.textContent = 'Admin Console';
56
+ trapContainer.appendChild(link);
57
+
58
+ document.body.appendChild(trapContainer);
59
+ }
60
+
61
+ // Auto-inject if document is available and ready
62
+ if (typeof document !== 'undefined') {
63
+ if (document.readyState === 'loading') {
64
+ document.addEventListener('DOMContentLoaded', injectTripwire);
65
+ } else {
66
+ injectTripwire();
67
+ }
68
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * ShieldClient - Browser Client SDK for Behavioral Biometrics & Tripwires
3
+ *
4
+ * Provides real-time mouse kinematics telemetry capture, freshness verification,
5
+ * replay protection, and transparent HTTP interceptors for fetch and Axios.
6
+ *
7
+ * @author ChdLabs
8
+ * @license MIT
9
+ */
10
+ import { MouseTelemetryCollector } from './collector.js';
11
+ import { injectTripwire } from './index.js';
12
+
13
+ export class ShieldClient {
14
+ /**
15
+ * @param {Object} [options={}]
16
+ * @param {number} [options.windowLength=128]
17
+ * @param {number} [options.sampleIntervalMs=10]
18
+ * @param {number} [options.maxIdleMs=2500] Discard stale trajectory if pointer idle > 2.5s
19
+ * @param {number} [options.minPoints=15] Require at least 15 events
20
+ * @param {boolean} [options.flushOnConsumption=true] Flush buffer after reading payload
21
+ * @param {boolean} [options.autoInjectTripwire=false] Auto-inject decoy honeypot trap into DOM
22
+ */
23
+ constructor(options = {}) {
24
+ this.options = options;
25
+ this.collector = new MouseTelemetryCollector(options);
26
+
27
+ if (options.autoInjectTripwire && typeof document !== 'undefined') {
28
+ injectTripwire();
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Begin recording pointer movements
34
+ */
35
+ start() {
36
+ this.collector.start();
37
+ }
38
+
39
+ /**
40
+ * Stop recording pointer movements
41
+ */
42
+ stop() {
43
+ this.collector.stop();
44
+ }
45
+
46
+ /**
47
+ * Extract normalized 128-sample kinematic telemetry vector
48
+ * @returns {{ velocity: number[], isTrusted: boolean, timestamp: number } | null}
49
+ */
50
+ getPayload() {
51
+ return this.collector.getPayload();
52
+ }
53
+
54
+ /**
55
+ * Transparently instrument window.fetch to attach x-shield-telemetry header
56
+ * @param {Object} [options={}]
57
+ * @param {string} [options.headerName='x-shield-telemetry']
58
+ * @param {string[]} [options.endpoints=['*']] URL patterns to match (e.g. ['/api/auth/*', '/checkout'])
59
+ */
60
+ instrumentFetch({ headerName = 'x-shield-telemetry', endpoints = ['*'] } = {}) {
61
+ if (typeof window === 'undefined' || typeof window.fetch !== 'function') return;
62
+ const originalFetch = window.fetch;
63
+ const client = this;
64
+
65
+ window.fetch = async function(input, init = {}) {
66
+ let url = typeof input === 'string' ? input : (input?.url || '');
67
+
68
+ const isMatch = endpoints.some(pattern => {
69
+ if (pattern === '*' || pattern === '/*') return true;
70
+ if (pattern.endsWith('*')) return url.startsWith(pattern.slice(0, -1));
71
+ return url === pattern;
72
+ });
73
+
74
+ if (isMatch) {
75
+ const payload = client.getPayload();
76
+ if (payload) {
77
+ const headers = new Headers(init.headers || {});
78
+ headers.set(headerName, JSON.stringify(payload));
79
+ init = { ...init, headers };
80
+ }
81
+ }
82
+
83
+ return originalFetch.call(this, input, init);
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Transparently instrument an Axios instance to attach telemetry headers
89
+ * @param {Object} axiosInstance Axios instance
90
+ * @param {Object} [options={}]
91
+ * @param {string} [options.headerName='x-shield-telemetry']
92
+ * @param {string[]} [options.endpoints=['*']]
93
+ */
94
+ instrumentAxios(axiosInstance, { headerName = 'x-shield-telemetry', endpoints = ['*'] } = {}) {
95
+ if (!axiosInstance || !axiosInstance.interceptors || !axiosInstance.interceptors.request) return;
96
+ const client = this;
97
+
98
+ axiosInstance.interceptors.request.use((config) => {
99
+ const url = config.url || '';
100
+ const isMatch = endpoints.some(pattern => {
101
+ if (pattern === '*' || pattern === '/*') return true;
102
+ if (pattern.endsWith('*')) return url.startsWith(pattern.slice(0, -1));
103
+ return url === pattern;
104
+ });
105
+
106
+ if (isMatch) {
107
+ const payload = client.getPayload();
108
+ if (payload) {
109
+ config.headers = config.headers || {};
110
+ config.headers[headerName] = JSON.stringify(payload);
111
+ }
112
+ }
113
+ return config;
114
+ });
115
+ }
116
+ }
117
+
118
+ export default ShieldClient;
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Enterprise Client IP Resolution & Gateway Protection
3
+ *
4
+ * Provides safe IP extraction across multi-hop reverse proxies, Cloudflare,
5
+ * Nginx, HAProxy, and containerized Docker/Kubernetes environments while
6
+ * defending against header spoofing and accidental gateway blacklisting.
7
+ *
8
+ * @author ChdLabs
9
+ * @license MIT
10
+ */
11
+ import net from 'node:net';
12
+
13
+ export class IPResolver {
14
+ /**
15
+ * @param {Object} [options={}]
16
+ * @param {boolean|string|string[]} [options.trustProxy='loopback,linklocal,uniquelocal']
17
+ * @param {boolean} [options.protectGateways=true]
18
+ * @param {string[]} [options.protectedSubnets] Additional subnets/IPs to protect from bans
19
+ */
20
+ constructor(options = {}) {
21
+ this.trustProxy = options.trustProxy !== undefined ? options.trustProxy : 'loopback,linklocal,uniquelocal';
22
+ this.gatewayProtection = options.protectGateways !== false;
23
+ this.protectedSubnets = options.protectedSubnets || [];
24
+ }
25
+
26
+ /**
27
+ * Clean and normalize raw IP string
28
+ * - Strips surrounding whitespace
29
+ * - Strips IPv6 zone index (e.g. %eth0)
30
+ * - Strips IPv6 port brackets (e.g. [2001:db8::1]:8080 -> 2001:db8::1)
31
+ * - Strips IPv4 port (e.g. 198.51.100.42:54321 -> 198.51.100.42)
32
+ * - Normalizes IPv6-mapped IPv4 (e.g. ::ffff:192.0.2.1 -> 192.0.2.1)
33
+ * @param {string} rawIp
34
+ * @returns {string}
35
+ */
36
+ normalize(rawIp) {
37
+ if (!rawIp || typeof rawIp !== 'string') return '';
38
+ let ip = rawIp.trim();
39
+
40
+ // 1. Strip IPv6 bracketed port: [2001:db8::1]:8080
41
+ if (ip.startsWith('[') && ip.includes(']')) {
42
+ const closingIdx = ip.indexOf(']');
43
+ ip = ip.substring(1, closingIdx);
44
+ } else if (ip.includes(':')) {
45
+ // Check if it's an IPv4 with port: 1.2.3.4:5678 (contains exactly one ':')
46
+ const colonCount = (ip.match(/:/g) || []).length;
47
+ if (colonCount === 1) {
48
+ const parts = ip.split(':');
49
+ if (net.isIPv4(parts[0])) {
50
+ ip = parts[0];
51
+ }
52
+ }
53
+ }
54
+
55
+ // 2. Strip IPv6 zone index: fe80::1%eth0 -> fe80::1
56
+ if (ip.includes('%')) {
57
+ ip = ip.split('%')[0];
58
+ }
59
+
60
+ // 3. Normalize IPv6-mapped IPv4: ::ffff:192.168.1.1 -> 192.168.1.1
61
+ const lower = ip.toLowerCase();
62
+ if (lower.startsWith('::ffff:') && lower.indexOf('.', 7) !== -1) {
63
+ const candidateV4 = ip.substring(7);
64
+ if (net.isIPv4(candidateV4)) {
65
+ return candidateV4;
66
+ }
67
+ }
68
+
69
+ return ip;
70
+ }
71
+
72
+ /**
73
+ * Check if string is a valid IPv4 or IPv6 address
74
+ * @param {string} ip
75
+ * @returns {boolean}
76
+ */
77
+ isValid(ip) {
78
+ if (!ip || typeof ip !== 'string') return false;
79
+ return net.isIP(ip) !== 0;
80
+ }
81
+
82
+ /**
83
+ * Check if an IPv4 or IPv6 address is private, loopback, or link-local
84
+ * @param {string} ip
85
+ * @returns {boolean}
86
+ */
87
+ isPrivateOrLoopback(ip) {
88
+ const clean = this.normalize(ip);
89
+ if (!this.isValid(clean)) return false;
90
+
91
+ if (net.isIPv4(clean)) {
92
+ const octets = clean.split('.').map(Number);
93
+ const [o1, o2] = octets;
94
+
95
+ // 127.0.0.0/8 (Loopback)
96
+ if (o1 === 127) return true;
97
+ // 10.0.0.0/8 (Private)
98
+ if (o1 === 10) return true;
99
+ // 172.16.0.0/12 (Private including Docker 172.17.x.x)
100
+ if (o1 === 172 && o2 >= 16 && o2 <= 31) return true;
101
+ // 192.168.0.0/16 (Private)
102
+ if (o1 === 192 && o2 === 168) return true;
103
+ // 169.254.0.0/16 (Link-local)
104
+ if (o1 === 169 && o2 === 254) return true;
105
+ // 0.0.0.0
106
+ if (clean === '0.0.0.0') return true;
107
+
108
+ return false;
109
+ }
110
+
111
+ if (net.isIPv6(clean)) {
112
+ const lower = clean.toLowerCase();
113
+ // ::1 (Loopback)
114
+ if (lower === '::1' || lower === '0:0:0:0:0:0:0:1') return true;
115
+ // fe80::/10 (Link-local)
116
+ if (lower.startsWith('fe8') || lower.startsWith('fe9') || lower.startsWith('fea') || lower.startsWith('feb')) return true;
117
+ // fc00::/7 (Unique local / private: fc00::/8 and fd00::/8)
118
+ if (lower.startsWith('fc') || lower.startsWith('fd')) return true;
119
+
120
+ return false;
121
+ }
122
+
123
+ return false;
124
+ }
125
+
126
+ /**
127
+ * Check if an IP is a protected container gateway or loopback that should NEVER be banned
128
+ * (e.g. 172.17.0.1 Docker bridge, 127.0.0.1, ::1)
129
+ * @param {string} ip
130
+ * @returns {boolean}
131
+ */
132
+ isProtectedGateway(ip) {
133
+ if (!this.gatewayProtection) return false;
134
+ const clean = this.normalize(ip);
135
+ if (!this.isValid(clean)) return false;
136
+
137
+ // Loopback is always protected
138
+ if (clean === '127.0.0.1' || clean === '::1' || clean.startsWith('127.')) {
139
+ return true;
140
+ }
141
+
142
+ // Docker default bridge gateway and bridge subnet: 172.17.0.0/16
143
+ if (net.isIPv4(clean)) {
144
+ const octets = clean.split('.').map(Number);
145
+ // Default docker bridge network gateway: 172.17.0.1
146
+ if (octets[0] === 172 && octets[1] === 17) {
147
+ // If it ends with .1 (standard docker/podman host bridge gateway)
148
+ if (octets[3] === 1) return true;
149
+ }
150
+ // Standard docker compose default gateway: 172.18.0.1, 172.19.0.1, 172.20.0.1
151
+ if (octets[0] === 172 && octets[1] >= 17 && octets[1] <= 31 && octets[3] === 1) {
152
+ return true;
153
+ }
154
+ }
155
+
156
+ // Check any user-defined protected subnets/IPs
157
+ for (const subnet of this.protectedSubnets) {
158
+ if (clean === subnet) return true;
159
+ }
160
+
161
+ return false;
162
+ }
163
+
164
+ /**
165
+ * Extract client IP safely from Express request
166
+ * @param {Object} req Express request
167
+ * @returns {string} Normalized client IP
168
+ */
169
+ resolve(req) {
170
+ if (!req) return '127.0.0.1';
171
+
172
+ // 1. Direct exposure mode (if trustProxy: false, ignore all proxy headers)
173
+ if (this.trustProxy === false) {
174
+ const direct = req.socket?.remoteAddress || req.connection?.remoteAddress || req.ip || '127.0.0.1';
175
+ const clean = this.normalize(direct);
176
+ return this.isValid(clean) ? clean : '127.0.0.1';
177
+ }
178
+
179
+ const headers = req.headers || {};
180
+
181
+ // 2. Priority check for single-value reverse proxy headers
182
+ const singleHeaders = [
183
+ 'cf-connecting-ip', // Cloudflare
184
+ 'true-client-ip', // Akamai & Cloudflare Enterprise
185
+ 'x-real-ip', // Nginx proxy_set_header X-Real-IP $remote_addr
186
+ 'x-client-ip' // Apache & other reverse proxies
187
+ ];
188
+
189
+ for (const h of singleHeaders) {
190
+ const val = headers[h];
191
+ if (val && typeof val === 'string') {
192
+ const clean = this.normalize(val);
193
+ if (this.isValid(clean)) {
194
+ return clean;
195
+ }
196
+ }
197
+ }
198
+
199
+ // 3. Multi-hop X-Forwarded-For chain parsing
200
+ const xff = headers['x-forwarded-for'];
201
+ if (xff && typeof xff === 'string') {
202
+ const ips = xff
203
+ .split(',')
204
+ .map(s => this.normalize(s))
205
+ .filter(s => this.isValid(s));
206
+
207
+ if (ips.length > 0) {
208
+ // Find first public IP (skipping private reverse proxy intermediate hops)
209
+ const publicIp = ips.find(ip => !this.isPrivateOrLoopback(ip));
210
+ if (publicIp) {
211
+ return publicIp;
212
+ }
213
+ // If all IPs in chain are private, return the outermost client (first in XFF)
214
+ return ips[0];
215
+ }
216
+ }
217
+
218
+ // 4. Fallback to Express req.ip or socket remote address
219
+ const fallback = req.ip || req.socket?.remoteAddress || req.connection?.remoteAddress || '127.0.0.1';
220
+ const cleanFallback = this.normalize(fallback);
221
+ return this.isValid(cleanFallback) ? cleanFallback : '127.0.0.1';
222
+ }
223
+ }
224
+
225
+ export default IPResolver;