roster-server 2.4.6 → 2.4.10

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
@@ -11,6 +11,7 @@ Welcome to **RosterServer**, the ultimate domain host router with automatic HTTP
11
11
  - **Static Sites**: No code? No problem. Drop a folder with `index.html` (and assets) and RosterServer serves it automatically—modular static handler with path-traversal protection and strict 404s.
12
12
  - **Virtual Hosting**: Serve multiple domains from a single server.
13
13
  - **Automatic Redirects**: Redirect `www` subdomains to the root domain.
14
+ - **Optional Request Plugins**: Run synchronous filters before site handlers, including the bundled scanner blocker.
14
15
  - **Zero Configuration**: Well, almost zero. Just a tiny bit of setup.
15
16
  - **Bun compatible**: Works with both Node.js and [Bun](https://bun.sh).
16
17
 
@@ -114,6 +115,29 @@ const server = new Roster(options);
114
115
  server.start();
115
116
  ```
116
117
 
118
+ ### Blocking vulnerability scanners
119
+
120
+ RosterServer includes an optional request plugin that rejects common PHP, WordPress, repository, and sensitive-file probes before they reach a site handler. Suspicious paths always receive a `404`; after the configured number of strikes, every request from that client is rejected until the ban expires.
121
+
122
+ ```javascript
123
+ import Roster from 'roster-server';
124
+ import { createScannerBlocker } from 'roster-server/plugins/scanner-blocker.js';
125
+
126
+ const roster = new Roster(options);
127
+
128
+ roster.use(createScannerBlocker({
129
+ onBlock(event) {
130
+ // Send event to the application's existing logger if desired.
131
+ }
132
+ }));
133
+
134
+ roster.start();
135
+ ```
136
+
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.
138
+
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.
140
+
117
141
  ### Your Site Handlers
118
142
 
119
143
  Each domain has its own folder under `www`. You can use:
@@ -426,6 +450,10 @@ Loads sites, generates SSL config (production), creates VirtualServers and initi
426
450
 
427
451
  Returns the Host-header dispatch function for a given port (defaults to `defaultPort`). Handles www→non-www redirects, wildcard matching, and VirtualServer dispatch.
428
452
 
453
+ #### `roster.use(plugin)` → `Roster`
454
+
455
+ Registers a synchronous request plugin and returns `this`. Plugins receive `(req, res, { host, domain })`; return `true` after sending a response to stop dispatch, or `false`/`undefined` to continue. Plugins run in registration order before redirects and site handlers.
456
+
429
457
  #### `roster.upgradeHandler(port?)` → `(req, socket, head) => void`
430
458
 
431
459
  Returns the WebSocket upgrade dispatcher for a given port. Routes upgrades to the correct VirtualServer.
@@ -493,4 +521,4 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
493
521
 
494
522
  ---
495
523
 
496
- Happy hosting! 🎈
524
+ Happy hosting! 🎈
package/index.js CHANGED
@@ -247,6 +247,7 @@ class Roster {
247
247
  this.local = options.local || false;
248
248
  this.domains = [];
249
249
  this.sites = {};
250
+ this.plugins = [];
250
251
  this.wildcardZones = new Set(); // Root domains that have a wildcard site (e.g. "example.com" for *.example.com)
251
252
  this.domainServers = {}; // Store separate servers for each domain
252
253
  this.portServers = {}; // Store servers by port
@@ -540,6 +541,11 @@ class Roster {
540
541
 
541
542
  handleRequest(req, res) {
542
543
  const host = req.headers.host || '';
544
+ const hostWithoutPort = host.split(':')[0];
545
+ const normalizedHost = hostWithoutPort.toLowerCase();
546
+ const domain = normalizedHost.startsWith('www.') ? normalizedHost.slice(4) : normalizedHost;
547
+
548
+ if (this._runRequestPlugins(req, res, { host: normalizedHost, domain })) return;
543
549
 
544
550
  if (host.startsWith('www.')) {
545
551
  const newHost = host.slice(4);
@@ -548,7 +554,6 @@ class Roster {
548
554
  return;
549
555
  }
550
556
 
551
- const hostWithoutPort = host.split(':')[0];
552
557
  const siteApp = this.getHandlerForHost(hostWithoutPort);
553
558
  if (siteApp) {
554
559
  siteApp(req, res);
@@ -597,6 +602,25 @@ class Roster {
597
602
  return this;
598
603
  }
599
604
 
605
+ use(plugin) {
606
+ if (typeof plugin !== 'function') {
607
+ throw new Error('plugin must be a function');
608
+ }
609
+ this.plugins.push(plugin);
610
+ return this;
611
+ }
612
+
613
+ _runRequestPlugins(req, res, context) {
614
+ for (const plugin of this.plugins) {
615
+ const handled = plugin(req, res, context);
616
+ if (handled && typeof handled.then === 'function') {
617
+ throw new Error('Request plugins must be synchronous');
618
+ }
619
+ if (handled === true) return true;
620
+ }
621
+ return false;
622
+ }
623
+
600
624
  parseDomainWithPort(domainString) {
601
625
  const parts = domainString.split(':');
602
626
  if (parts.length === 2) {
@@ -737,6 +761,8 @@ class Roster {
737
761
  const hostWithoutPort = host.split(':')[0].toLowerCase();
738
762
  const domain = hostWithoutPort.startsWith('www.') ? hostWithoutPort.slice(4) : hostWithoutPort;
739
763
 
764
+ if (this._runRequestPlugins(req, res, { host: hostWithoutPort, domain })) return;
765
+
740
766
  if (hostWithoutPort.startsWith('www.')) {
741
767
  const protocol = this.local ? 'http' : 'https';
742
768
  res.writeHead(301, { Location: `${protocol}://${domain}${req.url}` });
@@ -1062,6 +1088,8 @@ class Roster {
1062
1088
  const appHandler = portData.appHandlers[domain];
1063
1089
 
1064
1090
  const dispatcher = (req, res) => {
1091
+ const host = (req.headers.host || '').split(':')[0].toLowerCase();
1092
+ if (this._runRequestPlugins(req, res, { host, domain })) return;
1065
1093
  virtualServer.fallbackHandler = appHandler;
1066
1094
  if (virtualServer.requestListeners.length > 0) {
1067
1095
  virtualServer.processRequest(req, res);
@@ -1321,4 +1349,4 @@ module.exports.wildcardRoot = wildcardRoot;
1321
1349
  module.exports.hostMatchesWildcard = hostMatchesWildcard;
1322
1350
  module.exports.wildcardSubjectForHost = wildcardSubjectForHost;
1323
1351
  module.exports.buildCertLookupCandidates = buildCertLookupCandidates;
1324
- module.exports.certCoversName = certCoversName;
1352
+ module.exports.certCoversName = certCoversName;
package/package.json CHANGED
@@ -1,53 +1,53 @@
1
1
  {
2
- "name": "roster-server",
3
- "version": "2.4.6",
4
- "description": "👾 RosterServer - A domain host router to host multiple HTTPS.",
5
- "main": "index.js",
6
- "scripts": {
7
- "test": "node --test 'test/**/*.test.js'"
8
- },
9
- "repository": {
10
- "type": "git",
11
- "url": "git+https://github.com/clasen/RosterServer.git"
12
- },
13
- "keywords": [
14
- "greenlock",
15
- "https",
16
- "domain",
17
- "multi-domain",
18
- "letsencrypt",
19
- "hosting",
20
- "ssl",
21
- "host",
22
- "vhost",
23
- "virtual-host",
24
- "socket.io",
25
- "websocket",
26
- "express",
27
- "greenlock-express",
28
- "shotx",
29
- "bun",
30
- "clasen"
31
- ],
32
- "author": "Martin Clasen",
33
- "license": "MIT",
34
- "bugs": {
35
- "url": "https://github.com/clasen/RosterServer/issues"
36
- },
37
- "homepage": "https://github.com/clasen/RosterServer#readme",
38
- "dependencies": {
39
- "@greenlock/manager": "^3.1.0",
40
- "@root/acme": "^3.1.0",
41
- "@root/csr": "^0.8.1",
42
- "@root/keypairs": "^0.10.0",
43
- "@root/mkdirp": "^1.0.0",
44
- "@root/request": "^1.6.1",
45
- "acme-dns-01-cli": "^3.0.7",
46
- "acme-http-01-standalone": "^3.0.5",
47
- "cert-info": "^1.5.1",
48
- "greenlock-store-fs": "^3.2.2",
49
- "lemonlog": "^1.2.0",
50
- "redirect-https": "^1.3.1",
51
- "safe-replace": "^1.1.0"
52
- }
53
- }
2
+ "name": "roster-server",
3
+ "version": "2.4.10",
4
+ "description": "👾 RosterServer - A domain host router to host multiple HTTPS.",
5
+ "main": "index.js",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/clasen/RosterServer.git"
9
+ },
10
+ "keywords": [
11
+ "greenlock",
12
+ "https",
13
+ "domain",
14
+ "multi-domain",
15
+ "letsencrypt",
16
+ "hosting",
17
+ "ssl",
18
+ "host",
19
+ "vhost",
20
+ "virtual-host",
21
+ "socket.io",
22
+ "websocket",
23
+ "express",
24
+ "greenlock-express",
25
+ "shotx",
26
+ "bun",
27
+ "clasen"
28
+ ],
29
+ "author": "Martin Clasen",
30
+ "license": "MIT",
31
+ "bugs": {
32
+ "url": "https://github.com/clasen/RosterServer/issues"
33
+ },
34
+ "homepage": "https://github.com/clasen/RosterServer#readme",
35
+ "dependencies": {
36
+ "@greenlock/manager": "^3.1.0",
37
+ "@root/acme": "^3.1.0",
38
+ "@root/csr": "^0.8.1",
39
+ "@root/keypairs": "^0.10.0",
40
+ "@root/mkdirp": "^1.0.0",
41
+ "@root/request": "^1.6.1",
42
+ "acme-dns-01-cli": "^3.0.7",
43
+ "acme-http-01-standalone": "^3.0.5",
44
+ "cert-info": "^1.5.1",
45
+ "greenlock-store-fs": "^3.2.2",
46
+ "lemonlog": "^1.2.0",
47
+ "redirect-https": "^1.3.1",
48
+ "safe-replace": "^1.1.0"
49
+ },
50
+ "scripts": {
51
+ "test": "node --test 'test/**/*.test.js'"
52
+ }
53
+ }
@@ -0,0 +1,169 @@
1
+ 'use strict';
2
+
3
+ const PHP_PATH = /(?:^|\/)[^/]*\.php(?:$|\/)/i;
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;
6
+
7
+ function requirePositiveInteger(options, name) {
8
+ const value = options[name];
9
+ if (!Number.isSafeInteger(value) || value <= 0) {
10
+ throw new Error(`${name} must be a positive integer`);
11
+ }
12
+ return value;
13
+ }
14
+
15
+ function normalizeRequestPath(url) {
16
+ let requestPath = String(url || '/').split(/[?#]/, 1)[0];
17
+ for (let attempt = 0; attempt < 2; attempt++) {
18
+ try {
19
+ const decoded = decodeURIComponent(requestPath);
20
+ if (decoded === requestPath) break;
21
+ requestPath = decoded;
22
+ } catch {
23
+ break;
24
+ }
25
+ }
26
+ return requestPath.replace(/\\/g, '/').replace(/\/+/g, '/').toLowerCase();
27
+ }
28
+
29
+ function scannerReason(url) {
30
+ const requestPath = normalizeRequestPath(url);
31
+ if (PHP_PATH.test(requestPath)) return 'php-path';
32
+ if (SCANNER_PATH.test(requestPath)) return 'scanner-path';
33
+ if (SCANNER_FILE.test(requestPath)) return 'sensitive-file';
34
+ return null;
35
+ }
36
+
37
+ function normalizeIp(value) {
38
+ const ip = String(value || '').trim();
39
+ return ip.startsWith('::ffff:') ? ip.slice(7) : ip;
40
+ }
41
+
42
+ function clientIp(req, trustProxy) {
43
+ if (trustProxy) {
44
+ const forwardedFor = req.headers?.['x-forwarded-for'];
45
+ const firstForwardedIp = Array.isArray(forwardedFor)
46
+ ? forwardedFor[0]
47
+ : String(forwardedFor || '').split(',')[0];
48
+ const normalizedForwardedIp = normalizeIp(firstForwardedIp);
49
+ if (normalizedForwardedIp) return normalizedForwardedIp;
50
+ }
51
+ return normalizeIp(req.socket?.remoteAddress);
52
+ }
53
+
54
+ function rejectRequest(req, res) {
55
+ const body = 'Not Found';
56
+ res.writeHead(404, {
57
+ 'Content-Type': 'text/plain; charset=utf-8',
58
+ 'Content-Length': Buffer.byteLength(body),
59
+ 'Cache-Control': 'no-store'
60
+ });
61
+ res.end(req.method === 'HEAD' ? undefined : body);
62
+ }
63
+
64
+ function createScannerBlocker(options = {}) {
65
+ if (!options || typeof options !== 'object') {
66
+ throw new Error('scanner-blocker options must be an object');
67
+ }
68
+
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') {
84
+ throw new Error('trustProxy must be a boolean');
85
+ }
86
+ if (onBlock !== undefined && typeof onBlock !== 'function') {
87
+ throw new Error('onBlock must be a function');
88
+ }
89
+ if (now !== undefined && typeof now !== 'function') {
90
+ throw new Error('now must be a function');
91
+ }
92
+
93
+ const clock = now || Date.now;
94
+ const clients = new Map();
95
+
96
+ function removeExpiredClients(timestamp) {
97
+ for (const [ip, state] of clients) {
98
+ const banExpired = state.bannedUntil !== null && timestamp >= state.bannedUntil;
99
+ const windowExpired = state.bannedUntil === null && timestamp - state.windowStartedAt >= windowMs;
100
+ if (banExpired || windowExpired) clients.delete(ip);
101
+ }
102
+ }
103
+
104
+ function addClient(ip, state, timestamp) {
105
+ if (!clients.has(ip) && clients.size >= maxTrackedClients) {
106
+ removeExpiredClients(timestamp);
107
+ }
108
+ if (!clients.has(ip) && clients.size >= maxTrackedClients) {
109
+ clients.delete(clients.keys().next().value);
110
+ }
111
+ clients.delete(ip);
112
+ clients.set(ip, state);
113
+ }
114
+
115
+ function reportBlock(details) {
116
+ if (onBlock) onBlock(details);
117
+ }
118
+
119
+ return function scannerBlocker(req, res, context = {}) {
120
+ const timestamp = clock();
121
+ const ip = clientIp(req, trustProxy);
122
+ let state = ip ? clients.get(ip) : null;
123
+
124
+ if (state && state.bannedUntil !== null) {
125
+ if (timestamp < state.bannedUntil) {
126
+ rejectRequest(req, res);
127
+ reportBlock({
128
+ type: 'banned-client',
129
+ clientIp: ip,
130
+ host: context.host || '',
131
+ url: req.url || '/',
132
+ bannedUntil: state.bannedUntil
133
+ });
134
+ return true;
135
+ }
136
+ clients.delete(ip);
137
+ state = null;
138
+ }
139
+
140
+ const reason = scannerReason(req.url);
141
+ if (!reason) return false;
142
+
143
+ let bannedUntil = null;
144
+ if (ip) {
145
+ if (!state || timestamp - state.windowStartedAt >= windowMs) {
146
+ state = { strikes: 0, windowStartedAt: timestamp, bannedUntil: null };
147
+ }
148
+ state.strikes += 1;
149
+ if (state.strikes >= strikeThreshold) {
150
+ state.bannedUntil = timestamp + banDurationMs;
151
+ }
152
+ bannedUntil = state.bannedUntil;
153
+ addClient(ip, state, timestamp);
154
+ }
155
+
156
+ rejectRequest(req, res);
157
+ reportBlock({
158
+ type: 'scanner-path',
159
+ reason,
160
+ clientIp: ip,
161
+ host: context.host || '',
162
+ url: req.url || '/',
163
+ bannedUntil
164
+ });
165
+ return true;
166
+ };
167
+ }
168
+
169
+ module.exports = { createScannerBlocker };
@@ -0,0 +1,2 @@
1
+ allowBuilds:
2
+ '@root/acme': true
@@ -172,6 +172,18 @@ const httpsServer = await worker.createServingHttpsServer({ servername: 'example
172
172
  httpsServer.listen(4336);
173
173
  ```
174
174
 
175
+ ### Pattern 8: Optional Scanner Blocking
176
+ ```javascript
177
+ const Roster = require('roster-server');
178
+ const { createScannerBlocker } = require('roster-server/plugins/scanner-blocker.js');
179
+
180
+ const roster = new Roster({ local: true, wwwPath: './www' });
181
+ roster.use(createScannerBlocker());
182
+ roster.start();
183
+ ```
184
+
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.
186
+
175
187
  ## Key Configuration Options
176
188
 
177
189
  ```javascript
@@ -234,6 +246,9 @@ Convenience: wires `requestHandler` + `upgradeHandler` onto an external `http.Se
234
246
  ### `roster.register(domain, handler)`
235
247
  Manually register a domain handler. Domain can include port: `'api.com:8443'`. For wildcards use `'*.example.com'` or `'*.example.com:8080'`.
236
248
 
249
+ ### `roster.use(plugin)`
250
+ Registers a synchronous request plugin. It receives `(req, res, { host, domain })` and stops dispatch when it returns `true`.
251
+
237
252
  ### `roster.getUrl(domain)`
238
253
  Get environment-aware URL:
239
254
  - Local mode: `http://localhost:{port}`
@@ -7,6 +7,7 @@ const fs = require('fs');
7
7
  const http = require('http');
8
8
  const os = require('os');
9
9
  const Roster = require('../index.js');
10
+ const { createScannerBlocker } = require('../plugins/scanner-blocker.js');
10
11
  const {
11
12
  wildcardRoot,
12
13
  hostMatchesWildcard,
@@ -432,6 +433,13 @@ describe('Roster local mode (local: true)', () => {
432
433
  hostname: 'localhost'
433
434
  });
434
435
  const body = 'local-mode-ok';
436
+ roster.use(createScannerBlocker({
437
+ windowMs: 60_000,
438
+ strikeThreshold: 2,
439
+ banDurationMs: 300_000,
440
+ maxTrackedClients: 100,
441
+ trustProxy: false
442
+ }));
435
443
  roster.register('testlocal.example', (server) => {
436
444
  return (req, res) => {
437
445
  res.writeHead(200, { 'Content-Type': 'text/plain' });
@@ -446,6 +454,9 @@ describe('Roster local mode (local: true)', () => {
446
454
  const result = await httpGet('localhost', port, '/');
447
455
  assert.strictEqual(result.statusCode, 200);
448
456
  assert.strictEqual(result.body, body);
457
+ const probe = await httpGet('localhost', port, '/wp-login.php');
458
+ assert.strictEqual(probe.statusCode, 404);
459
+ assert.strictEqual(probe.body, 'Not Found');
449
460
  } finally {
450
461
  closePortServers(roster);
451
462
  }
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+
3
+ const { describe, it } = require('node:test');
4
+ const assert = require('node:assert');
5
+ const Roster = require('../index.js');
6
+ const { createScannerBlocker } = require('../plugins/scanner-blocker.js');
7
+
8
+ function invoke(plugin, { url = '/', ip = '192.0.2.10', headers = {}, method = 'GET' } = {}) {
9
+ let statusCode;
10
+ let responseHeaders;
11
+ let body;
12
+ const req = {
13
+ method,
14
+ url,
15
+ headers,
16
+ socket: { remoteAddress: ip }
17
+ };
18
+ const res = {
19
+ writeHead(code, nextHeaders) {
20
+ statusCode = code;
21
+ responseHeaders = nextHeaders;
22
+ },
23
+ end(nextBody) {
24
+ body = nextBody;
25
+ }
26
+ };
27
+ const handled = plugin(req, res, { host: 'example.com', domain: 'example.com' });
28
+ return { handled, statusCode, responseHeaders, body };
29
+ }
30
+
31
+ function blocker(overrides = {}) {
32
+ return createScannerBlocker({
33
+ windowMs: 60_000,
34
+ strikeThreshold: 2,
35
+ banDurationMs: 300_000,
36
+ maxTrackedClients: 100,
37
+ trustProxy: false,
38
+ ...overrides
39
+ });
40
+ }
41
+
42
+ describe('scanner-blocker plugin', () => {
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/);
57
+ assert.throws(() => blocker({ maxTrackedClients: 0 }), /maxTrackedClients/);
58
+ });
59
+
60
+ it('blocks PHP, WordPress, and sensitive-file probes', () => {
61
+ const urls = [
62
+ '/install.php',
63
+ '/user-new\\.php',
64
+ '/wp-json',
65
+ '/.git/config',
66
+ '/%252eenv'
67
+ ];
68
+
69
+ for (const [index, url] of urls.entries()) {
70
+ const result = invoke(blocker(), { url, ip: `192.0.2.${index + 1}` });
71
+ assert.strictEqual(result.handled, true, url);
72
+ assert.strictEqual(result.statusCode, 404, url);
73
+ assert.strictEqual(result.body, 'Not Found', url);
74
+ assert.strictEqual(result.responseHeaders['Cache-Control'], 'no-store');
75
+ }
76
+ });
77
+
78
+ it('does not classify ordinary article, atom, or config routes as scanner probes', () => {
79
+ const plugin = blocker();
80
+ assert.strictEqual(invoke(plugin, { url: '/en/an-article' }).handled, false);
81
+ assert.strictEqual(invoke(plugin, { url: '/atom' }).handled, false);
82
+ assert.strictEqual(invoke(plugin, { url: '/articles/config' }).handled, false);
83
+ });
84
+
85
+ it('bans a client after the configured number of probes', () => {
86
+ const timestamp = 1_000;
87
+ const events = [];
88
+ const plugin = blocker({
89
+ now: () => timestamp,
90
+ onBlock: event => events.push(event)
91
+ });
92
+
93
+ assert.strictEqual(invoke(plugin, { url: '/wp-login.php' }).handled, true);
94
+ assert.strictEqual(invoke(plugin, { url: '/xmlrpc.php' }).handled, true);
95
+ assert.strictEqual(invoke(plugin, { url: '/legitimate-page' }).handled, true);
96
+ assert.strictEqual(events[1].bannedUntil, 301_000);
97
+ assert.strictEqual(events[2].type, 'banned-client');
98
+
99
+ assert.strictEqual(invoke(plugin, { url: '/legitimate-page', ip: '192.0.2.11' }).handled, false);
100
+ });
101
+
102
+ it('expires bans without extending them on blocked requests', () => {
103
+ let timestamp = 1_000;
104
+ const events = [];
105
+ const plugin = blocker({
106
+ strikeThreshold: 1,
107
+ banDurationMs: 5_000,
108
+ now: () => timestamp,
109
+ onBlock: event => events.push(event)
110
+ });
111
+
112
+ invoke(plugin, { url: '/wp-login.php' });
113
+ assert.strictEqual(events[0].bannedUntil, 6_000);
114
+ timestamp = 5_999;
115
+ assert.strictEqual(invoke(plugin, { url: '/ordinary' }).handled, true);
116
+ timestamp = 6_000;
117
+ assert.strictEqual(invoke(plugin, { url: '/ordinary' }).handled, false);
118
+ });
119
+
120
+ it('trusts X-Forwarded-For only when explicitly configured', () => {
121
+ const headers = { 'x-forwarded-for': '198.51.100.1, 198.51.100.2' };
122
+ const directPlugin = blocker({ strikeThreshold: 1, trustProxy: false });
123
+ invoke(directPlugin, { url: '/install.php', headers, ip: '192.0.2.20' });
124
+ assert.strictEqual(invoke(directPlugin, { url: '/', headers, ip: '192.0.2.20' }).handled, true);
125
+ assert.strictEqual(invoke(directPlugin, { url: '/', headers, ip: '192.0.2.21' }).handled, false);
126
+
127
+ const proxyPlugin = blocker({ strikeThreshold: 1, trustProxy: true });
128
+ invoke(proxyPlugin, { url: '/install.php', headers, ip: '192.0.2.20' });
129
+ assert.strictEqual(invoke(proxyPlugin, { url: '/', headers, ip: '192.0.2.21' }).handled, true);
130
+ });
131
+
132
+ it('keeps tracked client state within the configured bound', () => {
133
+ const plugin = blocker({ maxTrackedClients: 1 });
134
+ invoke(plugin, { url: '/install.php', ip: '192.0.2.30' });
135
+ invoke(plugin, { url: '/install.php', ip: '192.0.2.31' });
136
+ invoke(plugin, { url: '/xmlrpc.php', ip: '192.0.2.30' });
137
+ assert.strictEqual(invoke(plugin, { url: '/ordinary', ip: '192.0.2.30' }).handled, false);
138
+ });
139
+
140
+ it('does not send a response body for HEAD probes', () => {
141
+ const result = invoke(blocker(), { method: 'HEAD', url: '/wp-login.php' });
142
+ assert.strictEqual(result.handled, true);
143
+ assert.strictEqual(result.body, undefined);
144
+ });
145
+ });
146
+
147
+ describe('Roster request plugins', () => {
148
+ it('runs plugins before dispatching to the site handler', async () => {
149
+ let siteRequests = 0;
150
+ const roster = new Roster({ local: true });
151
+ roster.use(createScannerBlocker({
152
+ windowMs: 60_000,
153
+ strikeThreshold: 2,
154
+ banDurationMs: 300_000,
155
+ maxTrackedClients: 100,
156
+ trustProxy: false
157
+ }));
158
+ roster.register('example.com', () => (req, res) => {
159
+ siteRequests += 1;
160
+ res.writeHead(200);
161
+ res.end('site');
162
+ });
163
+ await roster.init();
164
+
165
+ const handler = roster.requestHandler();
166
+ const probe = invoke(handler, {
167
+ url: '/wp-login.php',
168
+ headers: { host: 'example.com' }
169
+ });
170
+ assert.strictEqual(probe.statusCode, 404);
171
+ assert.strictEqual(siteRequests, 0);
172
+
173
+ const ordinary = invoke(handler, {
174
+ url: '/ordinary',
175
+ ip: '192.0.2.11',
176
+ headers: { host: 'example.com' }
177
+ });
178
+ assert.strictEqual(ordinary.statusCode, 200);
179
+ assert.strictEqual(ordinary.body, 'site');
180
+ assert.strictEqual(siteRequests, 1);
181
+ });
182
+
183
+ it('validates plugins and rejects asynchronous request plugins', async () => {
184
+ const roster = new Roster({ local: true });
185
+ assert.throws(() => roster.use({}), /plugin must be a function/);
186
+ assert.strictEqual(roster.use(() => false), roster);
187
+
188
+ const asyncRoster = new Roster({ local: true });
189
+ asyncRoster.use(async () => false);
190
+ asyncRoster.register('example.com', () => () => {});
191
+ await asyncRoster.init();
192
+ assert.throws(() => {
193
+ asyncRoster.requestHandler()(
194
+ { headers: { host: 'example.com' }, url: '/', socket: {} },
195
+ { writeHead() {}, end() {} }
196
+ );
197
+ }, /must be synchronous/);
198
+ });
199
+ });
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/.greenlockrc DELETED
@@ -1 +0,0 @@
1
- {"configDir":"/Users/martinclasen/Stuff/greenlock.d"}