@liberstudio/cloudflare-list 2.3.9 → 2.4.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,183 +1,184 @@
1
- # @liberstudio/cloudflare-list
2
-
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
- └─ allowedIps? → skip (log only)
15
- └─ IP Cloudflare? → skip (log only)
16
- └─ ASN lookup su api.hackertarget.com
17
- ├─ range trovato → blocca intera subnet su Cloudflare
18
- └─ fallback → blocca singolo IP /32 o /128
19
- ```
20
-
21
- ### Filtraggio automatico
22
-
23
- Il modulo ignora automaticamente:
24
- - IP nella whitelist `allowedIps` (configurazione statica)
25
- - IP appartenenti ai range ufficiali Cloudflare (scaricati e cachati 24h)
26
- - IP privati (10.x, 192.168.x, 172.16–31.x)
27
- - Loopback (127.0.0.1, ::1)
28
- - IPv4-mapped IPv6 (::ffff:x.x.x.x → convertiti in IPv4)
29
-
30
- ### Gestione errori e log
31
-
32
- - `AllExceptionsFilter` globale cattura tutte le eccezioni non gestite e le logga a console con IP, metodo, path e status code
33
- - Con `verbose: true` il log include anche body, query, params e stack trace
34
-
35
- ---
36
-
37
- ## Prima di iniziare su Cloudflare
38
-
39
- 1. Creare un account Cloudflare
40
- 2. Scegliere il dominio da utilizzare
41
- 3. **Manage Account > Configuration > Lists > Create List** (tipo: IP)
42
- 4. **Account API tokens > Create Token > Create Custom Token**
43
- - Permission: `Account > Account WAF > Edit`
44
- - Permission: `Account > Account Filter Lists > Edit`
45
- 5. **Security > WAF > Custom Rules > Create Rule**
46
- - Field: `IP Source Address` — `is in` — scegli la lista creata
47
- - Action: `Block`
48
- 6. Salvare la regola
49
-
50
- ---
51
-
52
- ## Installazione
53
-
54
- ```bash
55
- npm install @liberstudio/cloudflare-list
56
- ```
57
-
58
- ---
59
-
60
- ## Configurazione
61
-
62
- ### Statica (`forRoot`)
63
-
64
- ```typescript
65
- import { CloudflareAttacksModule } from "@liberstudio/cloudflare-list";
66
-
67
- @Module({
68
- imports: [
69
- CloudflareAttacksModule.forRoot({
70
- accountId: "abc123",
71
- listId: "def456",
72
- apiToken: "your-cloudflare-api-token",
73
- comment: "Blocked by liberstudio/cloudflare-list",
74
- logPath: "/var/log/nestjs-attacks.log",
75
- allowedIps: ["1.2.3.4", "10.0.0.0/8"],
76
- excludedPaths: ["/api/health", "/api/webhook"],
77
- silentPaths: ["/auth/me", "/auth/refresh"],
78
- verbose: false,
79
- whatsappNotify: { phone: "393331234567" },
80
- }),
81
- ],
82
- })
83
- export class AppModule {}
84
- ```
85
-
86
- ### Asincrona (`forRootAsync`) con `ConfigModule`
87
-
88
- ```typescript
89
- import { CloudflareAttacksModule } from "@liberstudio/cloudflare-list";
90
-
91
- @Module({
92
- imports: [
93
- CloudflareAttacksModule.forRootAsync({
94
- imports: [ConfigModule],
95
- inject: [ConfigService],
96
- 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
- comment: config.get<string>("CLOUDFLARE_LIST_COMMENT") || "Blocked",
101
- logPath: config.get<string>("CLOUDFLARE_LIST_LOG_PATH") || "/var/log/nestjs-attacks.log",
102
- allowedIps: ["1.2.3.4", "10.0.0.0/8"],
103
- excludedPaths: ["/api/health", "/api/webhook", /^\/api\/public\/.*/],
104
- silentPaths: ["/auth/me", "/auth/refresh"],
105
- verbose: false,
106
- whatsappNotify: {
107
- phone: config.getOrThrow<string>("WA_NOTIFY_PHONE"),
108
- },
109
- }),
110
- }),
111
- ],
112
- })
113
- export class AppModule {}
114
- ```
115
-
116
- ---
117
-
118
- ## Opzioni di configurazione
119
-
120
- | Opzione | Tipo | Obbligatorio | Descrizione |
121
- |---|---|---|---|
122
- | `apiToken` | `string` | si | Token API Cloudflare |
123
- | `accountId` | `string` | si | ID account Cloudflare |
124
- | `listId` | `string` | si | ID della IP List Cloudflare |
125
- | `comment` | `string` | si | Commento aggiunto ad ogni IP bloccato |
126
- | `logPath` | `string` | si | Percorso del file di log degli attacchi |
127
- | `allowedIps` | `string[]` | no | IP o CIDR mai bloccati (whitelist, supporta IPv4 e IPv6) |
128
- | `excludedPaths` | `(string \| RegExp)[]` | no | Path esclusi da ogni rilevamento e log |
129
- | `silentPaths` | `(string \| RegExp)[]` | no | Path bloccati su Cloudflare ma senza log su file |
130
- | `verbose` | `boolean` | no | Se `true`, il log include body, query, params e stack |
131
- | `whatsappNotify` | `{ phone: string }` | no | Invia una notifica WhatsApp quando un IP viene bannato |
132
-
133
- ### `whatsappNotify` — notifiche ban su WhatsApp
134
-
135
- Quando configurato, invia un messaggio WhatsApp ad un numero specifico ogni volta che un IP viene bloccato su Cloudflare. Richiede le variabili d'ambiente `WA_URL`, `WA_USER`, `WA_PASSWORD` e `CLOUDFLARE_WA_DEVICE_ID` già presenti nel progetto (lette automaticamente da `process.env`).
136
-
137
- ```typescript
138
- whatsappNotify: {
139
- phone: "393331234567", // numero con prefisso internazionale, senza +
140
- }
141
- ```
142
-
143
- Il messaggio inviato sarà:
144
- ```
145
- 🚨 IP bannato su Cloudflare
146
- IP: 1.2.3.4
147
- Subnet bloccata: 1.2.0.0/16
148
- ```
149
-
150
- Variabili d'ambiente richieste (lette automaticamente da `process.env`):
151
- ```env
152
- WA_URL=https://messaggistica.liberdesign.eu
153
- WA_USER=your-user
154
- WA_PASSWORD=your-password
155
- CLOUDFLARE_WA_DEVICE_ID=your-device-id
156
- WA_NOTIFY_PHONE=393331234567
157
- ```
158
-
159
- ### `allowedIps` — whitelist server
160
-
161
- Lista di IP o subnet CIDR che non vengono mai bloccati, indipendentemente dal comportamento. Utile per i propri server, proxy interni, CDN proprietarie. Supporta IPv4, CIDR IPv4, IPv6 e CIDR IPv6:
162
-
163
- ```typescript
164
- allowedIps: [
165
- "1.2.3.4", // IP singolo IPv4
166
- "10.0.0.0/8", // subnet IPv4
167
- "2001:db8::1", // IP singolo IPv6
168
- "2001:db8::/32", // subnet IPv6
169
- ]
170
- ```
171
-
172
- > I range ufficiali Cloudflare sono esclusi automaticamente senza bisogno di aggiungerli qui.
173
-
174
- ### `excludedPaths` vs `silentPaths`
175
-
176
- - **`excludedPaths`**: il path viene ignorato completamente — nessun log, nessun blocco. Usato per path infrastrutturali (`/health`, `/webhook`).
177
- - **`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`).
178
-
179
- ---
180
-
181
- ## License
182
-
183
- This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
1
+ # @liberstudio/cloudflare-list
2
+
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
+ └─ allowedIps? → skip (log only)
15
+ └─ IP Cloudflare? → skip (log only)
16
+ └─ ASN lookup su api.hackertarget.com
17
+ ├─ range trovato → blocca intera subnet su Cloudflare
18
+ └─ fallback → blocca singolo IP /32 o /128
19
+ ```
20
+
21
+ ### Filtraggio automatico
22
+
23
+ Il modulo ignora automaticamente:
24
+ - IP nella whitelist `allowedIps` (configurazione statica)
25
+ - IP appartenenti ai range ufficiali Cloudflare (scaricati e cachati 24h)
26
+ - IP privati (10.x, 192.168.x, 172.16–31.x)
27
+ - Loopback (127.0.0.1, ::1)
28
+ - IPv4-mapped IPv6 (::ffff:x.x.x.x → convertiti in IPv4)
29
+
30
+ ### Gestione errori e log
31
+
32
+ - `AllExceptionsFilter` globale cattura tutte le eccezioni non gestite e le logga a console con IP, metodo, path e status code
33
+ - Con `verbose: true` il log include anche body, query, params e stack trace
34
+
35
+ ---
36
+
37
+ ## Prima di iniziare su Cloudflare
38
+
39
+ 1. Creare un account Cloudflare
40
+ 2. Scegliere il dominio da utilizzare
41
+ 3. **Manage Account > Configuration > Lists > Create List** (tipo: IP)
42
+ 4. **Account API tokens > Create Token > Create Custom Token**
43
+ - Permission: `Account > Account WAF > Edit`
44
+ - Permission: `Account > Account Filter Lists > Edit`
45
+ 5. **Security > WAF > Custom Rules > Create Rule**
46
+ - Field: `IP Source Address` — `is in` — scegli la lista creata
47
+ - Action: `Block`
48
+ 6. Salvare la regola
49
+
50
+ ---
51
+
52
+ ## Installazione
53
+
54
+ ```bash
55
+ npm install @liberstudio/cloudflare-list
56
+ ```
57
+
58
+ ---
59
+
60
+ ## Configurazione
61
+
62
+ ### Statica (`forRoot`)
63
+
64
+ ```typescript
65
+ import { CloudflareAttacksModule } from "@liberstudio/cloudflare-list";
66
+
67
+ @Module({
68
+ imports: [
69
+ CloudflareAttacksModule.forRoot({
70
+ accountId: "abc123",
71
+ listId: "def456",
72
+ apiToken: "your-cloudflare-api-token",
73
+ comment: "Blocked by liberstudio/cloudflare-list",
74
+ logPath: "/var/log/nestjs-attacks.log",
75
+ allowedIps: ["1.2.3.4", "10.0.0.0/8"],
76
+ excludedPaths: ["/api/health", "/api/webhook"],
77
+ silentPaths: ["/auth/me", "/auth/refresh"],
78
+ verbose: false,
79
+ whatsappNotify: { phone: "393331234567" },
80
+ }),
81
+ ],
82
+ })
83
+ export class AppModule {}
84
+ ```
85
+
86
+ ### Asincrona (`forRootAsync`) con `ConfigModule`
87
+
88
+ ```typescript
89
+ import { CloudflareAttacksModule } from "@liberstudio/cloudflare-list";
90
+
91
+ @Module({
92
+ imports: [
93
+ CloudflareAttacksModule.forRootAsync({
94
+ imports: [ConfigModule],
95
+ inject: [ConfigService],
96
+ 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
+ comment: config.get<string>("CLOUDFLARE_LIST_COMMENT") || "Blocked",
101
+ logPath: config.get<string>("CLOUDFLARE_LIST_LOG_PATH") || "/var/log/nestjs-attacks.log",
102
+ allowedIps: ["1.2.3.4", "10.0.0.0/8"],
103
+ excludedPaths: ["/api/health", "/api/webhook", /^\/api\/public\/.*/],
104
+ silentPaths: ["/auth/me", "/auth/refresh"],
105
+ verbose: false,
106
+ whatsappNotify: {
107
+ phone: config.getOrThrow<string>("WA_NOTIFY_PHONE"),
108
+ },
109
+ }),
110
+ }),
111
+ ],
112
+ })
113
+ export class AppModule {}
114
+ ```
115
+
116
+ ---
117
+
118
+ ## Opzioni di configurazione
119
+
120
+ | Opzione | Tipo | Obbligatorio | Descrizione |
121
+ |---|---|---|---|
122
+ | `apiToken` | `string` | si | Token API Cloudflare |
123
+ | `accountId` | `string` | si | ID account Cloudflare |
124
+ | `listId` | `string` | si | ID della IP List Cloudflare |
125
+ | `comment` | `string` | si | Commento aggiunto ad ogni IP bloccato |
126
+ | `logPath` | `string` | si | Percorso del file di log degli attacchi |
127
+ | `allowedIps` | `string[]` | no | IP o CIDR mai bloccati (whitelist, supporta IPv4 e IPv6) |
128
+ | `excludedPaths` | `(string \| RegExp)[]` | no | Path esclusi da ogni rilevamento e log |
129
+ | `silentPaths` | `(string \| RegExp)[]` | no | Path bloccati su Cloudflare ma senza log su file |
130
+ | `verbose` | `boolean` | no | Se `true`, il log include body, query, params e stack |
131
+ | `whatsappNotify` | `{ phone: string }` | no | Invia una notifica WhatsApp quando un IP viene bannato |
132
+
133
+ ### `whatsappNotify` — notifiche ban su WhatsApp
134
+
135
+ Quando configurato, invia un messaggio WhatsApp ad un numero specifico ogni volta che un IP viene bloccato su Cloudflare. Richiede le variabili d'ambiente `WA_URL`, `WA_USER`, `WA_PASSWORD` e `CLOUDFLARE_WA_DEVICE_ID` già presenti nel progetto (lette automaticamente da `process.env`).
136
+
137
+ ```typescript
138
+ whatsappNotify: {
139
+ phone: "393331234567", // numero con prefisso internazionale, senza +
140
+ }
141
+ ```
142
+
143
+ Il messaggio inviato sarà:
144
+ ```
145
+ 🚨 IP bannato su Cloudflare
146
+ Provenienza: Blocked by liberstudio/cloudflare-list
147
+ IP: 1.2.3.4
148
+ Subnet bloccata: 1.2.0.0/16
149
+ ```
150
+
151
+ Variabili d'ambiente richieste (lette automaticamente da `process.env`):
152
+ ```env
153
+ WA_URL=https://messaggistica.liberdesign.eu
154
+ WA_USER=your-user
155
+ WA_PASSWORD=your-password
156
+ CLOUDFLARE_WA_DEVICE_ID=your-device-id
157
+ WA_NOTIFY_PHONE=393331234567
158
+ ```
159
+
160
+ ### `allowedIps` — whitelist server
161
+
162
+ Lista di IP o subnet CIDR che non vengono mai bloccati, indipendentemente dal comportamento. Utile per i propri server, proxy interni, CDN proprietarie. Supporta IPv4, CIDR IPv4, IPv6 e CIDR IPv6:
163
+
164
+ ```typescript
165
+ allowedIps: [
166
+ "1.2.3.4", // IP singolo IPv4
167
+ "10.0.0.0/8", // subnet IPv4
168
+ "2001:db8::1", // IP singolo IPv6
169
+ "2001:db8::/32", // subnet IPv6
170
+ ]
171
+ ```
172
+
173
+ > I range ufficiali Cloudflare sono esclusi automaticamente senza bisogno di aggiungerli qui.
174
+
175
+ ### `excludedPaths` vs `silentPaths`
176
+
177
+ - **`excludedPaths`**: il path viene ignorato completamente — nessun log, nessun blocco. Usato per path infrastrutturali (`/health`, `/webhook`).
178
+ - **`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`).
179
+
180
+ ---
181
+
182
+ ## License
183
+
184
+ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -39,6 +39,10 @@ let AttacksService = class AttacksService {
39
39
  this.logger.log(`IP ${rawIp} appartiene al range Cloudflare — ban saltato`);
40
40
  return null;
41
41
  }
