@liberstudio/cloudflare-list 2.2.9 → 2.3.1

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
@@ -1,16 +1,49 @@
1
- # Modulo NestJS per la gestione IP List Cloudflare
1
+ # @liberstudio/cloudflare-list
2
2
 
3
- ## Prima di iniziare
3
+ Modulo NestJS per il rilevamento e blocco automatico di attacchi tramite Cloudflare IP List.
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.
6
+
7
+ ## Come funziona
8
+
9
+ ```
10
+ Request → 404 rilevato dal middleware
11
+ └─ IP estratto (cf-connecting-ip / x-real-ip / x-forwarded-for)
12
+ └─ throttle 5s per IP
13
+ └─ log su file (se path non è silentPath)
14
+ └─ ASN lookup su api.hackertarget.com
15
+ ├─ range trovato → blocca intera subnet su Cloudflare
16
+ └─ fallback → blocca singolo IP /32 o /128
17
+ ```
18
+
19
+ ### Filtraggio automatico
20
+
21
+ Il modulo ignora automaticamente:
22
+ - IP privati (10.x, 192.168.x, 172.16–31.x)
23
+ - Loopback (127.0.0.1, ::1)
24
+ - IPv4-mapped IPv6 (::ffff:x.x.x.x → convertiti in IPv4)
25
+
26
+ ### Gestione errori e log
27
+
28
+ - `AllExceptionsFilter` globale cattura tutte le eccezioni non gestite e le logga a console con IP, metodo, path e status code
29
+ - Con `verbose: true` il log include anche body, query, params e stack trace
30
+
31
+ ---
32
+
33
+ ## Prima di iniziare su Cloudflare
4
34
 
5
35
  1. Creare un account Cloudflare
6
- 2. Scegliere il Dominio da utilizzare
7
- 3. Manage Account > Configuration > List > Create List
8
- 4. Account API tokens > Create Token > Create Custom Token (Permission: Account > Account WAF > Edit, Account > Account Filter List > Edit)
9
- 5. Salvare il token API senza scadenza
10
- 6. Domains > Scegliere il dominio da utilizzare
11
- 7. Andare nella categoria Security > Security Rules > Create rule > Custom Rules
12
- 8. Nome: <Name>, Field: <IP Source Address , is in, scegli la lista creata prima>, Action: Block
13
- 9. Salvare la regola
36
+ 2. Scegliere il dominio da utilizzare
37
+ 3. **Manage Account > Configuration > Lists > Create List** (tipo: IP)
38
+ 4. **Account API tokens > Create Token > Create Custom Token**
39
+ - Permission: `Account > Account WAF > Edit`
40
+ - Permission: `Account > Account Filter Lists > Edit`
41
+ 5. **Security > WAF > Custom Rules > Create Rule**
42
+ - Field: `IP Source Address` — `is in` — scegli la lista creata
43
+ - Action: `Block`
44
+ 6. Salvare la regola
45
+
46
+ ---
14
47
 
15
48
  ## Installazione
16
49
 
@@ -18,11 +51,11 @@
18
51
  npm install @liberstudio/cloudflare-list
19
52
  ```
20
53
 
21
- ## Utilizzo
54
+ ---
22
55
 
23
- ### Configurazione
56
+ ## Configurazione
24
57
 
25
- Nel file di configurazione del progetto, aggiungere il modulo all'import della configurazione:
58
+ ### Statica (`forRoot`)
26
59
 
27
60
  ```typescript
28
61
  import { CloudflareAttacksModule } from "@liberstudio/cloudflare-list";
