@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 +84 -29
- package/dist/attacks.service.d.ts +4 -1
- package/dist/attacks.service.js +24 -4
- package/dist/filters/all-exceptions.filter.js +2 -2
- package/dist/interfaces/asn-lookup.interface.d.ts +6 -0
- package/dist/interfaces/asn-lookup.interface.js +2 -0
- package/dist/interfaces/index.d.ts +1 -0
- package/dist/interfaces/index.js +1 -0
- package/dist/middleware/attack-logger.middleware.d.ts +1 -0
- package/dist/middleware/attack-logger.middleware.js +23 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,16 +1,49 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @liberstudio/cloudflare-list
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
7
|
-
3. Manage Account > Configuration >
|
|
8
|
-
4. Account API tokens > Create Token > Create Custom Token
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
54
|
+
---
|
|
22
55
|
|
|
23
|
-
|
|
56
|
+
## Configurazione
|
|
24
57
|
|
|
25
|
-
|
|
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: "
|
|
34
|
-
listId:
|
|
35
|
-
apiToken:
|
|
36
|
-
comment: "
|
|
37
|
-
logPath:
|
|
38
|
-
excludedPaths: "
|
|
39
|
-
silentPaths: "
|
|
40
|
-
verbose: false,
|
|
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
|
-
|
|
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:
|
|
89
|
+
inject: [ConfigService],
|
|
57
90
|
useFactory: (config: ConfigService) => ({
|
|
58
|
-
apiToken:
|
|
91
|
+
apiToken: config.getOrThrow<string>("CLOUDFLARE_API_TOKEN"),
|
|
59
92
|
accountId: config.getOrThrow<string>("CLOUDFLARE_ACCOUNT_ID"),
|
|
60
|
-
listId:
|
|
61
|
-
comment:
|
|
62
|
-
logPath:
|
|
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:
|
|
65
|
-
verbose: false,
|
|
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
|
-
|
|
10
|
+
blockIp(rawIp: string): Promise<CloudflareResponse>;
|
|
11
|
+
private lookupAsnRange;
|
|
12
|
+
updateIpList(cidr: string): Promise<CloudflareResponse>;
|
|
10
13
|
}
|
package/dist/attacks.service.js
CHANGED
|
@@ -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
|
|
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.
|
|
36
|
-
const
|
|
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) :
|
|
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) :
|
|
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({
|
package/dist/interfaces/index.js
CHANGED
|
@@ -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);
|
|
@@ -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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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.
|
|
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()) {
|