wafaHell 1.0.0__py3-none-any.whl → 1.1.0__py3-none-any.whl
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.
- wafaHell/app.py +3 -3
- wafaHell/logger.py +6 -5
- wafaHell/middleware.py +276 -159
- wafaHell/model.py +11 -0
- wafaHell/panel.py +254 -40
- wafaHell/utils.py +303 -199
- {wafahell-1.0.0.dist-info → wafahell-1.1.0.dist-info}/METADATA +1 -1
- wafahell-1.1.0.dist-info/RECORD +15 -0
- wafahell-1.0.0.dist-info/RECORD +0 -15
- {wafahell-1.0.0.dist-info → wafahell-1.1.0.dist-info}/WHEEL +0 -0
- {wafahell-1.0.0.dist-info → wafahell-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {wafahell-1.0.0.dist-info → wafahell-1.1.0.dist-info}/top_level.txt +0 -0
wafaHell/app.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from middleware import
|
|
1
|
+
from middleware import Wafahell
|
|
2
2
|
from flask import Flask, render_template, request
|
|
3
3
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
|
4
4
|
|
|
@@ -27,5 +27,5 @@ def dashboard():
|
|
|
27
27
|
return "<h1>Dashboard Personalizado</h1><p>Este é o painel de controle personalizado.</p>"
|
|
28
28
|
|
|
29
29
|
if __name__ == '__main__':
|
|
30
|
-
|
|
31
|
-
app.run(debug=True, host='0.0.0.0', port=
|
|
30
|
+
Wafahell(app=app, dashboard_path='/hell/dashboard', block_durantion=15, rate_limit=True, block_ip=False)
|
|
31
|
+
app.run(debug=True, host='0.0.0.0', port=5001)
|
wafaHell/logger.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
from .model import WafLog, get_session
|
|
1
2
|
from datetime import datetime
|
|
2
3
|
import logging
|
|
3
|
-
from model import WafLog, get_session
|
|
4
4
|
import re
|
|
5
5
|
# Handler customizado para salvar no SQLite via SQLAlchemy
|
|
6
6
|
class SQLAlchemyHandler(logging.Handler):
|
|
@@ -94,6 +94,7 @@ class Logger:
|
|
|
94
94
|
def __init__(self, name="WAF", log_file="waf.log", level=logging.INFO):
|
|
95
95
|
self.logger = logging.getLogger(name)
|
|
96
96
|
self.logger.setLevel(level)
|
|
97
|
+
self.logger.propagate = False
|
|
97
98
|
|
|
98
99
|
if not self.logger.handlers:
|
|
99
100
|
formatter = logging.Formatter(
|
|
@@ -111,10 +112,10 @@ class Logger:
|
|
|
111
112
|
file_handler.setFormatter(formatter)
|
|
112
113
|
self.logger.addHandler(file_handler)
|
|
113
114
|
|
|
114
|
-
# Handler 3: Banco de Dados (A Mágica acontece aqui)
|
|
115
|
-
db_handler = SQLAlchemyHandler()
|
|
116
|
-
db_handler.setLevel(logging.INFO) # Salva INFO, WARNING e acima no DB
|
|
117
|
-
self.logger.addHandler(db_handler)
|
|
115
|
+
# # Handler 3: Banco de Dados (A Mágica acontece aqui)
|
|
116
|
+
# db_handler = SQLAlchemyHandler()
|
|
117
|
+
# db_handler.setLevel(logging.INFO) # Salva INFO, WARNING e acima no DB
|
|
118
|
+
# self.logger.addHandler(db_handler)
|
|
118
119
|
|
|
119
120
|
def info(self, msg):
|
|
120
121
|
self.logger.info(msg)
|
wafaHell/middleware.py
CHANGED
|
@@ -1,53 +1,72 @@
|
|
|
1
|
+
from .model import Base, Blocked, WafLog, Whitelist, get_session, engine
|
|
2
|
+
from .logger import Logger
|
|
3
|
+
from .rateLimiter import RateLimiter
|
|
4
|
+
from .panel import setup_dashboard
|
|
5
|
+
from .utils import Admin, seed_default_whitelist
|
|
6
|
+
from .globals import waf_cache
|
|
1
7
|
from datetime import datetime, timedelta, timezone
|
|
2
|
-
import os
|
|
3
8
|
import re
|
|
4
|
-
import subprocess
|
|
5
9
|
import time
|
|
6
10
|
from flask import request as req, abort, g
|
|
7
11
|
from urllib.parse import unquote
|
|
8
|
-
from model import Base, Blocked, WafLog, get_session, engine
|
|
9
|
-
from logger import Logger
|
|
10
|
-
from rateLimiter import RateLimiter
|
|
11
12
|
from sqlalchemy.exc import OperationalError
|
|
12
|
-
from panel import setup_dashboard
|
|
13
|
-
from utils import Admin
|
|
14
13
|
from sqlalchemy import text
|
|
15
|
-
|
|
14
|
+
import hashlib
|
|
15
|
+
import uuid
|
|
16
|
+
import socket
|
|
17
|
+
import ipaddress
|
|
16
18
|
|
|
17
19
|
# Inicializa o RateLimiter
|
|
18
20
|
limiter = RateLimiter(limit=100, window=60)
|
|
19
21
|
|
|
20
|
-
class
|
|
22
|
+
class Wafahell:
|
|
23
|
+
_instance = None
|
|
24
|
+
|
|
25
|
+
def __new__(cls, *args, **kwargs):
|
|
26
|
+
if not cls._instance:
|
|
27
|
+
cls._instance = super(Wafahell, cls).__new__(cls)
|
|
28
|
+
return cls._instance
|
|
29
|
+
|
|
21
30
|
def __init__(self, app=None, block_code=403, block_durantion=5, block_ip=False, log_func=None, monitor_mode=False, rate_limit=False, dashboard_path=None):
|
|
22
|
-
self
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
31
|
+
if not hasattr(self, 'initialized'):
|
|
32
|
+
self.initialized = True
|
|
33
|
+
|
|
34
|
+
self.app = app
|
|
35
|
+
self.block_code = block_code
|
|
36
|
+
self.log = log_func or Logger()
|
|
37
|
+
self.monitor_mode = monitor_mode
|
|
38
|
+
self.block_ip = block_ip
|
|
39
|
+
self.rate_limit = rate_limit
|
|
40
|
+
self.dashboard_path = dashboard_path
|
|
41
|
+
self.block_durantion = block_durantion
|
|
42
|
+
self.recent_blocks_cache = {}
|
|
43
|
+
|
|
44
|
+
self.rules_sqli = [
|
|
45
|
+
r"(\bUNION\b|\bSELECT\b|\bINSERT\b|\bDROP\b)",
|
|
46
|
+
r"' OR '1'='1"
|
|
47
|
+
]
|
|
48
|
+
self.rules_xss = [
|
|
49
|
+
r"<script.*?>.*?</script>",
|
|
50
|
+
r"javascript:"
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
if app is not None:
|
|
54
|
+
self.init_app(app)
|
|
43
55
|
|
|
44
56
|
def init_app(self, app):
|
|
45
57
|
Base.metadata.create_all(engine)
|
|
46
58
|
setup_dashboard(app, self.dashboard_path)
|
|
59
|
+
seed_default_whitelist()
|
|
47
60
|
Admin.create_admin_user(get_session())
|
|
48
61
|
|
|
49
62
|
if not app.secret_key:
|
|
50
|
-
|
|
63
|
+
def create_secret_key():
|
|
64
|
+
mac_address = str(uuid.getnode())
|
|
65
|
+
hostname = socket.gethostname()
|
|
66
|
+
project_salt = "wafahell-security-core-v1"
|
|
67
|
+
fingerprint = f"{mac_address}-{hostname}-{project_salt}"
|
|
68
|
+
return hashlib.sha256(fingerprint.encode()).hexdigest()
|
|
69
|
+
app.secret_key = create_secret_key()
|
|
51
70
|
|
|
52
71
|
@app.before_request
|
|
53
72
|
def create_session():
|
|
@@ -68,6 +87,8 @@ class WafaHell:
|
|
|
68
87
|
|
|
69
88
|
@app.before_request
|
|
70
89
|
def waf_check():
|
|
90
|
+
if self.check_whitelist(req):
|
|
91
|
+
return
|
|
71
92
|
self.verify_client_blocked(req)
|
|
72
93
|
self.verify_rate_limit(req)
|
|
73
94
|
is_malicious, attack_local, payload, attack_type = self.is_malicious(req)
|
|
@@ -75,6 +96,8 @@ class WafaHell:
|
|
|
75
96
|
if not is_malicious:
|
|
76
97
|
return
|
|
77
98
|
|
|
99
|
+
self.log_attack(req, attack_type, payload, attack_local)
|
|
100
|
+
|
|
78
101
|
if not self.monitor_mode:
|
|
79
102
|
self.log.warning(self.parse_req(req, payload, attack_local, attack_type))
|
|
80
103
|
self.block_ip_address(req.remote_addr, req.headers.get("User-Agent", "unknown"))
|
|
@@ -119,23 +142,91 @@ class WafaHell:
|
|
|
119
142
|
return response
|
|
120
143
|
|
|
121
144
|
def log_legit_access(self, req):
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
145
|
+
entry = {
|
|
146
|
+
"timestamp": datetime.now(timezone.utc),
|
|
147
|
+
"attack_type": 'INFO',
|
|
148
|
+
"ip": req.remote_addr,
|
|
149
|
+
"path": req.path,
|
|
150
|
+
"method": req.method,
|
|
151
|
+
"level": 'INFO',
|
|
152
|
+
"payload": None, # INFO não tem payload
|
|
153
|
+
"attack_local": None # INFO não tem local de ataque
|
|
154
|
+
}
|
|
155
|
+
# Manda para o gerenciador de lote
|
|
156
|
+
self._push_to_batch(entry)
|
|
157
|
+
|
|
158
|
+
def log_attack(self, req, attack_type, payload, attack_local):
|
|
159
|
+
# Decodifica o payload para ficar legível no banco
|
|
160
|
+
safe_payload = unquote(payload) if payload else "---"
|
|
161
|
+
|
|
162
|
+
entry = {
|
|
163
|
+
"timestamp": datetime.now(timezone.utc),
|
|
164
|
+
"attack_type": attack_type, # Ex: SQLI, XSS
|
|
165
|
+
"ip": req.remote_addr,
|
|
166
|
+
"path": req.path,
|
|
167
|
+
"method": req.method,
|
|
168
|
+
"level": 'WARNING',
|
|
169
|
+
"payload": safe_payload,
|
|
170
|
+
"attack_local": attack_local # Ex: URL, BODY, HEADER
|
|
171
|
+
}
|
|
172
|
+
self.log_block(req)
|
|
173
|
+
self._push_to_batch(entry)
|
|
174
|
+
|
|
175
|
+
def log_block(self, req):
|
|
176
|
+
"""
|
|
177
|
+
Registra especificamente bloqueios de conexão (Blacklist/Manual Block).
|
|
178
|
+
"""
|
|
179
|
+
entry = {
|
|
180
|
+
"timestamp": datetime.now(timezone.utc),
|
|
181
|
+
"attack_type": "IP BLOCK",
|
|
182
|
+
"ip": req.remote_addr,
|
|
183
|
+
"path": req.path,
|
|
184
|
+
"method": req.method,
|
|
185
|
+
"level": 'INFO',
|
|
186
|
+
"payload": "---",
|
|
187
|
+
"attack_local": "WAF"
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
# Envia para o mesmo lote que os ataques normais
|
|
191
|
+
self._push_to_batch(entry)
|
|
192
|
+
|
|
193
|
+
def _push_to_batch(self, log_entry):
|
|
194
|
+
"""
|
|
195
|
+
Função central que gerencia o Buffer de Logs (Memória -> Banco).
|
|
196
|
+
Usada tanto por logs legítimos quanto por ataques.
|
|
197
|
+
"""
|
|
198
|
+
# 1. Recupera os logs pendentes do cache global
|
|
199
|
+
pending_logs = waf_cache.get('pending_logs_batch', default=[])
|
|
200
|
+
pending_logs.append(log_entry)
|
|
201
|
+
|
|
202
|
+
# 2. Verifica Gatilhos: 50 logs OU 3 segundos (reduzi de 10 pra 3 pra ficar mais "real time")
|
|
203
|
+
current_time = time.time()
|
|
204
|
+
last_flush = waf_cache.get('last_log_flush_time', default=0)
|
|
205
|
+
|
|
206
|
+
if len(pending_logs) >= 50 or (current_time - last_flush) > 3:
|
|
207
|
+
|
|
208
|
+
# Função interna de flush (Abre sessão dedicada para o lote)
|
|
209
|
+
session = get_session()
|
|
210
|
+
try:
|
|
211
|
+
# bulk_insert_mappings é OTIMIZADO para grandes volumes
|
|
212
|
+
session.bulk_insert_mappings(WafLog, pending_logs)
|
|
213
|
+
session.commit()
|
|
214
|
+
|
|
215
|
+
# Sucesso: Limpa o cache
|
|
216
|
+
waf_cache.set('pending_logs_batch', [], expire=60)
|
|
217
|
+
waf_cache.set('last_log_flush_time', current_time, expire=60)
|
|
218
|
+
except Exception as e:
|
|
219
|
+
session.rollback()
|
|
220
|
+
# Não usamos self.log.error aqui para não criar loop infinito se o erro for no logger
|
|
221
|
+
print(f" [ERRO CRÍTICO] Falha no Batch Insert do WAF: {e}")
|
|
222
|
+
|
|
223
|
+
# Mantém os dados no cache para tentar na próxima requisição
|
|
224
|
+
waf_cache.set('pending_logs_batch', pending_logs, expire=60)
|
|
225
|
+
finally:
|
|
226
|
+
session.close()
|
|
227
|
+
else:
|
|
228
|
+
# Apenas atualiza a lista no cache esperando o gatilho
|
|
229
|
+
waf_cache.set('pending_logs_batch', pending_logs, expire=60)
|
|
139
230
|
|
|
140
231
|
def detect_attack(self, data: str) -> bool:
|
|
141
232
|
for pattern in self.rules_xss:
|
|
@@ -190,89 +281,57 @@ class WafaHell:
|
|
|
190
281
|
return False, None, None, None
|
|
191
282
|
|
|
192
283
|
def verify_client_blocked(self, req) -> None:
|
|
193
|
-
|
|
284
|
+
ip = req.remote_addr
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------
|
|
287
|
+
# 1. FAST PATH: Cache Check (Memória/Disco)
|
|
288
|
+
# ---------------------------------------------------------
|
|
289
|
+
# Se o cache diz que está bloqueado, abortamos imediatamente.
|
|
290
|
+
# Isso economiza 99% das queries de SELECT durante um ataque (fuzzing).
|
|
291
|
+
if waf_cache.get(f"blocked_{ip}"):
|
|
292
|
+
abort(self.block_code)
|
|
293
|
+
|
|
294
|
+
# ---------------------------------------------------------
|
|
295
|
+
# 2. SLOW PATH: Database Check
|
|
296
|
+
# ---------------------------------------------------------
|
|
297
|
+
# Só chegamos aqui se o IP não estiver no cache.
|
|
298
|
+
# Pode ser um IP limpo OU um IP bloqueado cujo cache expirou (TTL).
|
|
299
|
+
|
|
300
|
+
session = req.session # Reutiliza a sessão da request (Fundamental!)
|
|
301
|
+
|
|
194
302
|
try:
|
|
195
|
-
client_blocked = session.query(Blocked).filter_by(
|
|
196
|
-
ip=req.remote_addr,
|
|
197
|
-
).first()
|
|
303
|
+
client_blocked = session.query(Blocked).filter_by(ip=ip).first()
|
|
198
304
|
|
|
199
305
|
if client_blocked:
|
|
200
|
-
|
|
306
|
+
# Normalização de fuso horário
|
|
201
307
|
now = datetime.now(timezone.utc) if client_blocked.blocked_until.tzinfo else datetime.utcnow()
|
|
202
308
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
#
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
309
|
+
# Caso 1: Ainda está bloqueado
|
|
310
|
+
if client_blocked.blocked_until > now:
|
|
311
|
+
# RE-AQUECIMENTO DO CACHE:
|
|
312
|
+
# O bloqueio ainda é válido no banco, então renovamos o cache por mais 60s.
|
|
313
|
+
# Assim, as próximas requisições desse IP vão cair no Fast Path acima.
|
|
314
|
+
waf_cache.set(f"blocked_{ip}", True, expire=60)
|
|
315
|
+
abort(self.block_code)
|
|
316
|
+
|
|
317
|
+
# Caso 2: O bloqueio expirou (Desbloqueio)
|
|
318
|
+
else:
|
|
213
319
|
try:
|
|
214
320
|
session.delete(client_blocked)
|
|
215
321
|
session.commit()
|
|
322
|
+
self.log.info(f"[UNBLOCKED] Bloqueio do IP {ip} expirou.")
|
|
216
323
|
|
|
217
|
-
#
|
|
218
|
-
|
|
219
|
-
|
|
324
|
+
# Garante que não sobrou lixo no cache
|
|
325
|
+
waf_cache.delete(f"blocked_{ip}")
|
|
326
|
+
# Remove travas de bloqueio antigas se existirem
|
|
327
|
+
waf_cache.delete(f"blocking_lock_{ip}")
|
|
220
328
|
|
|
221
|
-
self.log.info(f"[UNBLOCKED] IP {req.remote_addr} bloqueio expirou.")
|
|
222
329
|
except Exception as e:
|
|
330
|
+
# Se der erro de concorrência (outro thread já deletou), apenas ignora
|
|
223
331
|
session.rollback()
|
|
224
|
-
self.recent_blocks_cache.pop(cache_key, None)
|
|
225
|
-
raise e
|
|
226
|
-
return
|
|
227
|
-
|
|
228
|
-
# Se chegou aqui, ainda está bloqueado
|
|
229
|
-
abort(self.block_code)
|
|
230
|
-
|
|
231
|
-
except OperationalError:
|
|
232
|
-
session.rollback()
|
|
233
|
-
abort(self.block_code)
|
|
234
|
-
|
|
235
|
-
try:
|
|
236
|
-
|
|
237
|
-
client_blocked = session.query(Blocked).filter_by(
|
|
238
|
-
ip=req.remote_addr,
|
|
239
|
-
user_agent=req.headers.get("User-Agent")
|
|
240
|
-
).first()
|
|
241
|
-
|
|
242
|
-
if not client_blocked:
|
|
243
|
-
return
|
|
244
|
-
|
|
245
|
-
# Normaliza o tempo para comparação
|
|
246
|
-
now = datetime.now(timezone.utc) if client_blocked.blocked_until.tzinfo else datetime.utcnow()
|
|
247
|
-
|
|
248
|
-
if client_blocked.blocked_until <= now:
|
|
249
|
-
# --- TRAVA DE DESBLOQUEIO (Anti-Race Condition) ---
|
|
250
|
-
# Usamos um marcador no cache para saber se alguém já está desbloqueando este IP
|
|
251
|
-
cache_key = f"unblocking_{req.remote_addr}"
|
|
252
|
-
if cache_key in self.recent_blocks_cache:
|
|
253
|
-
return # Outra thread já está limpando este IP, apenas saia
|
|
254
|
-
|
|
255
|
-
self.recent_blocks_cache[cache_key] = True
|
|
256
|
-
# --------------------------------------------------
|
|
257
|
-
|
|
258
|
-
try:
|
|
259
|
-
session.delete(client_blocked)
|
|
260
|
-
session.commit()
|
|
261
|
-
|
|
262
|
-
# Limpa os caches de controle deste IP
|
|
263
|
-
self.recent_blocks_cache.pop(req.remote_addr, None)
|
|
264
|
-
self.recent_blocks_cache.pop(cache_key, None)
|
|
265
|
-
|
|
266
|
-
self.log.info(f"[UNBLOCKED] IP {req.remote_addr} bloqueio expirou.")
|
|
267
|
-
except Exception as e:
|
|
268
|
-
session.rollback()
|
|
269
|
-
self.recent_blocks_cache.pop(cache_key, None)
|
|
270
|
-
raise e
|
|
271
|
-
return
|
|
272
|
-
|
|
273
|
-
abort(self.block_code)
|
|
274
332
|
|
|
275
333
|
except OperationalError:
|
|
334
|
+
# Se o banco estiver travado/ocupado, abortamos por segurança (Fail Closed)
|
|
276
335
|
session.rollback()
|
|
277
336
|
abort(self.block_code)
|
|
278
337
|
|
|
@@ -280,49 +339,58 @@ class WafaHell:
|
|
|
280
339
|
if not self.block_ip:
|
|
281
340
|
return
|
|
282
341
|
|
|
283
|
-
# 1.
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
342
|
+
# 1. TRAVA DE CACHE (A Salvação do Fuzzing)
|
|
343
|
+
# Verifica se já existe um processo de bloqueio rodando para este IP.
|
|
344
|
+
# Isso impede que 50 threads do ffuf tentem fazer INSERT ao mesmo tempo.
|
|
345
|
+
cache_key = f"blocking_lock_{ip}"
|
|
346
|
+
|
|
347
|
+
if waf_cache.get(cache_key):
|
|
348
|
+
return # Já está sendo bloqueado por outra thread, aborta.
|
|
349
|
+
|
|
350
|
+
# Cria a trava por 5 segundos (tempo mais que suficiente para o insert ocorrer)
|
|
351
|
+
waf_cache.set(cache_key, True, expire=5)
|
|
289
352
|
|
|
290
|
-
|
|
353
|
+
# 2. REUTILIZAÇÃO DE SESSÃO
|
|
354
|
+
# Usamos a sessão que já está aberta na requisição atual.
|
|
355
|
+
session = req.session
|
|
356
|
+
|
|
291
357
|
try:
|
|
292
|
-
#
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
358
|
+
# Verifica se já existe na tabela (Query leve)
|
|
359
|
+
exists_block = session.query(Blocked).filter_by(ip=ip).first()
|
|
360
|
+
|
|
361
|
+
if not exists_block:
|
|
362
|
+
now = datetime.now(timezone.utc)
|
|
363
|
+
until = now + timedelta(minutes=self.block_durantion)
|
|
364
|
+
|
|
365
|
+
# Atenção ao formato do blocked_at se o seu Model esperar String
|
|
366
|
+
# Se no model for DateTime, use 'now'. Se for String, use 'now.strftime...'
|
|
367
|
+
new_block = Blocked(
|
|
368
|
+
ip=ip,
|
|
369
|
+
user_agent=user_agent or "unknown",
|
|
370
|
+
blocked_at=now.strftime("%H:%M:%S"),
|
|
371
|
+
blocked_until=until
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
session.add(new_block)
|
|
375
|
+
session.commit()
|
|
376
|
+
|
|
377
|
+
# Log no arquivo/console
|
|
378
|
+
self.log.warning(f"[BLOCKED] IP: {ip} bloqueado por {self.block_durantion} min.")
|
|
379
|
+
|
|
380
|
+
# 3. PRÉ-AQUECIMENTO DE CACHE
|
|
381
|
+
# Já avisa o cache que este IP está bloqueado.
|
|
382
|
+
# A próxima requisição vai bater no verify_client_blocked, ler o cache e ser barrada sem tocar no banco.
|
|
383
|
+
waf_cache.set(f"blocked_{ip}", True, expire=60)
|
|
384
|
+
|
|
321
385
|
except Exception as e:
|
|
322
386
|
session.rollback()
|
|
323
387
|
self.log.error(f"Erro ao persistir bloqueio: {e}")
|
|
324
|
-
|
|
325
|
-
|
|
388
|
+
# Se deu erro, removemos a trava para tentar novamente na próxima
|
|
389
|
+
waf_cache.delete(cache_key)
|
|
390
|
+
|
|
391
|
+
# IMPORTANTE:
|
|
392
|
+
# Não usamos 'finally: session.close()' aqui!
|
|
393
|
+
# Quem fecha é o @app.teardown_request no final do ciclo.
|
|
326
394
|
|
|
327
395
|
def verify_rate_limit(self, req) -> None:
|
|
328
396
|
if self.rate_limit:
|
|
@@ -330,17 +398,66 @@ class WafaHell:
|
|
|
330
398
|
ua = req.headers.get("User-Agent", "unknown")
|
|
331
399
|
|
|
332
400
|
if limiter.is_rate_limited(ip, ua):
|
|
401
|
+
self.log_attack(
|
|
402
|
+
req=req,
|
|
403
|
+
attack_type="RATE LIMIT",
|
|
404
|
+
payload="Too Many Requests",
|
|
405
|
+
attack_local="Rate Limiter"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
self.log.warning(f"[RATE LIMIT] IP: {ip} exceeded limit.")
|
|
409
|
+
|
|
333
410
|
if self.monitor_mode:
|
|
334
411
|
return
|
|
335
412
|
|
|
336
|
-
|
|
337
413
|
if self.block_ip:
|
|
338
414
|
self.block_ip_address(ip, ua)
|
|
339
|
-
self.log.warning(f"[RATE LIMIT] IP: {ip} exceeded limit.")
|
|
340
|
-
self.log.warning(f"[BLOCKED] IP: {ip}, UA: {ua}")
|
|
341
415
|
abort(self.block_code)
|
|
342
416
|
|
|
343
|
-
|
|
417
|
+
|
|
418
|
+
def check_whitelist(self, req) -> bool:
|
|
419
|
+
ip_str = req.remote_addr
|
|
420
|
+
|
|
421
|
+
# 1. CACHE (Velocidade Extrema)
|
|
422
|
+
# Se esse IP já foi validado antes (seja por faixa ou exato), libera.
|
|
423
|
+
if waf_cache.get(f"whitelist_{ip_str}"):
|
|
424
|
+
return True
|
|
425
|
+
|
|
426
|
+
session = req.session
|
|
427
|
+
try:
|
|
428
|
+
# 2. Busca Exata (Para IPs unitários como 8.8.8.8)
|
|
429
|
+
# É muito rápido.
|
|
430
|
+
if session.query(Whitelist).filter_by(ip=ip_str).first():
|
|
431
|
+
waf_cache.set(f"whitelist_{ip_str}", True, expire=3600)
|
|
432
|
+
return True
|
|
433
|
+
|
|
434
|
+
# 3. Busca por Faixas (CIDR)
|
|
435
|
+
# Só executamos isso se não achou match exato.
|
|
436
|
+
# Trazemos apenas as faixas que contêm "/" para não trazer IPs soltos
|
|
437
|
+
cidr_ranges = session.query(Whitelist.ip).filter(Whitelist.ip.like('%/%')).all()
|
|
438
|
+
|
|
439
|
+
if not cidr_ranges:
|
|
440
|
+
return False
|
|
441
|
+
|
|
442
|
+
user_ip = ipaddress.ip_address(ip_str)
|
|
443
|
+
|
|
444
|
+
for row in cidr_ranges:
|
|
445
|
+
try:
|
|
446
|
+
# Verifica matematicamente se o IP está na rede
|
|
447
|
+
network = ipaddress.ip_network(row.ip, strict=False)
|
|
448
|
+
if user_ip in network:
|
|
449
|
+
# ACHOU!
|
|
450
|
+
# Salva o IP DO USUÁRIO no cache.
|
|
451
|
+
# Na próxima requisição, ele cai no passo 1 e nem passa por aqui.
|
|
452
|
+
waf_cache.set(f"whitelist_{ip_str}", True, expire=3600)
|
|
453
|
+
return True
|
|
454
|
+
except ValueError:
|
|
455
|
+
continue
|
|
456
|
+
|
|
457
|
+
except Exception as e:
|
|
458
|
+
return False
|
|
459
|
+
|
|
460
|
+
return False
|
|
344
461
|
|
|
345
462
|
def parse_req(self, req, payload, attack_local=None, attack_type=None) -> str:
|
|
346
463
|
ip = req.remote_addr
|
wafaHell/model.py
CHANGED
|
@@ -52,6 +52,17 @@ class Blocked(Base):
|
|
|
52
52
|
f"blocked_until='{self.blocked_until}')>"
|
|
53
53
|
)
|
|
54
54
|
|
|
55
|
+
class Whitelist(Base):
|
|
56
|
+
__tablename__ = "whitelist"
|
|
57
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
58
|
+
ip = Column(String, nullable=False, unique=True)
|
|
59
|
+
added_at = Column(
|
|
60
|
+
String,
|
|
61
|
+
nullable=False,
|
|
62
|
+
default=lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
63
|
+
)
|
|
64
|
+
def __repr__(self):
|
|
65
|
+
return f"<Whitelist(ip='{self.ip}', added_at='{self.added_at}')>"
|
|
55
66
|
|
|
56
67
|
class WafLog(Base):
|
|
57
68
|
__tablename__ = "waf_logs"
|