42
+ if (await (0, utils_1.isGoogleBotIp)(rawIp)) {
43
+ this.logger.log(`IP ${rawIp} è un crawler Google — ban saltato`);
44
+ return null;
45
+ }
42
46
  const asnRange = await this.lookupAsnRange(rawIp);
43
47
  if (asnRange) {
44
48
  this.logger.error(`ASN range trovato per ${rawIp}: ${asnRange} — blocco intera subnet`);
@@ -66,7 +70,7 @@ let AttacksService = class AttacksService {
66
70
  return;
67
71
  }
68
72
  const auth = Buffer.from(`${user}:${password}`).toString("base64");
69
- const message = `🚨 IP bannato su Cloudflare\nIP: ${rawIp}\nSubnet bloccata: ${cidr}`;
73
+ const message = `🚨 IP bannato su Cloudflare\nProvenienza: ${this.options.comment}\nIP: ${rawIp}\nSubnet bloccata: ${cidr}`;
70
74
  (0, rxjs_1.firstValueFrom)(this.httpService.post(`${url}/send/message`, { phone: wa.phone, message }, { headers: { Authorization: `Basic ${auth}`, "X-Device-Id": deviceId } })).catch((err) => {
71
75
  const msg = err instanceof Error ? err.message : "Errore sconosciuto";
72
76
  this.logger.warn(`Notifica WhatsApp fallita: ${msg}`);
@@ -0,0 +1,10 @@
1
+ /** Rimuove il suffisso CIDR, fa trim e srotola gli IPv4-mapped IPv6 (`::ffff:`). */
2
+ export declare function purifyIp(ip: string): string;
3
+ export declare function ipv4ToInt(ip: string): number;
4
+ export declare function ipv6ToBigInt(ip: string): bigint;
5
+ export declare function isInCidrV4(ip: string, cidr: string): boolean;
6
+ export declare function isInCidrV6(ip: string, cidr: string): boolean;
7
+ /** Verifica se un IP "puro" appartiene a uno qualsiasi dei CIDR della sua versione. */
8
+ export declare function isIpInAnyCidr(pureIp: string, cidrs: string[], version: 4 | 6): boolean;
9
+ /** Ritorna la versione IP (4 | 6) di un valore già "purificato", oppure null. */
10
+ export declare function ipVersion(pureIp: string): 4 | 6 | null;
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.purifyIp = purifyIp;
4
+ exports.ipv4ToInt = ipv4ToInt;
5
+ exports.ipv6ToBigInt = ipv6ToBigInt;
6
+ exports.isInCidrV4 = isInCidrV4;
7
+ exports.isInCidrV6 = isInCidrV6;
8
+ exports.isIpInAnyCidr = isIpInAnyCidr;
9
+ exports.ipVersion = ipVersion;
10
+ const net_1 = require("net");
11
+ /** Rimuove il suffisso CIDR, fa trim e srotola gli IPv4-mapped IPv6 (`::ffff:`). */
12
+ function purifyIp(ip) {
13
+ let pure = ip.includes("/") ? ip.split("/")[0] : ip;
14
+ pure = pure.trim();
15
+ if (pure.startsWith("::ffff:"))
16
+ pure = pure.slice("::ffff:".length);
17
+ return pure;
18
+ }
19
+ function ipv4ToInt(ip) {
20
+ return ip.split(".").reduce((acc, oct) => (acc << 8) + Number(oct), 0) >>> 0;
21
+ }
22
+ function ipv6ToBigInt(ip) {
23
+ const [headStr, tailStr] = ip.includes("::") ? ip.split("::") : [ip, ""];
24
+ const head = headStr ? headStr.split(":") : [];
25
+ const tail = tailStr ? tailStr.split(":") : [];
26
+ const missing = 8 - head.length - tail.length;
27
+ const groups = [...head, ...Array(missing).fill("0"), ...tail];
28
+ return groups.reduce((acc, g) => (acc << 16n) + BigInt(parseInt(g || "0", 16)), 0n);
29
+ }
30
+ function isInCidrV4(ip, cidr) {
31
+ const [range, bitsStr] = cidr.split("/");
32
+ const bits = bitsStr === undefined ? 32 : Number(bitsStr);
33
+ if (bits === 0)
34
+ return true;
35
+ const mask = (0xffffffff << (32 - bits)) >>> 0;
36
+ return (ipv4ToInt(ip) & mask) === (ipv4ToInt(range) & mask);
37
+ }
38
+ function isInCidrV6(ip, cidr) {
39
+ const [range, bitsStr] = cidr.split("/");
40
+ const prefix = bitsStr === undefined ? 128 : Number(bitsStr);
41
+ if (prefix === 0)
42
+ return true;
43
+ const mask = ((1n << 128n) - 1n) ^ ((1n << BigInt(128 - prefix)) - 1n);
44
+ return (ipv6ToBigInt(ip) & mask) === (ipv6ToBigInt(range) & mask);
45
+ }
46
+ /** Verifica se un IP "puro" appartiene a uno qualsiasi dei CIDR della sua versione. */
47
+ function isIpInAnyCidr(pureIp, cidrs, version) {
48
+ return version === 4
49
+ ? cidrs.some((cidr) => isInCidrV4(pureIp, cidr))
50
+ : cidrs.some((cidr) => isInCidrV6(pureIp, cidr));
51
+ }
52
+ /** Ritorna la versione IP (4 | 6) di un valore già "purificato", oppure null. */
53
+ function ipVersion(pureIp) {
54
+ const version = (0, net_1.isIP)(pureIp);
55
+ return version === 0 ? null : version;
56
+ }
@@ -6,7 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.isAllowedIp = isAllowedIp;
7
7
  exports.isCloudflareIp = isCloudflareIp;
8
8
  const axios_1 = __importDefault(require("axios"));
9
- const net_1 = require("net");
9
+ const cidr_util_1 = require("./cidr.util");
10
10
  const CF_IPS_V4_URL = "https://www.cloudflare.com/ips-v4/";
11
11
  const CF_IPS_V6_URL = "https://www.cloudflare.com/ips-v6/";
12
12
  const TTL_MS = 24 * 60 * 60 * 1000;
@@ -34,71 +34,30 @@ async function loadCloudflareIps() {
34
34
  });
35
35
  return inFlight;
36
36
  }
37
- function ipv4ToInt(ip) {
38
- return ip.split(".").reduce((acc, oct) => (acc << 8) + Number(oct), 0) >>> 0;
39
- }
40
- function ipv6ToBigInt(ip) {
41
- const [headStr, tailStr] = ip.includes("::") ? ip.split("::") : [ip, ""];
42
- const head = headStr ? headStr.split(":") : [];
43
- const tail = tailStr ? tailStr.split(":") : [];
44
- const missing = 8 - head.length - tail.length;
45
- const groups = [...head, ...Array(missing).fill("0"), ...tail];
46
- return groups.reduce((acc, g) => (acc << 16n) + BigInt(parseInt(g || "0", 16)), 0n);
47
- }
48
- function isInCidrV4(ip, cidr) {
49
- const [range, bitsStr] = cidr.split("/");
50
- const bits = bitsStr === undefined ? 32 : Number(bitsStr);
51
- if (bits === 0)
52
- return true;
53
- const mask = (0xffffffff << (32 - bits)) >>> 0;
54
- return (ipv4ToInt(ip) & mask) === (ipv4ToInt(range) & mask);
55
- }
56
- function isInCidrV6(ip, cidr) {
57
- const [range, bitsStr] = cidr.split("/");
58
- const prefix = bitsStr === undefined ? 128 : Number(bitsStr);
59
- if (prefix === 0)
60
- return true;
61
- const mask = ((1n << 128n) - 1n) ^ ((1n << BigInt(128 - prefix)) - 1n);
62
- return (ipv6ToBigInt(ip) & mask) === (ipv6ToBigInt(range) & mask);
63
- }
64
37
  function isAllowedIp(ip, allowedIps) {
65
38
  if (!ip || !allowedIps.length)
66
39
  return false;
67
- let pure = ip.includes("/") ? ip.split("/")[0] : ip;
68
- pure = pure.trim();
69
- if (pure.startsWith("::ffff:"))
70
- pure = pure.slice("::ffff:".length);
71
- const version = (0, net_1.isIP)(pure);
40
+ const pure = (0, cidr_util_1.purifyIp)(ip);
41
+ const version = (0, cidr_util_1.ipVersion)(pure);
72
42
  if (!version)
73
43
  return false;
74
44
  return allowedIps.some((entry) => {
75
- let entryPure = entry.includes("/") ? entry.split("/")[0] : entry;
76
- entryPure = entryPure.trim();
77
- if (entryPure.startsWith("::ffff:"))
78
- entryPure = entryPure.slice("::ffff:".length);
79
- const entryVersion = (0, net_1.isIP)(entryPure);
45
+ const entryVersion = (0, cidr_util_1.ipVersion)((0, cidr_util_1.purifyIp)(entry));
80
46
  if (!entryVersion || entryVersion !== version)
81
47
  return false;
82
- return version === 4
83
- ? isInCidrV4(pure, entry)
84
- : isInCidrV6(pure, entry);
48
+ return (0, cidr_util_1.isIpInAnyCidr)(pure, [entry], version);
85
49
  });
86
50
  }
87
51
  async function isCloudflareIp(ip) {
88
52
  if (!ip)
89
53
  return false;
90
- let pure = ip.includes("/") ? ip.split("/")[0] : ip;
91
- pure = pure.trim();
92
- if (pure.startsWith("::ffff:"))
93
- pure = pure.slice("::ffff:".length);
94
- const version = (0, net_1.isIP)(pure);
54
+ const pure = (0, cidr_util_1.purifyIp)(ip);
55
+ const version = (0, cidr_util_1.ipVersion)(pure);
95
56
  if (!version)
96
57
  return false;
97
58
  try {
98
59
  const { v4, v6 } = await loadCloudflareIps();
99
- return version === 4
100
- ? v4.some((cidr) => isInCidrV4(pure, cidr))
101
- : v6.some((cidr) => isInCidrV6(pure, cidr));
60
+ return (0, cidr_util_1.isIpInAnyCidr)(pure, version === 4 ? v4 : v6, version);
102
61
  }
103
62
  catch {
104
63
  return false;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * True se l'IP appartiene ai crawler Google (Googlebot, special crawlers, fetcher).
3
+ *
4
+ * Strategia hybrid:
5
+ * 1. match veloce sulle liste IP ufficiali (cache 24h, auto-aggiornate);
6
+ * 2. fallback reverse DNS anti-spoofing se non presente nelle liste.
7
+ *
8
+ * Fail-open: se né le liste né il DNS riescono a verificare, ritorna `true`
9
+ * (non bannare) per non rischiare di bloccare Googlebot durante un disservizio.
10
+ */
11
+ export declare function isGoogleBotIp(ip: string): Promise<boolean>;
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.isGoogleBotIp = isGoogleBotIp;
7
+ const axios_1 = __importDefault(require("axios"));
8
+ const dns_1 = require("dns");
9
+ const cidr_util_1 = require("./cidr.util");
10
+ // Liste IP ufficiali pubblicate (e aggiornate) da Google.
11
+ const GOOGLE_IP_LIST_URLS = [
12
+ "https://developers.google.com/static/search/apis/ipranges/googlebot.json",
13
+ "https://developers.google.com/static/search/apis/ipranges/special-crawlers.json",
14
+ "https://developers.google.com/static/search/apis/ipranges/user-triggered-fetchers.json",
15
+ ];
16
+ // Domini dei crawler verificati da Google (per il fallback reverse DNS).
17
+ const GOOGLE_HOSTS = [".googlebot.com", ".google.com", ".googleusercontent.com"];
18
+ const TTL_MS = 24 * 60 * 60 * 1000;
19
+ let cache = null;
20
+ let inFlight = null;
21
+ async function loadGoogleIps() {
22
+ if (cache && Date.now() < cache.expiresAt)
23
+ return cache;
24
+ if (inFlight)
25
+ return inFlight;
26
+ inFlight = (async () => {
27
+ const responses = await Promise.all(GOOGLE_IP_LIST_URLS.map((url) => axios_1.default.get(url)));
28
+ const v4 = [];
29
+ const v6 = [];
30
+ for (const res of responses) {
31
+ for (const prefix of res.data?.prefixes ?? []) {
32
+ if (prefix.ipv4Prefix)
33
+ v4.push(prefix.ipv4Prefix);
34
+ if (prefix.ipv6Prefix)
35
+ v6.push(prefix.ipv6Prefix);
36
+ }
37
+ }
38
+ cache = { v4, v6, expiresAt: Date.now() + TTL_MS };
39
+ return cache;
40
+ })().finally(() => {
41
+ inFlight = null;
42
+ });
43
+ return inFlight;
44
+ }
45
+ /**
46
+ * Verifica anti-spoofing via reverse + forward DNS (metodo raccomandato da Google).
47
+ * Ritorna `true`/`false` se determinabile, `null` se la risoluzione DNS fallisce.
48
+ */
49
+ async function verifyByReverseDns(pureIp, version) {
50
+ try {
51
+ const hostnames = await dns_1.promises.reverse(pureIp);
52
+ const host = hostnames.find((h) => GOOGLE_HOSTS.some((d) => h.endsWith(d)));
53
+ if (!host)
54
+ return false; // reverse DNS risolto ma non è un dominio Google
55
+ const resolved = version === 4 ? await dns_1.promises.resolve4(host) : await dns_1.promises.resolve6(host);
56
+ return resolved.some((addr) => (0, cidr_util_1.purifyIp)(addr) === pureIp);
57
+ }
58
+ catch {
59
+ return null; // nessun PTR / errore DNS: non determinabile
60
+ }
61
+ }
62
+ /**
63
+ * True se l'IP appartiene ai crawler Google (Googlebot, special crawlers, fetcher).
64
+ *
65
+ * Strategia hybrid:
66
+ * 1. match veloce sulle liste IP ufficiali (cache 24h, auto-aggiornate);
67
+ * 2. fallback reverse DNS anti-spoofing se non presente nelle liste.
68
+ *
69
+ * Fail-open: se né le liste né il DNS riescono a verificare, ritorna `true`
70
+ * (non bannare) per non rischiare di bloccare Googlebot durante un disservizio.
71
+ */
72
+ async function isGoogleBotIp(ip) {
73
+ if (!ip)
74
+ return false;
75
+ const pure = (0, cidr_util_1.purifyIp)(ip);
76
+ const version = (0, cidr_util_1.ipVersion)(pure);
77
+ if (!version)
78
+ return false;
79
+ let listAvailable = false;
80
+ try {
81
+ const { v4, v6 } = await loadGoogleIps();
82
+ listAvailable = true;
83
+ if ((0, cidr_util_1.isIpInAnyCidr)(pure, version === 4 ? v4 : v6, version))
84
+ return true;
85
+ }
86
+ catch {
87
+ listAvailable = false;
88
+ }
89
+ const dnsResult = await verifyByReverseDns(pure, version);
90
+ if (dnsResult !== null)
91
+ return dnsResult;
92
+ // DNS non determinabile: se la lista era disponibile e l'IP non c'era → non è Google;
93
+ // se nemmeno la lista era raggiungibile → fail-open.
94
+ return !listAvailable;
95
+ }
@@ -1,4 +1,6 @@
1
+ export * from './cidr.util';
1
2
  export * from './cloudflare.constants';
2
3
  export * from './cloudflare-ips.util';
3
4
  export * from './get-client-ip.util';
5
+ export * from './googlebot.util';
4
6
  export * from './options.validator';
@@ -14,7 +14,9 @@ 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("./cidr.util"), exports);
17
18
  __exportStar(require("./cloudflare.constants"), exports);
18
19
  __exportStar(require("./cloudflare-ips.util"), exports);
19
20
  __exportStar(require("./get-client-ip.util"), exports);
21
+ __exportStar(require("./googlebot.util"), exports);
20
22
  __exportStar(require("./options.validator"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liberstudio/cloudflare-list",
3
- "version": "2.3.9",
3
+ "version": "2.4.1",
4
4
  "description": "Modulo NestJS per gestione IP List Cloudflare",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -32,14 +32,17 @@
32
32
  "rxjs": ">=7.0.0"
33
33
  },
34
34
  "devDependencies": {
35
- "@nestjs/axios": "^4.0.0",
36
- "@nestjs/common": "^11.0.0",
37
- "@nestjs/core": "^11.1.14",
35
+ "@nestjs/axios": "^4.0.1",
36
+ "@nestjs/common": "^11.1.26",
37
+ "@nestjs/core": "^11.1.26",
38
38
  "@types/express": "^5.0.6",
39
- "@types/node": "^20.9.0",
40
- "axios": "^1.0.0",
41
- "rxjs": "^7.8.0",
39
+ "@types/node": "^20.19.43",
40
+ "axios": "^1.18.0",
41
+ "rxjs": "^7.8.2",
42
42
  "simple-git-hooks": "^2.13.1",
43
43
  "typescript": "^5.0.0"
44
+ },
45
+ "allowScripts": {
46
+ "simple-git-hooks@2.13.1": true
44
47
  }
45
48
  }