@hadi_ali/warden 1.0.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/LICENSE +21 -0
- package/README.md +209 -0
- package/app/Loger.js +14 -0
- package/app/allowlist.js +43 -0
- package/app/bruteforce.js +13 -0
- package/app/cli/cleanup.js +167 -0
- package/app/cli/index.js +62 -0
- package/app/db_connection/db.js +43 -0
- package/app/db_connection/migrate.js +63 -0
- package/app/db_connection/queries.js +61 -0
- package/app/demo/server.js +56 -0
- package/app/getClientIP.js +51 -0
- package/app/hashIP.js +7 -0
- package/app/honeypot.js +29 -0
- package/app/middleware/warden.js +276 -0
- package/app/normalizeIP.js +18 -0
- package/app/ratelimit.js +17 -0
- package/app/scoring/SubDomainScore.js +90 -0
- package/app/scoring/computeScore.js +28 -0
- package/app/scoring/computeSessionScore.js +52 -0
- package/app/scoring/headerFingerPrint.js +16 -0
- package/app/scoring/scoreFromHeaders.js +37 -0
- package/app/scoring/scoreUser.js +44 -0
- package/app/store.js +193 -0
- package/app/verified-bots/matcher.js +102 -0
- package/app/verified-bots/sync.js +220 -0
- package/index.js +14 -0
- package/package.json +56 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
async function upsertReputation(db, { ip_hash, score, path, action, reason }) {
|
|
2
|
+
if (!db || typeof db.query !== 'function') return null;
|
|
3
|
+
|
|
4
|
+
const query = `
|
|
5
|
+
WITH upserted AS (
|
|
6
|
+
INSERT INTO warden_reputation (ip_hash, score, last_seen)
|
|
7
|
+
VALUES ($1, $2, NOW())
|
|
8
|
+
ON CONFLICT (ip_hash)
|
|
9
|
+
DO UPDATE SET
|
|
10
|
+
score = GREATEST(0, LEAST(100,
|
|
11
|
+
FLOOR(
|
|
12
|
+
warden_reputation.score
|
|
13
|
+
* POWER(
|
|
14
|
+
0.95,
|
|
15
|
+
EXTRACT(EPOCH FROM (
|
|
16
|
+
NOW() - COALESCE(warden_reputation.last_seen, NOW())
|
|
17
|
+
)) / 3600.0
|
|
18
|
+
)
|
|
19
|
+
) + EXCLUDED.score
|
|
20
|
+
)),
|
|
21
|
+
last_seen = NOW()
|
|
22
|
+
RETURNING score
|
|
23
|
+
)
|
|
24
|
+
INSERT INTO warden_requests (ip_hash, timestamp, path, score_delta, action, reason)
|
|
25
|
+
VALUES ($1, NOW(), $3, $2, $4, $5)
|
|
26
|
+
RETURNING
|
|
27
|
+
(SELECT score FROM upserted) AS new_score;
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
const result = await db.query(query, [ip_hash, score, path, action, reason]);
|
|
31
|
+
return result.rows[0];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function upsertSession(db, session_hash, score) {
|
|
35
|
+
if (!db || typeof db.query !== 'function') return null;
|
|
36
|
+
|
|
37
|
+
const query = `
|
|
38
|
+
INSERT INTO warden_sessions (session_hash, score, last_seen)
|
|
39
|
+
VALUES ($1, $2, NOW())
|
|
40
|
+
ON CONFLICT (session_hash)
|
|
41
|
+
DO UPDATE SET
|
|
42
|
+
score = GREATEST(0, LEAST(100,
|
|
43
|
+
FLOOR(
|
|
44
|
+
warden_sessions.score
|
|
45
|
+
* POWER(
|
|
46
|
+
0.95,
|
|
47
|
+
EXTRACT(EPOCH FROM (
|
|
48
|
+
NOW() - COALESCE(warden_sessions.last_seen, NOW())
|
|
49
|
+
)) / 3600.0
|
|
50
|
+
)
|
|
51
|
+
) + EXCLUDED.score
|
|
52
|
+
)),
|
|
53
|
+
last_seen = NOW()
|
|
54
|
+
RETURNING score AS new_score
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
const result = await db.query(query, [session_hash, score]);
|
|
58
|
+
return result.rows[0];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { upsertReputation, upsertSession };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const { pool } = require('../db_connection/db');
|
|
3
|
+
const { createClient } = require('redis');
|
|
4
|
+
const { warden } = require('../middleware/warden');
|
|
5
|
+
const { syncVerifiedBots } = require('../verified-bots/sync');
|
|
6
|
+
const { reloadRanges } = require('../verified-bots/matcher');
|
|
7
|
+
|
|
8
|
+
// Example/demo server wiring up Warden with PostgreSQL + Redis.
|
|
9
|
+
// Run with: CONNECTION_STRING=postgres://... node node_modules/warden/app/demo/server.js
|
|
10
|
+
async function main() {
|
|
11
|
+
const app = express();
|
|
12
|
+
app.set('trust proxy', 1);
|
|
13
|
+
|
|
14
|
+
let redis = null;
|
|
15
|
+
try {
|
|
16
|
+
redis = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' });
|
|
17
|
+
redis.on('error', (err) => console.error('[Redis]', err.message));
|
|
18
|
+
await redis.connect();
|
|
19
|
+
} catch (err) {
|
|
20
|
+
console.warn('[Redis] not available, falling back to in-memory store');
|
|
21
|
+
redis = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
app.use(warden({
|
|
25
|
+
db: pool,
|
|
26
|
+
redis,
|
|
27
|
+
allowlist: [],
|
|
28
|
+
failopen: true,
|
|
29
|
+
scorethreshold: 30,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
app.get('/', (req, res) => {
|
|
33
|
+
res.json({ message: 'hello' });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const success = await syncVerifiedBots();
|
|
38
|
+
if (success) reloadRanges();
|
|
39
|
+
setInterval(async () => {
|
|
40
|
+
const refreshed = await syncVerifiedBots();
|
|
41
|
+
if (refreshed) reloadRanges();
|
|
42
|
+
}, 24 * 60 * 60 * 1000).unref();
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.warn('[verified-bots sync] skipped:', err.message);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const port = process.env.PORT || 3000;
|
|
48
|
+
app.listen(port, () => {
|
|
49
|
+
console.log(`Server running on port ${port}`);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
main().catch((err) => {
|
|
54
|
+
console.error('Fatal startup error:', err);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
function pickFirst(list) {
|
|
2
|
+
if (!list) return null;
|
|
3
|
+
const parts = String(list).split(',');
|
|
4
|
+
for (const part of parts) {
|
|
5
|
+
const trimmed = part.trim();
|
|
6
|
+
if (trimmed) return trimmed;
|
|
7
|
+
}
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the client IP for a request.
|
|
13
|
+
*
|
|
14
|
+
* Security: by default we trust ONLY req.ip, which Express populates
|
|
15
|
+
* from req.socket.remoteAddress unless app.set('trust proxy', N) is configured.
|
|
16
|
+
* Manually parsing X-Forwarded-For / X-Real-IP / CF-Connecting-IP would let
|
|
17
|
+
* attackers spoof IPs (e.g. "X-Forwarded-For: 8.8.8.8" via Postman) and
|
|
18
|
+
* bypass rate limits or get legitimate services banned.
|
|
19
|
+
*
|
|
20
|
+
* Pass trustHeaders: true ONLY if you've already configured
|
|
21
|
+
* app.set('trust proxy', N) with a specific hop count. Warden will not
|
|
22
|
+
* validate that configuration for you.
|
|
23
|
+
*/
|
|
24
|
+
function getClientIP(req, { trustHeaders = false } = {}) {
|
|
25
|
+
if (!req) return null;
|
|
26
|
+
|
|
27
|
+
if (trustHeaders) {
|
|
28
|
+
const headers = req.headers || {};
|
|
29
|
+
|
|
30
|
+
const xff = pickFirst(headers['x-forwarded-for']);
|
|
31
|
+
if (xff) return xff;
|
|
32
|
+
|
|
33
|
+
const xri = pickFirst(headers['x-real-ip']);
|
|
34
|
+
if (xri) return xri;
|
|
35
|
+
|
|
36
|
+
const cf = pickFirst(headers['cf-connecting-ip']);
|
|
37
|
+
if (cf) return cf;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (typeof req.ip === 'string' && req.ip.trim()) {
|
|
41
|
+
return req.ip.trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (req.socket && typeof req.socket.remoteAddress === 'string') {
|
|
45
|
+
return req.socket.remoteAddress.trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { getClientIP };
|
package/app/hashIP.js
ADDED
package/app/honeypot.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
function checkHoneypot(req) {
|
|
2
|
+
const honeypots = [
|
|
3
|
+
'wp-admin', 'wp-login.php', 'wp-config.php',
|
|
4
|
+
'phpmyadmin', 'pma',
|
|
5
|
+
'shell.php', 'cmd.php', 'exec.php', 'backdoor', 'webshell',
|
|
6
|
+
'phpinfo', 'phpinfo.php',
|
|
7
|
+
'env', 'git', 'htaccess', '.env', '.git', '.htaccess',
|
|
8
|
+
'config.php', 'web.config',
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const path = req.path.toLowerCase().replace(/\/+$/, '');
|
|
12
|
+
|
|
13
|
+
for (const honeypot of honeypots) {
|
|
14
|
+
if (path === `/${honeypot}` || path === honeypot) {
|
|
15
|
+
return 30;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const pathSegments = path.split('/').filter(Boolean);
|
|
20
|
+
for (const segment of pathSegments) {
|
|
21
|
+
if (honeypots.includes(segment)) {
|
|
22
|
+
return 30;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { checkHoneypot };
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
const { computeScore } = require('../scoring/computeScore');
|
|
2
|
+
const { checkAllowlist } = require('../allowlist');
|
|
3
|
+
const { checkHoneypot } = require('../honeypot');
|
|
4
|
+
const { upsertReputation, upsertSession } = require('../db_connection/queries');
|
|
5
|
+
const { getLimit, isRateLimited, DEFAULTS } = require('../ratelimit');
|
|
6
|
+
const { log } = require('../Loger');
|
|
7
|
+
const { trackFailure } = require('../bruteforce');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
const { computeSessionScore } = require('../scoring/computeSessionScore');
|
|
10
|
+
const { isVerifiedBot } = require('../verified-bots/matcher');
|
|
11
|
+
const { store } = require('../store');
|
|
12
|
+
const verifiedBotsSync = require('../verified-bots/sync');
|
|
13
|
+
|
|
14
|
+
function logInternalError(message, err, meta = {}) {
|
|
15
|
+
const details = {
|
|
16
|
+
...meta,
|
|
17
|
+
error: err instanceof Error ? err.message : String(err),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
if (err instanceof Error && err.stack) {
|
|
21
|
+
details.stack = err.stack;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
console.error(`[warden] ${message}:`, details);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function safeCall(hookName, fn, ...args) {
|
|
28
|
+
if (typeof hookName === 'function') {
|
|
29
|
+
args = [fn, ...args];
|
|
30
|
+
fn = hookName;
|
|
31
|
+
hookName = 'anonymous';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (typeof fn !== 'function') return;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const result = fn(...args);
|
|
38
|
+
if (result && typeof result.then === 'function') {
|
|
39
|
+
return result.catch((err) => {
|
|
40
|
+
logInternalError(`hook "${hookName}" failed`, err);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return result;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
logInternalError(`hook "${hookName}" failed`, err);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getSafeTier(score, limits) {
|
|
51
|
+
const tier = getLimit(score, limits) || getLimit(score, DEFAULTS) || DEFAULTS.clean;
|
|
52
|
+
|
|
53
|
+
if (
|
|
54
|
+
!tier ||
|
|
55
|
+
!Number.isFinite(tier.requests) ||
|
|
56
|
+
!Number.isFinite(tier.windowSeconds) ||
|
|
57
|
+
tier.requests <= 0 ||
|
|
58
|
+
tier.windowSeconds <= 0
|
|
59
|
+
) {
|
|
60
|
+
return DEFAULTS.clean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return tier;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function withFallback(taskName, task, fallbackValue, meta = {}) {
|
|
67
|
+
try {
|
|
68
|
+
return await task();
|
|
69
|
+
} catch (err) {
|
|
70
|
+
logInternalError(taskName, err, meta);
|
|
71
|
+
return fallbackValue;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function setHeaderSafe(res, name, value) {
|
|
76
|
+
if (!res || res.headersSent) return;
|
|
77
|
+
if (typeof res.set === 'function') {
|
|
78
|
+
res.set(name, value);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (typeof res.setHeader === 'function') {
|
|
83
|
+
res.setHeader(name, value);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function replySafe(res, statusCode, body) {
|
|
88
|
+
if (!res || res.headersSent) return;
|
|
89
|
+
|
|
90
|
+
if (typeof res.status === 'function') {
|
|
91
|
+
res.status(statusCode);
|
|
92
|
+
} else {
|
|
93
|
+
res.statusCode = statusCode;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (typeof res.json === 'function') {
|
|
97
|
+
return res.json(body);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (typeof res.send === 'function') {
|
|
101
|
+
return res.send(body);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (typeof res.end === 'function') {
|
|
105
|
+
const payload = typeof body === 'string' ? body : JSON.stringify(body);
|
|
106
|
+
return res.end(payload);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function warden({
|
|
111
|
+
db = null,
|
|
112
|
+
redis = null,
|
|
113
|
+
allowlist = [],
|
|
114
|
+
failopen = true,
|
|
115
|
+
scorethreshold = 30,
|
|
116
|
+
limits = {},
|
|
117
|
+
onBlock = null,
|
|
118
|
+
knownSubdomains = [],
|
|
119
|
+
onSuspicious = null,
|
|
120
|
+
onRateLimit = null,
|
|
121
|
+
suspiciousThreshold = 30,
|
|
122
|
+
trustHeaders = false,
|
|
123
|
+
getSessionId = null,
|
|
124
|
+
} = {}) {
|
|
125
|
+
const BRUTETHRESHOLD=7
|
|
126
|
+
const mergedLimits = {
|
|
127
|
+
clean: { ...DEFAULTS.clean, ...(limits.clean || {}) },
|
|
128
|
+
suspicious: { ...DEFAULTS.suspicious, ...(limits.suspicious || {}) },
|
|
129
|
+
hostile: { ...DEFAULTS.hostile, ...(limits.hostile || {}) },
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (redis && typeof redis === 'object' && typeof store.useRedis === 'function') {
|
|
133
|
+
store.useRedis(redis);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (redis && typeof redis.set === 'function') {
|
|
137
|
+
verifiedBotsSync.setExternalStore(redis);
|
|
138
|
+
} else if (db && typeof db.query === 'function') {
|
|
139
|
+
verifiedBotsSync.setExternalStore(db);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return async function(req, res, next) {
|
|
143
|
+
const reqId = crypto.randomUUID();
|
|
144
|
+
const start = Date.now();
|
|
145
|
+
const path = req?.path || req?.originalUrl || req?.url || 'unknown';
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
if (checkAllowlist(req, allowlist)) return next();
|
|
149
|
+
|
|
150
|
+
const computed = computeScore(req, knownSubdomains, { trustHeaders }) || {};
|
|
151
|
+
const ip_hash = typeof computed.ip_hash === 'string' && computed.ip_hash ? computed.ip_hash : 'unknown';
|
|
152
|
+
const score = Number.isFinite(computed.score) ? computed.score : 0;
|
|
153
|
+
const raw_ip = computed.raw_ip;
|
|
154
|
+
|
|
155
|
+
if (raw_ip && isVerifiedBot(raw_ip)) return next();
|
|
156
|
+
const honeypotScore = checkHoneypot(req);
|
|
157
|
+
const totalScore = score + honeypotScore;
|
|
158
|
+
const action = totalScore >= scorethreshold ? 'blocked' : 'allowed';
|
|
159
|
+
const reason = honeypotScore > 0 ? 'honeypot' : 'score';
|
|
160
|
+
|
|
161
|
+
if (typeof res?.on === 'function') {
|
|
162
|
+
res.on('finish', () => {
|
|
163
|
+
(async () => {
|
|
164
|
+
try {
|
|
165
|
+
if (![400, 401, 404].includes(res.statusCode)) return;
|
|
166
|
+
if (res.statusCode === 404) {
|
|
167
|
+
const isStaticAsset = /\.(png|jpg|jpeg|gif|ico|css|js|woff|woff2|ttf|svg|map)$/i.test(path);
|
|
168
|
+
if (isStaticAsset) return; // Let it go, it's just a broken webpage
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const count = await withFallback(
|
|
172
|
+
'post-response brute-force tracking failed',
|
|
173
|
+
() => trackFailure(ip_hash),
|
|
174
|
+
0,
|
|
175
|
+
{ reqId, ip_hash, path }
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (count < BRUTETHRESHOLD) return;
|
|
179
|
+
|
|
180
|
+
await withFallback(
|
|
181
|
+
'post-response brute-force persistence failed',
|
|
182
|
+
() => upsertReputation(db, {
|
|
183
|
+
ip_hash,
|
|
184
|
+
score: 100,
|
|
185
|
+
path,
|
|
186
|
+
action: 'blocked',
|
|
187
|
+
reason: 'bruteforce'
|
|
188
|
+
}),
|
|
189
|
+
null,
|
|
190
|
+
{ reqId, ip_hash, path }
|
|
191
|
+
);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
logInternalError('post-response handler crashed', err, { reqId, ip_hash, path });
|
|
194
|
+
}
|
|
195
|
+
})();
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const session_hash = getSessionId ? computeSessionScore(req, getSessionId) : null;
|
|
200
|
+
|
|
201
|
+
const shouldPersistScore = totalScore > 0;
|
|
202
|
+
const [reputationResult, sessionResult] = await Promise.all([
|
|
203
|
+
shouldPersistScore
|
|
204
|
+
? withFallback(
|
|
205
|
+
'reputation persistence failed',
|
|
206
|
+
() => upsertReputation(db, {
|
|
207
|
+
ip_hash,
|
|
208
|
+
score: totalScore,
|
|
209
|
+
path,
|
|
210
|
+
action,
|
|
211
|
+
reason,
|
|
212
|
+
}),
|
|
213
|
+
null,
|
|
214
|
+
{ reqId, ip_hash, path }
|
|
215
|
+
)
|
|
216
|
+
: Promise.resolve(null),
|
|
217
|
+
shouldPersistScore && session_hash
|
|
218
|
+
? withFallback(
|
|
219
|
+
'session persistence failed',
|
|
220
|
+
() => upsertSession(db, session_hash, totalScore),
|
|
221
|
+
null,
|
|
222
|
+
{ reqId, ip_hash, path, session_hash }
|
|
223
|
+
)
|
|
224
|
+
: Promise.resolve(null)
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
const new_score = Number.isFinite(reputationResult?.new_score)
|
|
228
|
+
? reputationResult.new_score
|
|
229
|
+
: Math.max(0, totalScore);
|
|
230
|
+
const session_new_score = Number.isFinite(sessionResult?.new_score)
|
|
231
|
+
? sessionResult.new_score
|
|
232
|
+
: null;
|
|
233
|
+
const finalScore = session_new_score !== null
|
|
234
|
+
? Math.max(new_score, session_new_score)
|
|
235
|
+
: new_score;
|
|
236
|
+
const tier = getSafeTier(finalScore, mergedLimits);
|
|
237
|
+
const rateLimitKey = `rl:${ip_hash}:${tier.windowSeconds}:${tier.requests}`;
|
|
238
|
+
const window_requests = await withFallback(
|
|
239
|
+
'rate limit store increment failed',
|
|
240
|
+
() => store.increment(rateLimitKey, tier.windowSeconds),
|
|
241
|
+
0,
|
|
242
|
+
{ reqId, ip_hash, path, rateLimitKey }
|
|
243
|
+
);
|
|
244
|
+
setHeaderSafe(res, 'X-RateLimit-Limit', tier.requests);
|
|
245
|
+
setHeaderSafe(res, 'X-RateLimit-Remaining', Math.max(0, tier.requests - window_requests));
|
|
246
|
+
setHeaderSafe(res, 'X-RateLimit-Reset', Math.floor(Date.now() / 1000) + tier.windowSeconds);
|
|
247
|
+
|
|
248
|
+
if (isRateLimited(finalScore, window_requests, mergedLimits)) {
|
|
249
|
+
setHeaderSafe(res, 'Retry-After', tier.windowSeconds);
|
|
250
|
+
log({ reqId, ip_hash, score: finalScore, raw_score: totalScore, action: 'blocked', reason: 'rate_limit', path, duration_ms: Date.now() - start });
|
|
251
|
+
safeCall('onRateLimit', onRateLimit, ip_hash, finalScore, req);
|
|
252
|
+
return replySafe(res, 429, { error: 'Too many requests' });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (finalScore >= scorethreshold) {
|
|
256
|
+
log({ reqId, ip_hash, score: finalScore, raw_score: totalScore, action: 'blocked', reason, path, duration_ms: Date.now() - start });
|
|
257
|
+
safeCall('onBlock', onBlock, ip_hash, finalScore, reason, req);
|
|
258
|
+
return replySafe(res, 403, { error: 'forbidden' });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
log({ reqId, ip_hash, score: finalScore, raw_score: totalScore, action: 'allowed', reason, path, duration_ms: Date.now() - start });
|
|
262
|
+
if (finalScore > suspiciousThreshold) {
|
|
263
|
+
safeCall('onSuspicious', onSuspicious, ip_hash, finalScore, req);
|
|
264
|
+
}
|
|
265
|
+
return next();
|
|
266
|
+
|
|
267
|
+
} catch (err) {
|
|
268
|
+
logInternalError('middleware execution failed', err, { reqId, path });
|
|
269
|
+
if (failopen === true) return next();
|
|
270
|
+
if (res?.headersSent && typeof next === 'function') return next(err);
|
|
271
|
+
return replySafe(res, 500, { error: 'internal server error' });
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
module.exports = { warden, safeCall, logInternalError, replySafe, setHeaderSafe, getSafeTier };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const { Address6 } = require('ip-address');
|
|
2
|
+
const net = require('net');
|
|
3
|
+
|
|
4
|
+
function normalizeIP(input) {
|
|
5
|
+
if (!input) return null;
|
|
6
|
+
|
|
7
|
+
const ipVersion = net.isIP(input);
|
|
8
|
+
if (ipVersion === 4) {
|
|
9
|
+
return input;
|
|
10
|
+
} else if (ipVersion === 6) {
|
|
11
|
+
const addr = new Address6(input);
|
|
12
|
+
const sections = addr.canonicalForm().split(':');
|
|
13
|
+
return sections.slice(0, 4).join(':') + '::/64';
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { normalizeIP };
|
package/app/ratelimit.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
|
|
2
|
+
const DEFAULTS = {
|
|
3
|
+
clean: { requests: 60, windowSeconds: 60 },
|
|
4
|
+
suspicious: { requests: 20, windowSeconds: 60 },
|
|
5
|
+
hostile: { requests: 5, windowSeconds: 30 }
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function getLimit(score, limits=DEFAULTS) {
|
|
9
|
+
if (score > 60) return limits.hostile;
|
|
10
|
+
if (score > 30) return limits.suspicious;
|
|
11
|
+
return limits.clean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isRateLimited(score, windowRequests, customLimits = {}) {
|
|
15
|
+
return windowRequests > getLimit(score, customLimits).requests;
|
|
16
|
+
}
|
|
17
|
+
module.exports={getLimit,isRateLimited,DEFAULTS}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
function scoreSubdomain(req, knownSubdomains = []) {
|
|
2
|
+
const host = (req.headers?.host || req.hostname || '').toLowerCase();
|
|
3
|
+
if (!host || !host.includes('.')) return 0;
|
|
4
|
+
|
|
5
|
+
const cleanHost = host.split(':')[0];
|
|
6
|
+
const parts = cleanHost.split('.');
|
|
7
|
+
if (parts.length < 3) return 0;
|
|
8
|
+
|
|
9
|
+
const subdomains = parts.slice(0, -2);
|
|
10
|
+
const whitelist = new Set(knownSubdomains.map(s => s.toLowerCase()));
|
|
11
|
+
|
|
12
|
+
// 60 points — no legitimate user hits these, pure recon/attack surface
|
|
13
|
+
const highSensitive = new Set([
|
|
14
|
+
// admin panels
|
|
15
|
+
'admin', 'administrator', 'panel', 'control', 'manage', 'manager',
|
|
16
|
+
'phpmyadmin', 'pma', 'adminer', 'myadmin', 'dbadmin', 'sqladmin',
|
|
17
|
+
'cpanel', 'whm', 'plesk', 'directadmin', 'virtualmin', 'webmin',
|
|
18
|
+
// remote access
|
|
19
|
+
'ssh', 'rdp', 'vnc', 'telnet', 'bastion', 'console', 'remote', 'access',
|
|
20
|
+
// secrets
|
|
21
|
+
'vault', 'secrets', 'cred', 'credentials', 'password', 'passwords',
|
|
22
|
+
// backups
|
|
23
|
+
'backup', 'bak', 'old', 'restore', 'archive',
|
|
24
|
+
// debug
|
|
25
|
+
'phpinfo', 'debug', 'debugger', 'devmode',
|
|
26
|
+
// database admin
|
|
27
|
+
'dba',
|
|
28
|
+
// shell
|
|
29
|
+
'shell', 'cmd', 'exec',
|
|
30
|
+
// internal
|
|
31
|
+
'intranet', 'internal', 'inside', 'private', 'secret', 'confidential',
|
|
32
|
+
'corp', 'corporate',
|
|
33
|
+
// vpn/tunnel
|
|
34
|
+
'vpn', 'tunnel', 'gateway',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// 15 points — common but signals reconnaissance
|
|
38
|
+
const medSensitive = new Set([
|
|
39
|
+
// dev environments
|
|
40
|
+
'dev', 'development', 'staging', 'stage', 'test', 'testing',
|
|
41
|
+
'beta', 'alpha', 'demo', 'uat', 'qa', 'sandbox', 'mock',
|
|
42
|
+
'preprod', 'pre-prod', 'preview',
|
|
43
|
+
// versioning
|
|
44
|
+
'v1', 'v2', 'v3', 'v4', 'version',
|
|
45
|
+
// apis
|
|
46
|
+
'api', 'graphql', 'gql', 'rest', 'soap', 'rpc',
|
|
47
|
+
'swagger', 'openapi', 'api-docs', 'playground',
|
|
48
|
+
// ci/cd
|
|
49
|
+
'jenkins', 'ci', 'cd', 'build', 'deploy', 'release', 'pipeline',
|
|
50
|
+
'travis', 'circleci', 'github', 'gitlab', 'bitbucket', 'git', 'svn',
|
|
51
|
+
// monitoring
|
|
52
|
+
'grafana', 'prometheus', 'kibana', 'monitor', 'monitoring',
|
|
53
|
+
'nagios', 'zabbix', 'datadog', 'newrelic', 'splunk',
|
|
54
|
+
'logs', 'log', 'logging', 'trace', 'tracing', 'metrics',
|
|
55
|
+
'jaeger', 'zipkin', 'otel',
|
|
56
|
+
// databases
|
|
57
|
+
'db', 'database', 'sql', 'mysql', 'postgres', 'postgresql',
|
|
58
|
+
'mongo', 'mongodb', 'redis', 'cassandra', 'oracle', 'elastic',
|
|
59
|
+
// auth
|
|
60
|
+
'auth', 'sso', 'oauth', 'saml', 'ldap', 'login', 'signin',
|
|
61
|
+
// infra
|
|
62
|
+
'k8s', 'kubernetes', 'kube', 'docker', 'rancher', 'portainer',
|
|
63
|
+
'consul', 'nomad',
|
|
64
|
+
// mail
|
|
65
|
+
'mail', 'email', 'webmail', 'smtp',
|
|
66
|
+
// docs
|
|
67
|
+
'wiki', 'docs', 'confluence', 'jira',
|
|
68
|
+
// misc
|
|
69
|
+
'status', 'health', 'ping', 'probe',
|
|
70
|
+
'ws', 'socket', 'realtime',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
let score = 0;
|
|
74
|
+
|
|
75
|
+
for (const sub of subdomains) {
|
|
76
|
+
if (whitelist.has(sub)) continue;
|
|
77
|
+
if (highSensitive.has(sub)) {
|
|
78
|
+
score += 60;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (medSensitive.has(sub)) {
|
|
82
|
+
score += 15;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return Math.min(score, 100);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { scoreSubdomain };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const { getClientIP } = require('../getClientIP');
|
|
2
|
+
const { hashIP } = require('../hashIP');
|
|
3
|
+
const { normalizeIP } = require('../normalizeIP');
|
|
4
|
+
const { scoreFromHeaders } = require('./scoreFromHeaders');
|
|
5
|
+
const {scoreSubdomain}=require('./SubDomainScore')
|
|
6
|
+
function computeScore(req, knownSubdomains, options = {}) {
|
|
7
|
+
const ip = getClientIP(req, options);
|
|
8
|
+
const normalip = normalizeIP(ip);
|
|
9
|
+
|
|
10
|
+
if (!normalip) {
|
|
11
|
+
return {
|
|
12
|
+
ip_hash: 'unknown',
|
|
13
|
+
score: 60,
|
|
14
|
+
raw_ip: null
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const haship = hashIP(normalip);
|
|
19
|
+
const headerScore = scoreFromHeaders(req);
|
|
20
|
+
const scoreSub = scoreSubdomain(req,knownSubdomains)
|
|
21
|
+
return {
|
|
22
|
+
ip_hash: haship,
|
|
23
|
+
score: headerScore+scoreSub,
|
|
24
|
+
raw_ip: normalip
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { computeScore };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
function hashSession(session) {
|
|
4
|
+
return crypto.createHash('sha256').update(String(session)).digest('hex');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function extractBearerToken(req) {
|
|
8
|
+
const auth = req.headers?.authorization
|
|
9
|
+
if (!auth) return null;
|
|
10
|
+
const parts = String(auth).split(' ');
|
|
11
|
+
if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') {
|
|
12
|
+
return parts[1];
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function extractCookie(req, name) {
|
|
18
|
+
if (req.cookies && req.cookies[name]) {
|
|
19
|
+
return req.cookies[name];
|
|
20
|
+
}
|
|
21
|
+
if (req.headers?.cookie) {
|
|
22
|
+
const match = req.headers.cookie.match(
|
|
23
|
+
new RegExp(`(?:^|;\\s*)${name}=([^;]+)`)
|
|
24
|
+
);
|
|
25
|
+
if (match) return match[1];
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getDefaultSessionId(req) {
|
|
31
|
+
// 1. Try connect.sid cookie first
|
|
32
|
+
const sid = extractCookie(req, 'connect.sid');
|
|
33
|
+
if (sid) return sid;
|
|
34
|
+
|
|
35
|
+
// 2. Try Authorization bearer second
|
|
36
|
+
const bearer = extractBearerToken(req);
|
|
37
|
+
if (bearer) return bearer;
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function computeSessionScore(req, getSessionId) {
|
|
43
|
+
const sessionId = typeof getSessionId === 'function'
|
|
44
|
+
? getSessionId(req)
|
|
45
|
+
: getDefaultSessionId(req);
|
|
46
|
+
|
|
47
|
+
if (!sessionId) return null;
|
|
48
|
+
|
|
49
|
+
return hashSession(sessionId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { computeSessionScore, hashSession };
|