@@ -30,21 +63,21 @@ import { CloudflareAttacksModule } from "@liberstudio/cloudflare-list";
30
63
  @Module({
31
64
  imports: [
32
65
  CloudflareAttacksModule.forRoot({
33
- accountId: "<account_id>",
34
- listId: "<list_id>",
35
- apiToken: "<api_token>",
36
- comment: "<comment>",
37
- logPath: "<logPath>",
38
- excludedPaths: "<excludedList>",
39
- silentPaths: "<silentList>",
40
- verbose: false, // default: false — log solo riga breve; true = log completo con body, query, stack
66
+ accountId: "abc123",
67
+ listId: "def456",
68
+ apiToken: "your-cloudflare-api-token",
69
+ comment: "Blocked by liberstudio/cloudflare-list",
70
+ logPath: "/var/log/nestjs-attacks.log",
71
+ excludedPaths: ["/api/health", "/api/webhook"],
72
+ silentPaths: ["/auth/me", "/auth/refresh"],
73
+ verbose: false,
41
74
  }),
42
75
  ],
43
76
  })
44
77
  export class AppModule {}
45
78
  ```
46
79
 
47
- ## Versione Asincrona con ConfigModule
80
+ ### Asincrona (`forRootAsync`) con `ConfigModule`
48
81
 
49
82
  ```typescript
50
83
  import { CloudflareAttacksModule } from "@liberstudio/cloudflare-list";
@@ -53,16 +86,16 @@ import { CloudflareAttacksModule } from "@liberstudio/cloudflare-list";
53
86
  imports: [
54
87
  CloudflareAttacksModule.forRootAsync({
55
88
  imports: [ConfigModule],
56
- inject: [ConfigService],
89
+ inject: [ConfigService],
57
90
  useFactory: (config: ConfigService) => ({
58
- apiToken: config.getOrThrow<string>("CLOUDFLARE_API_TOKEN"),
91
+ apiToken: config.getOrThrow<string>("CLOUDFLARE_API_TOKEN"),
59
92
  accountId: config.getOrThrow<string>("CLOUDFLARE_ACCOUNT_ID"),
60
- listId: config.getOrThrow<string>("CLOUDFLARE_LIST_ID"),
61
- comment: config.get<string>("CLOUDFLARE_LIST_COMMENT") || "Blocked",
62
- logPath: config.get<string>("CLOUDFLARE_LIST_LOG_PATH") || "/var/log/nestjs-attacks.log",
93
+ listId: config.getOrThrow<string>("CLOUDFLARE_LIST_ID"),
94
+ comment: config.get<string>("CLOUDFLARE_LIST_COMMENT") || "Blocked",
95
+ logPath: config.get<string>("CLOUDFLARE_LIST_LOG_PATH") || "/var/log/nestjs-attacks.log",
63
96
  excludedPaths: ["/api/health", "/api/webhook", /^\/api\/public\/.*/],
64
- silentPaths: ["/auth/me", "/auth/refresh"],
65
- verbose: false, // default: false — log solo riga breve; true = log completo con body, query, stack
97
+ silentPaths: ["/auth/me", "/auth/refresh"],
98
+ verbose: false,
66
99
  }),
67
100
  }),
68
101
  ],
@@ -70,6 +103,28 @@ import { CloudflareAttacksModule } from "@liberstudio/cloudflare-list";
70
103
  export class AppModule {}
