@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.
- package/README.md +245 -113
- package/models/default-weights.json +682 -0
- package/package.json +19 -4
- package/src/admin/otpGate.js +370 -0
- package/src/biometrics/index.js +10 -0
- package/src/biometrics/minirocket.js +213 -0
- package/src/biometrics/ridge.js +144 -0
- package/src/client/collector.js +136 -0
- package/src/client/index.js +68 -0
- package/src/client/shieldClient.js +118 -0
- package/src/core/ipResolver.js +225 -0
- package/src/index.d.ts +181 -0
- package/src/index.js +138 -5
- package/src/middleware/behavioralGuard.js +304 -0
- package/src/middleware/honeypot.js +6 -4
- package/src/middleware/notFoundCollector.js +6 -4
- package/src/middleware/shieldGuard.js +3 -0
- package/src/storage/BaseAdapter.js +58 -0
- package/src/storage/CallbackStorageAdapter.js +69 -0
- package/src/storage/FileStorageAdapter.js +174 -0
- package/src/storage/MemoryAdapter.js +56 -0
- package/src/storage/index.js +53 -0
|
@@ -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;
|