@liberstudio/cloudflare-list 2.4.8 → 3.0.3

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
@@ -2,12 +2,12 @@
2
2
 
3
3
  Modulo NestJS per il rilevamento e blocco automatico di attacchi tramite Cloudflare IP List.
4
4
 
5
- Ogni richiesta che produce un `404` viene considerata sospetta (tipicamente bot, scanner, enumeratori). L'IP viene risolto tramite ASN lookup per ottenere l'intero range di subnet e bloccarlo direttamente sulla lista Cloudflare WAF. Se il lookup fallisce, viene bloccato il singolo IP.
5
+ Ogni richiesta che produce un `404` su un indirizzo senza nessuna rotta viene considerata sospetta (tipicamente bot, scanner, enumeratori: `/wp-admin`, `/.env`). Il `404` restituito da una rotta esistente (es. `GET /artworks/:id` di una risorsa che non c'è) non viene bloccato. L'IP viene risolto tramite ASN lookup per ottenere l'intero range di subnet e bloccarlo direttamente sulla lista Cloudflare WAF. Se il lookup fallisce, viene bloccato il singolo IP.
6
6
 
7
7
  ## Come funziona
8
8
 
9
9
  ```
10
- Request → 404 rilevato dal middleware
10
+ Request → 404 rilevato dal middleware (solo se nessuna rotta ha risposto)
11
11
  └─ IP estratto (cf-connecting-ip / x-real-ip / x-forwarded-for)
12
12
  └─ throttle 5s per IP
13
13
  └─ log su file (se path non è silentPath)
@@ -47,6 +47,8 @@ Il modulo ignora automaticamente:
47
47
  - Action: `Block`
48
48
  6. Salvare la regola
49
49
 
50
+ Con più account Cloudflare ripetere i passi per ogni account: ognuno ha la sua lista, il suo token e la sua regola WAF.
51
+
50
52
  ---
51
53
 
52
54
  ## Installazione
@@ -67,9 +69,10 @@ import { CloudflareAttacksModule } from "@liberstudio/cloudflare-list";
67
69
  @Module({
68
70
  imports: [
69
71
  CloudflareAttacksModule.forRoot({
70
- accountId: "abc123",
71
- listId: "def456",
72
- apiToken: "your-cloudflare-api-token",
72
+ accounts: [
73
+ { accountId: "abc123", listId: "def456", apiToken: "token-account-1" },
74
+ { accountId: "ghi789", listId: "jkl012", apiToken: "token-account-2" },
75
+ ],
73
76
  comment: "Blocked by liberstudio/cloudflare-list",
74
77
  logPath: "/var/log/nestjs-attacks.log",
75
78
  allowedIps: ["1.2.3.4", "10.0.0.0/8"],
@@ -94,9 +97,18 @@ import { CloudflareAttacksModule } from "@liberstudio/cloudflare-list";
94
97
  imports: [ConfigModule],
95
98
  inject: [ConfigService],
96
99
  useFactory: (config: ConfigService) => ({
97
- apiToken: config.getOrThrow<string>("CLOUDFLARE_API_TOKEN"),
98
- accountId: config.getOrThrow<string>("CLOUDFLARE_ACCOUNT_ID"),
99
- listId: config.getOrThrow<string>("CLOUDFLARE_LIST_ID"),
100
+ accounts: [
101
+ {
102
+ apiToken: config.getOrThrow<string>("CLOUDFLARE_API_TOKEN"),
103
+ accountId: config.getOrThrow<string>("CLOUDFLARE_ACCOUNT_ID"),
104
+ listId: config.getOrThrow<string>("CLOUDFLARE_LIST_ID"),
105
+ },
106
+ {
107
+ apiToken: config.getOrThrow<string>("CLOUDFLARE_2_API_TOKEN"),
108
+ accountId: config.getOrThrow<string>("CLOUDFLARE_2_ACCOUNT_ID"),
109
+ listId: config.getOrThrow<string>("CLOUDFLARE_2_LIST_ID"),
110
+ },
111
+ ],
100
112
  comment: config.get<string>("CLOUDFLARE_LIST_COMMENT") || "Blocked",
101
113
  logPath: config.get<string>("CLOUDFLARE_LIST_LOG_PATH") || "/var/log/nestjs-attacks.log",
102
114
  allowedIps: ["1.2.3.4", "10.0.0.0/8"],
@@ -120,9 +132,7 @@ export class AppModule {}
120
132
 
121
133
  | Opzione | Tipo | Obbligatorio | Descrizione |
122
134
  |---|---|---|---|
123
- | `apiToken` | `string` | si | Token API Cloudflare |
124
- | `accountId` | `string` | si | ID account Cloudflare |
125
- | `listId` | `string` | si | ID della IP List Cloudflare |
135
+ | `accounts` | `CloudflareAccount[]` | si | Account Cloudflare su cui bannare (almeno uno), vedi sotto |
126
136
  | `comment` | `string` | si | Commento aggiunto ad ogni IP bloccato |
127
137
  | `logPath` | `string` | si | Percorso del file di log degli attacchi |
128
138
  | `allowedIps` | `string[]` | no | IP o CIDR mai bloccati (whitelist, supporta IPv4 e IPv6) |
@@ -133,6 +143,38 @@ export class AppModule {}
133
143
  | `telegramNotify` | `{ chatId: string }` | no | Invia una notifica Telegram quando un IP viene bannato |
134
144
  | `notifyProvider` | `"whatsapp" \| "telegram"` | no | Canale di notifica da usare (default: vedi sotto) |
135
145
 
146
+ ### `accounts` — ban su più account Cloudflare
147
+
148
+ Ogni elemento di `accounts` è un `CloudflareAccount`:
149
+
150
+ | Campo | Tipo | Obbligatorio | Descrizione |
151
+ |---|---|---|---|
152
+ | `apiToken` | `string` | si | Token API Cloudflare con accesso all'account |
153
+ | `accountId` | `string` | si | ID account Cloudflare |
154
+ | `listId` | `string` | si | ID della IP List dell'account |
155
+
156
+ Ad ogni ban l'IP (o la subnet) viene aggiunto **in parallelo** alle liste di tutti gli account:
157
+
158
+ - se un account fallisce, l'errore viene loggato con il suo `accountId` e gli altri proseguono;
159
+ - se falliscono **tutti**, `updateIpList()` lancia `HttpException` con il primo errore;
160
+ - `updateIpList()` e `blockIp()` ritornano un `CloudflareResponse[]` con le risposte degli account riusciti.
161
+
162
+ Se lo stesso token ha accesso a più account, basta ripeterlo in ogni elemento.
163
+
164
+ ### Migrazione dalla 2.x
165
+
166
+ La 3.0.0 è un breaking change: `apiToken`, `accountId` e `listId` non stanno più alla radice delle opzioni, ma dentro `accounts`.
167
+
168
+ ```typescript
169
+ // 2.x
170
+ { apiToken: "...", accountId: "...", listId: "...", comment: "...", logPath: "..." }
171
+
172
+ // 3.x
173
+ { accounts: [{ apiToken: "...", accountId: "...", listId: "..." }], comment: "...", logPath: "..." }
174
+ ```
175
+
176
+ Inoltre `updateIpList()` ritorna `CloudflareResponse[]` invece di `CloudflareResponse`, e `blockIp()` ritorna `CloudflareResponse[] | null`.
177
+
136
178
  ### `notifyProvider` — scelta del canale di notifica
137
179
 
138
180
  Ad ogni ban viene inviata **una sola** notifica, sul canale scelto:
@@ -176,8 +218,11 @@ Il messaggio inviato sarà:
176
218
  Provenienza: Blocked by liberstudio/cloudflare-list
177
219
  IP: 1.2.3.4
178
220
  Subnet bloccata: 1.2.0.0/16
221
+ Account: 2/2
179
222
  ```
180
223
 
224
+ `Account` indica su quanti account configurati il ban è andato a buon fine. La notifica è sempre **una** per ban.
225
+
181
226
  Variabili d'ambiente richieste (lette automaticamente da `process.env`):
182
227
  ```env
183
228
  WA_URL=https://messaggistica.liberdesign.eu
@@ -7,11 +7,13 @@ export declare class AttacksService {
7
7
  private readonly API_URL;
8
8
  private readonly ASN_LOOKUP_URL;
9
9
  constructor(httpService: HttpService, options: CloudflareAttacksOptions);
10
- blockIp(rawIp: string): Promise<CloudflareResponse | null>;
10
+ blockIp(rawIp: string): Promise<CloudflareResponse[] | null>;
11
11
  private sendNotification;
12
12
  private isWhatsappConfigured;
13
13
  private sendTelegramNotification;
14
14
  private sendWhatsappNotification;
15
15
  private lookupAsnRange;
16
- updateIpList(cidr: string): Promise<CloudflareResponse>;
16
+ /** Aggiunge il CIDR alle liste di tutti gli account; ritorna le risposte degli account riusciti. */
17
+ updateIpList(cidr: string): Promise<CloudflareResponse[]>;
18
+ private addToAccountList;
17
19
  }
@@ -47,7 +47,7 @@ let AttacksService = class AttacksService {
47
47
  if (asnRange && !(await (0, utils_1.googleBotOverlapsCidr)(asnRange))) {
48
48
  this.logger.error(`ASN range trovato per ${rawIp}: ${asnRange} — blocco intera subnet`);
49
49
  const result = await this.updateIpList(asnRange);
50
- this.sendNotification(rawIp, asnRange);
50
+ this.sendNotification(rawIp, asnRange, result.length);
51
51
  return result;
52
52
  }
53
53
  if (asnRange) {
@@ -57,11 +57,11 @@ let AttacksService = class AttacksService {
57
57
  if (!cidr)
58
58
  throw new Error(`IP non valido: ${rawIp}`);
59
59
  const result = await this.updateIpList(cidr);
60
- this.sendNotification(rawIp, cidr);
60
+ this.sendNotification(rawIp, cidr, result.length);
61
61
  return result;
62
62
  }
63
- sendNotification(rawIp, cidr) {
64
- const message = `🚨 IP bannato su Cloudflare\nProvenienza: ${this.options.comment}\nIP: ${rawIp}\nSubnet bloccata: ${cidr}`;
63
+ sendNotification(rawIp, cidr, okAccounts) {
64
+ const message = `🚨 IP bannato su Cloudflare\nProvenienza: ${this.options.comment}\nIP: ${rawIp}\nSubnet bloccata: ${cidr}\nAccount: ${okAccounts}/${this.options.accounts.length}`;
65
65
  const provider = this.options.notifyProvider ?? (this.isWhatsappConfigured() ? "whatsapp" : "telegram");
66
66
  if (provider === "whatsapp")
67
67
  this.sendWhatsappNotification(message);
@@ -114,11 +114,30 @@ let AttacksService = class AttacksService {
114
114
  return null;
115
115
  }
116
116
  }
117
+ /** Aggiunge il CIDR alle liste di tutti gli account; ritorna le risposte degli account riusciti. */
117
118
  async updateIpList(cidr) {
118
- const { accountId, listId, apiToken, comment } = this.options;
119
+ const { accounts } = this.options;
120
+ this.logger.error(`Aggiungo ${cidr} alle liste Cloudflare (${accounts.length} account)`);
121
+ const results = await Promise.allSettled(accounts.map((account) => this.addToAccountList(account, cidr)));
122
+ const ok = [];
123
+ let firstError;
124
+ results.forEach((r, i) => {
125
+ if (r.status === "fulfilled") {
126
+ ok.push(r.value);
127
+ return;
128
+ }
129
+ firstError ??= r.reason;
130
+ const msg = r.reason instanceof Error ? r.reason.message : "Errore sconosciuto";
131
+ this.logger.error(`Aggiunta di ${cidr} fallita sull'account ${accounts[i].accountId}: ${msg}`);
132
+ });
133
+ if (ok.length === 0)
134
+ throw firstError;
135
+ return ok;
136
+ }
137
+ async addToAccountList(account, cidr) {
138
+ const { accountId, listId, apiToken } = account;
119
139
  const url = `${this.API_URL}/${accountId}/rules/lists/${listId}/items`;
120
- this.logger.error(`Aggiungo ${cidr} alla lista Cloudflare`);
121
- const body = [{ ip: cidr, comment }];
140
+ const body = [{ ip: cidr, comment: this.options.comment }];
122
141
  try {
123
142
  const response = await (0, rxjs_1.firstValueFrom)(this.httpService.post(url, body, {
124
143
  headers: {
@@ -5,10 +5,13 @@ export interface TelegramNotifyOptions {
5
5
  chatId: string;
6
6
  }
7
7
  export type NotifyProvider = "whatsapp" | "telegram";
8
- export interface CloudflareAttacksOptions {
8
+ export interface CloudflareAccount {
9
9
  apiToken: string;
10
10
  accountId: string;
11
11
  listId: string;
12
+ }
13
+ export interface CloudflareAttacksOptions {
14
+ accounts: CloudflareAccount[];
12
15
  comment: string;
13
16
  logPath: string;
14
17
  allowedIps?: string[];
@@ -15,6 +15,7 @@ export declare class AttackLoggerMiddleware implements NestMiddleware, OnModuleD
15
15
  private isThrottled;
16
16
  private sanitize;
17
17
  private ensureDirectoryExists;
18
+ private hasRealRoute;
18
19
  private isExcluded;
19
20
  private isSilent;
20
21
  onModuleDestroy(): void;
@@ -42,7 +42,7 @@ let AttackLoggerMiddleware = AttackLoggerMiddleware_1 = class AttackLoggerMiddle
42
42
  }
43
43
  use(req, res, next) {
44
44
  res.on("finish", () => {
45
- if (res.statusCode === 404 && !this.isExcluded(req.url)) {
45
+ if (res.statusCode === 404 && !this.hasRealRoute(req) && !this.isExcluded(req.url)) {
46
46
  this.handleSuspicious(req);
47
47
  }
48
48
  });
@@ -85,7 +85,7 @@ let AttackLoggerMiddleware = AttackLoggerMiddleware_1 = class AttackLoggerMiddle
85
85
  this.logger.warn(`IP non valido: ${ip}`);
86
86
  return;
87
87
  }
88
- this.logger.warn(`Provenienza ${ip}: socket=${origin.remoteAddress} host=${origin.host} cf-ray=${origin.cfRay}`);
88
+ this.logger.warn(`Provenienza ${ip}: ${req.method} ${this.sanitize(req.originalUrl)} → 404, socket=${origin.remoteAddress} host=${origin.host} cf-ray=${origin.cfRay}`);
89
89
  this.attSrv.blockIp(ip).catch((err) => {
90
90
  const msg = err instanceof Error ? err.message : "Errore sconosciuto";
91
91
  this.logger.error(`Aggiornamento Cloudflare fallito: ${msg}`);
@@ -110,6 +110,13 @@ let AttackLoggerMiddleware = AttackLoggerMiddleware_1 = class AttackLoggerMiddle
110
110
  this.logger.log(`Cartella dei log creata: ${dir}`);
111
111
  }
112
112
  }
113
+ // Un 404 di una rotta esistente (es. risorsa non trovata) non è una scansione.
114
+ // Express lascia req.route solo se una rotta ha risposto; Nest 12 con prefisso globale
115
+ // gestisce gli indirizzi senza rotta con il catch-all '*path', che quindi non conta.
116
+ hasRealRoute(req) {
117
+ const path = req.route?.path;
118
+ return path !== undefined && path !== "*path";
119
+ }
113
120
  isExcluded(url) {
114
121
  const excluded = this.options.excludedPaths ?? [];
115
122
  return excluded.some((p) => (p instanceof RegExp ? p.test(url) : url.startsWith(p)));
@@ -20,4 +20,10 @@ if (minor >= 10) {
20
20
  pkg.version = `${major}.${minor}.${patch}`;
21
21
  // Scrittura package.json
22
22
  (0, node_fs_1.writeFileSync)(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
23
+ // Allineamento package-lock.json (versione radice e del pacchetto "")
24
+ const lockPath = (0, node_path_1.resolve)(__dirname, '../../package-lock.json');
25
+ const lock = JSON.parse((0, node_fs_1.readFileSync)(lockPath, 'utf8'));
26
+ lock.version = pkg.version;
27
+ lock.packages[''].version = pkg.version;
28
+ (0, node_fs_1.writeFileSync)(lockPath, JSON.stringify(lock, null, 2) + '\n');
23
29
  console.log(`Version updated to ${pkg.version}`);
@@ -1,16 +1,29 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.validateOptions = validateOptions;
4
+ function isNonEmptyString(value) {
5
+ return typeof value === "string" && value.trim().length > 0;
6
+ }
4
7
  function validateOptions(opzioni) {
5
8
  if (!opzioni)
6
9
  throw new Error("CloudflareAttacksOptions è obbligatorio");
7
- const campiObbligatori = ["apiToken", "accountId", "listId", "comment", "logPath"];
10
+ const campiObbligatori = ["comment", "logPath"];
8
11
  for (const campo of campiObbligatori) {
9
- const valore = opzioni[campo];
10
- if (typeof valore !== "string" || valore.trim().length === 0) {
12
+ if (!isNonEmptyString(opzioni[campo])) {
11
13
  throw new Error(`CloudflareAttacksOptions.${campo} deve essere una stringa non vuota`);
12
14
  }
13
15
  }
16
+ if (!Array.isArray(opzioni.accounts) || opzioni.accounts.length === 0) {
17
+ throw new Error("CloudflareAttacksOptions.accounts deve essere un array non vuoto");
18
+ }
19
+ const campiAccount = ["apiToken", "accountId", "listId"];
20
+ opzioni.accounts.forEach((account, i) => {
21
+ for (const campo of campiAccount) {
22
+ if (!isNonEmptyString(account?.[campo])) {
23
+ throw new Error(`CloudflareAttacksOptions.accounts[${i}].${campo} deve essere una stringa non vuota`);
24
+ }
25
+ }
26
+ });
14
27
  const provider = opzioni.notifyProvider;
15
28
  if (provider !== undefined && provider !== "whatsapp" && provider !== "telegram") {
16
29
  throw new Error(`CloudflareAttacksOptions.notifyProvider deve essere "whatsapp" o "telegram"`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liberstudio/cloudflare-list",
3
- "version": "2.4.8",
3
+ "version": "3.0.3",
4
4
  "description": "Modulo NestJS per gestione IP List Cloudflare",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -14,6 +14,7 @@
14
14
  ],
15
15
  "scripts": {
16
16
  "build": "npx tsc",
17
+ "test": "vitest run",
17
18
  "prepublishOnly": "npm run build",
18
19
  "version:bump": "npx tsx src/scripts/version-bump.ts",
19
20
  "pub": "npm run build && npm publish --access public"
@@ -35,12 +36,14 @@
35
36
  "@nestjs/axios": "^12.0.1",
36
37
  "@nestjs/common": "^12.4.0",
37
38
  "@nestjs/core": "^12.4.0",
39
+ "@nestjs/platform-express": "^12.4.2",
38
40
  "@types/express": "^5.0.6",
39
- "@types/node": "^20.19.43",
41
+ "@types/node": "^24.19.2",
40
42
  "axios": "^1.21.0",
41
43
  "rxjs": "^7.8.2",
42
44
  "simple-git-hooks": "^2.13.1",
43
- "typescript": "^5.0.0"
45
+ "typescript": "^5.0.0",
46
+ "vitest": "^5.1.0"
44
47
  },
45
48
  "allowScripts": {
46
49
  "simple-git-hooks@2.13.1": true