71
104
  ```
72
105
 
106
+ ---
107
+
108
+ ## Opzioni di configurazione
109
+
110
+ | Opzione | Tipo | Obbligatorio | Descrizione |
111
+ |---|---|---|---|
112
+ | `apiToken` | `string` | si | Token API Cloudflare |
113
+ | `accountId` | `string` | si | ID account Cloudflare |
114
+ | `listId` | `string` | si | ID della IP List Cloudflare |
115
+ | `comment` | `string` | si | Commento aggiunto ad ogni IP bloccato |
116
+ | `logPath` | `string` | si | Percorso del file di log degli attacchi |
117
+ | `excludedPaths` | `(string \| RegExp)[]` | no | Path esclusi da ogni rilevamento e log |
118
+ | `silentPaths` | `(string \| RegExp)[]` | no | Path bloccati su Cloudflare ma senza log su file |
119
+ | `verbose` | `boolean` | no | Se `true`, il log include body, query, params e stack |
120
+
121
+ ### `excludedPaths` vs `silentPaths`
122
+
123
+ - **`excludedPaths`**: il path viene ignorato completamente — nessun log, nessun blocco. Usato per path infrastrutturali (`/health`, `/webhook`).
124
+ - **`silentPaths`**: il path viene bloccato su Cloudflare ma non viene scritto sul file di log. Usato per path che generano 404 legittimi frequenti (`/auth/me`, `/auth/refresh`).
125
+
126
+ ---
127
+
73
128
  ## License
74
129
 
75
130
  This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -5,6 +5,9 @@ export declare class AttacksService {
5
5
  private readonly options;
6
6
  private readonly logger;
7
7
  private readonly API_URL;
8
+ private readonly ASN_LOOKUP_URL;
8
9
  constructor(httpService: HttpService, options: CloudflareAttacksOptions);
9
- updateIpList(ip: string): Promise<CloudflareResponse>;
10
+ blockIp(rawIp: string): Promise<CloudflareResponse>;
11
+ private lookupAsnRange;
12
+ updateIpList(cidr: string): Promise<CloudflareResponse>;
10
13
  }
@@ -28,13 +28,33 @@ let AttacksService = class AttacksService {
28
28
  this.options = options;
29
29
  this.logger = new common_1.Logger("AttackLogger");
30
30
  this.API_URL = `https://api.cloudflare.com/client/v4/accounts`;
31
+ this.ASN_LOOKUP_URL = `https://api.hackertarget.com/aslookup`;
31
32
  }
