roster-server 2.4.8 → 2.4.11
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
CHANGED
|
@@ -126,11 +126,6 @@ import { createScannerBlocker } from 'roster-server/plugins/scanner-blocker.js';
|
|
|
126
126
|
const roster = new Roster(options);
|
|
127
127
|
|
|
128
128
|
roster.use(createScannerBlocker({
|
|
129
|
-
windowMs: 60_000,
|
|
130
|
-
strikeThreshold: 3,
|
|
131
|
-
banDurationMs: 15 * 60_000,
|
|
132
|
-
maxTrackedClients: 10_000,
|
|
133
|
-
trustProxy: false,
|
|
134
129
|
onBlock(event) {
|
|
135
130
|
// Send event to the application's existing logger if desired.
|
|
136
131
|
}
|
|
@@ -139,7 +134,7 @@ roster.use(createScannerBlocker({
|
|
|
139
134
|
roster.start();
|
|
140
135
|
```
|
|
141
136
|
|
|
142
|
-
|
|
137
|
+
By default, the plugin uses a 60-second strike window, 3 strikes, a 15-minute ban, tracks up to 10,000 clients, and does not trust proxy headers. Pass only the values you need to override. Keep `trustProxy: false` when RosterServer receives traffic directly. Set it to `true` only when a trusted reverse proxy overwrites `X-Forwarded-For`; otherwise clients can spoof the address used for bans.
|
|
143
138
|
|
|
144
139
|
The in-memory strike and ban state is bounded by `maxTrackedClients`, belongs to one RosterServer process, and is cleared on restart. Use the optional `onBlock(event)` callback to feed a shared firewall or Fail2ban when bans must persist or span multiple workers. Routes such as `/atom` and `/articles/config` are not classified as scanner probes.
|
|
145
140
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roster-server",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.11",
|
|
4
4
|
"description": "👾 RosterServer - A domain host router to host multiple HTTPS.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"repository": {
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"@greenlock/manager": "^3.1.0",
|
|
37
37
|
"@root/acme": "^3.1.0",
|
|
38
38
|
"@root/csr": "^0.8.1",
|
|
39
|
+
"@root/encoding": "^1.0.1",
|
|
39
40
|
"@root/keypairs": "^0.10.0",
|
|
40
41
|
"@root/mkdirp": "^1.0.0",
|
|
41
42
|
"@root/request": "^1.6.1",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const PHP_PATH = /(?:^|\/)[^/]*\.php(?:$|\/)/i;
|
|
4
4
|
const SCANNER_PATH = /(?:^|\/)(?:\.git|\.hg|\.svn|\.ssh|\.aws|cgi-bin|server-status|vendor\/phpunit|wp-admin|wp-content|wp-includes|wp-json)(?:\/|$)/i;
|
|
5
|
-
const SCANNER_FILE = /(?:^|\/)(?:\.env(?:\.[^/]*)?|\.htaccess|composer\.(?:json|lock)|web\.config)(?:\/|$)/i;
|
|
5
|
+
const SCANNER_FILE = /(?:^|\/)(?:\.env(?:\.[^/]*)?|\.htaccess|composer\.(?:json|lock)|security\.txt|web\.config)(?:\/|$)/i;
|
|
6
6
|
|
|
7
7
|
function requirePositiveInteger(options, name) {
|
|
8
8
|
const value = options[name];
|
|
@@ -61,28 +61,36 @@ function rejectRequest(req, res) {
|
|
|
61
61
|
res.end(req.method === 'HEAD' ? undefined : body);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
function createScannerBlocker(options) {
|
|
64
|
+
function createScannerBlocker(options = {}) {
|
|
65
65
|
if (!options || typeof options !== 'object') {
|
|
66
|
-
throw new Error('scanner-blocker options
|
|
66
|
+
throw new Error('scanner-blocker options must be an object');
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
const {
|
|
70
|
+
windowMs = 60_000,
|
|
71
|
+
strikeThreshold = 3,
|
|
72
|
+
banDurationMs = 15 * 60_000,
|
|
73
|
+
maxTrackedClients = 10_000,
|
|
74
|
+
trustProxy = false,
|
|
75
|
+
onBlock,
|
|
76
|
+
now
|
|
77
|
+
} = options;
|
|
78
|
+
const normalizedOptions = { windowMs, strikeThreshold, banDurationMs, maxTrackedClients };
|
|
79
|
+
requirePositiveInteger(normalizedOptions, 'windowMs');
|
|
80
|
+
requirePositiveInteger(normalizedOptions, 'strikeThreshold');
|
|
81
|
+
requirePositiveInteger(normalizedOptions, 'banDurationMs');
|
|
82
|
+
requirePositiveInteger(normalizedOptions, 'maxTrackedClients');
|
|
83
|
+
if (typeof trustProxy !== 'boolean') {
|
|
74
84
|
throw new Error('trustProxy must be a boolean');
|
|
75
85
|
}
|
|
76
|
-
if (
|
|
86
|
+
if (onBlock !== undefined && typeof onBlock !== 'function') {
|
|
77
87
|
throw new Error('onBlock must be a function');
|
|
78
88
|
}
|
|
79
|
-
if (
|
|
89
|
+
if (now !== undefined && typeof now !== 'function') {
|
|
80
90
|
throw new Error('now must be a function');
|
|
81
91
|
}
|
|
82
92
|
|
|
83
|
-
const
|
|
84
|
-
const onBlock = options.onBlock;
|
|
85
|
-
const now = options.now || Date.now;
|
|
93
|
+
const clock = now || Date.now;
|
|
86
94
|
const clients = new Map();
|
|
87
95
|
|
|
88
96
|
function removeExpiredClients(timestamp) {
|
|
@@ -109,7 +117,7 @@ function createScannerBlocker(options) {
|
|
|
109
117
|
}
|
|
110
118
|
|
|
111
119
|
return function scannerBlocker(req, res, context = {}) {
|
|
112
|
-
const timestamp =
|
|
120
|
+
const timestamp = clock();
|
|
113
121
|
const ip = clientIp(req, trustProxy);
|
|
114
122
|
let state = ip ? clients.get(ip) : null;
|
|
115
123
|
|
|
@@ -178,17 +178,11 @@ const Roster = require('roster-server');
|
|
|
178
178
|
const { createScannerBlocker } = require('roster-server/plugins/scanner-blocker.js');
|
|
179
179
|
|
|
180
180
|
const roster = new Roster({ local: true, wwwPath: './www' });
|
|
181
|
-
roster.use(createScannerBlocker(
|
|
182
|
-
windowMs: 60_000,
|
|
183
|
-
strikeThreshold: 3,
|
|
184
|
-
banDurationMs: 15 * 60_000,
|
|
185
|
-
maxTrackedClients: 10_000,
|
|
186
|
-
trustProxy: false
|
|
187
|
-
}));
|
|
181
|
+
roster.use(createScannerBlocker());
|
|
188
182
|
roster.start();
|
|
189
183
|
```
|
|
190
184
|
|
|
191
|
-
The plugin blocks common PHP, WordPress, repository, and sensitive-file probes before site handlers.
|
|
185
|
+
The plugin blocks common PHP, WordPress, repository, and sensitive-file probes before site handlers. Defaults are a 60-second window, 3 strikes, a 15-minute ban, 10,000 tracked clients, and `trustProxy: false`; pass only the values to override. Set `trustProxy: true` only behind a trusted reverse proxy that overwrites `X-Forwarded-For`. Ban state is per process and in memory; use `onBlock` to integrate a shared firewall or Fail2ban.
|
|
192
186
|
|
|
193
187
|
## Key Configuration Options
|
|
194
188
|
|
|
@@ -8,6 +8,7 @@ const http = require('http');
|
|
|
8
8
|
const os = require('os');
|
|
9
9
|
const Roster = require('../index.js');
|
|
10
10
|
const { createScannerBlocker } = require('../plugins/scanner-blocker.js');
|
|
11
|
+
const HttpsMiddleware = require('../vendor/greenlock-express/https-middleware.js');
|
|
11
12
|
const {
|
|
12
13
|
wildcardRoot,
|
|
13
14
|
hostMatchesWildcard,
|
|
@@ -40,6 +41,37 @@ function httpGet(host, port, pathname = '/') {
|
|
|
40
41
|
});
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
describe('vendored HTTPS hostname middleware', () => {
|
|
45
|
+
function invoke(host) {
|
|
46
|
+
let appCalled = false;
|
|
47
|
+
let body;
|
|
48
|
+
const req = { headers: { host }, socket: {} };
|
|
49
|
+
const res = {
|
|
50
|
+
end(value) {
|
|
51
|
+
body = value;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
HttpsMiddleware.create({}, () => {
|
|
55
|
+
appCalled = true;
|
|
56
|
+
})(req, res);
|
|
57
|
+
return { appCalled, body, req, res };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
it('accepts a valid Host header with a numeric port', () => {
|
|
61
|
+
const result = invoke('example.com:8880');
|
|
62
|
+
assert.strictEqual(result.appCalled, true);
|
|
63
|
+
assert.strictEqual(result.res.statusCode, undefined);
|
|
64
|
+
assert.strictEqual(result.req.headers.host, 'example.com');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('rejects a Host header with a non-numeric port', () => {
|
|
68
|
+
const result = invoke('example.com:invalid');
|
|
69
|
+
assert.strictEqual(result.appCalled, false);
|
|
70
|
+
assert.strictEqual(result.res.statusCode, 400);
|
|
71
|
+
assert.match(result.body, /Malformed HTTP Header/);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
43
75
|
describe('wildcardRoot', () => {
|
|
44
76
|
it('returns root domain for *.example.com', () => {
|
|
45
77
|
assert.strictEqual(wildcardRoot('*.example.com'), 'example.com');
|
|
@@ -40,10 +40,20 @@ function blocker(overrides = {}) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
describe('scanner-blocker plugin', () => {
|
|
43
|
-
it('
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
it('uses documented defaults and permits individual overrides', () => {
|
|
44
|
+
const plugin = createScannerBlocker();
|
|
45
|
+
invoke(plugin, { url: '/wp-login.php' });
|
|
46
|
+
invoke(plugin, { url: '/xmlrpc.php' });
|
|
47
|
+
assert.strictEqual(invoke(plugin, { url: '/ordinary' }).handled, false);
|
|
48
|
+
invoke(plugin, { url: '/install.php' });
|
|
49
|
+
assert.strictEqual(invoke(plugin, { url: '/ordinary' }).handled, true);
|
|
50
|
+
|
|
51
|
+
const overriddenPlugin = createScannerBlocker({ strikeThreshold: 1 });
|
|
52
|
+
invoke(overriddenPlugin, { url: '/wp-login.php' });
|
|
53
|
+
assert.strictEqual(invoke(overriddenPlugin, { url: '/ordinary' }).handled, true);
|
|
54
|
+
|
|
55
|
+
assert.throws(() => createScannerBlocker(null), /options must be an object/);
|
|
56
|
+
assert.throws(() => blocker({ trustProxy: 'false' }), /trustProxy/);
|
|
47
57
|
assert.throws(() => blocker({ maxTrackedClients: 0 }), /maxTrackedClients/);
|
|
48
58
|
});
|
|
49
59
|
|
|
@@ -51,9 +61,11 @@ describe('scanner-blocker plugin', () => {
|
|
|
51
61
|
const urls = [
|
|
52
62
|
'/install.php',
|
|
53
63
|
'/user-new\\.php',
|
|
64
|
+
'/wp-login.php',
|
|
54
65
|
'/wp-json',
|
|
55
66
|
'/.git/config',
|
|
56
|
-
'/%252eenv'
|
|
67
|
+
'/%252eenv',
|
|
68
|
+
'/security.txt'
|
|
57
69
|
];
|
|
58
70
|
|
|
59
71
|
for (const [index, url] of urls.entries()) {
|
|
@@ -20,6 +20,7 @@ SanitizeHost.create = function(gl, app) {
|
|
|
20
20
|
var hostname = HttpMiddleware.getHostname(req);
|
|
21
21
|
// Replace the hostname, and get the safe version
|
|
22
22
|
var safehost = HttpMiddleware.sanitizeHostname(req);
|
|
23
|
+
var normalizedHostname = hostname.toLowerCase().replace(/:\d+$/, "");
|
|
23
24
|
|
|
24
25
|
// if no hostname, move along
|
|
25
26
|
if (!hostname) {
|
|
@@ -28,7 +29,7 @@ SanitizeHost.create = function(gl, app) {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
// if there were unallowed characters, complain
|
|
31
|
-
if (safehost
|
|
32
|
+
if (safehost !== normalizedHostname) {
|
|
32
33
|
res.statusCode = 400;
|
|
33
34
|
res.end("Malformed HTTP Header: 'Host: " + hostname + "'");
|
|
34
35
|
return;
|