32
- async updateIpList(ip) {
33
+ async blockIp(rawIp) {
34
+ const asnRange = await this.lookupAsnRange(rawIp);
35
+ if (asnRange) {
36
+ this.logger.log(`ASN range trovato per ${rawIp}: ${asnRange} — blocco intera subnet`);
37
+ return this.updateIpList(asnRange);
38
+ }
39
+ const cidr = (0, utils_1.normalizeIp)(rawIp);
40
+ if (!cidr)
41
+ throw new Error(`IP non valido: ${rawIp}`);
42
+ return this.updateIpList(cidr);
43
+ }
44
+ async lookupAsnRange(ip) {
45
+ try {
46
+ const response = await (0, rxjs_1.firstValueFrom)(this.httpService.get(`${this.ASN_LOOKUP_URL}/?q=${ip}&output=json`));
47
+ return response.data?.asn_range ?? null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ async updateIpList(cidr) {
33
54
  const { accountId, listId, apiToken, comment } = this.options;
34
55
  const url = `${this.API_URL}/${accountId}/rules/lists/${listId}/items`;
35
- this.logger.error(`Aggiungo IP ${ip} alla lista Cloudflare`);
36
- const formattedIp = ip.includes("/") ? ip : `${ip}/32`;
37
- const body = [{ ip: formattedIp, comment }];
56
+ this.logger.log(`Aggiungo ${cidr} alla lista Cloudflare`);
57
+ const body = [{ ip: cidr, comment }];
38
58
  try {
39
59
  const response = await (0, rxjs_1.firstValueFrom)(this.httpService.post(url, body, {
40
60
  headers: {
@@ -50,10 +50,10 @@ let AllExceptionsFilter = AllExceptionsFilter_1 = class AllExceptionsFilter {
50
50
  if (!isSilentPath) {
51
51
  const verbose = this.options?.verbose === true;
52
52
  if (status >= 500) {
53
- this.logger.error(`[${ip}] [${request.method}] ${request.url} → ${status}`, verbose ? JSON.stringify(errorLog, null, 2) : undefined);
53
+ this.logger.error(`[${ip}] [${request.method}] ${request.url} → ${status}`, verbose ? JSON.stringify(errorLog, null, 2) : message);
54
54
  }
55
55
  else {
56
- this.logger.warn(`[${ip}] [${request.method}] ${request.url} → ${status}`, verbose ? JSON.stringify(errorLog, null, 2) : undefined);
56
+ this.logger.warn(`[${ip}] [${request.method}] ${request.url} → ${status}`, verbose ? JSON.stringify(errorLog, null, 2) : message);
57
57
  }
58
58
  }
59
59
  response.status(status).json({
@@ -0,0 +1,6 @@
1
+ export interface AsnLookupResponse {
2
+ asn: string;
3
+ asn_name: string;
4
+ asn_range: string;
5
+ ip: string;
6
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -1,2 +1,3 @@
1
+ export * from './asn-lookup.interface';
1
2
  export * from './cloudflare-attacks.interface';
2
3
  export * from './cloudflare-response.interface';
@@ -14,5 +14,6 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./asn-lookup.interface"), exports);
17
18
  __exportStar(require("./cloudflare-attacks.interface"), exports);
18
19
  __exportStar(require("./cloudflare-response.interface"), exports);
@@ -16,5 +16,6 @@ export declare class AttackLoggerMiddleware implements NestMiddleware, OnModuleD
16
16
  private sanitize;
17
17
  private ensureDirectoryExists;
18
18
  private isExcluded;
19
+ private isSilent;
19
20
  onModuleDestroy(): void;
20
21
  }
@@ -52,25 +52,32 @@ let AttackLoggerMiddleware = AttackLoggerMiddleware_1 = class AttackLoggerMiddle
52
52
  const ip = (0, utils_1.getClientIp)(req);
53
53
  if (!ip || this.isThrottled(ip))
54
54
  return;
55
- const entry = {
56
- timestamp: new Date().toISOString(),
57
- type: "ATTACK",
58
- ip,
59
- method: req.method,
60
- url: this.sanitize(req.url),
61
- };
62
- const line = JSON.stringify(entry) + "\n";
63
- if (!this.stream.write(line)) {
64
- this.stream.once("drain", () => {
65
- this.logger.debug("Stream del log degli attacchi svuotato");
66
- });
55
+ const silent = this.isSilent(req.url);
56
+ if (!silent) {
57
+ const entry = {
58
+ timestamp: new Date().toISOString(),
59
+ type: "ATTACK",
60
+ ip,
61
+ method: req.method,
62
+ url: this.sanitize(req.url),
63
+ ...(this.options.verbose && {
64
+ query: req.query,
65
+ body: req.body,
66
+ }),
67
+ };
68
+ const line = JSON.stringify(entry) + "\n";
69
+ if (!this.stream.write(line)) {
70
+ this.stream.once("drain", () => {
71
+ this.logger.debug("Stream del log degli attacchi svuotato");
72
+ });
73
+ }
67
74
  }
68
75
  const cidr = (0, utils_1.normalizeIp)(ip);
69
76
  if (!cidr) {
70
77
  this.logger.warn(`IP non valido: ${ip}`);
71
78
  return;
72
79
  }
73
- this.attSrv.updateIpList(cidr).catch((err) => {
80
+ this.attSrv.blockIp(ip).catch((err) => {
74
81
  const msg = err instanceof Error ? err.message : "Errore sconosciuto";
75
82
  this.logger.error(`Aggiornamento Cloudflare fallito: ${msg}`);
76
83
  });
@@ -98,6 +105,9 @@ let AttackLoggerMiddleware = AttackLoggerMiddleware_1 = class AttackLoggerMiddle
98
105
  const excluded = this.options.excludedPaths ?? [];
99
106
  return excluded.some((p) => (p instanceof RegExp ? p.test(url) : url.startsWith(p)));
100
107
  }
108
+ isSilent(url) {
109
+ return (this.options.silentPaths ?? []).some((p) => p instanceof RegExp ? p.test(url) : url.startsWith(p));
110
+ }
101
111
  onModuleDestroy() {
102
112
  this.stream.end();
103
113
  for (const timeout of this.recentIps.values()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liberstudio/cloudflare-list",
3
- "version": "2.2.9",
3
+ "version": "2.3.1",
4
4
  "description": "Modulo NestJS per gestione IP List Cloudflare",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",